@pond-ts/charts 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/AreaChart.d.ts +85 -0
- package/dist/AreaChart.js +119 -0
- package/dist/BandChart.d.ts +55 -0
- package/dist/BandChart.js +93 -0
- package/dist/BarChart.d.ts +72 -0
- package/dist/BarChart.js +137 -0
- package/dist/BoxPlot.d.ts +77 -0
- package/dist/BoxPlot.js +137 -0
- package/dist/Canvas.d.ts +37 -0
- package/dist/Canvas.js +39 -0
- package/dist/ChartContainer.d.ts +106 -0
- package/dist/ChartContainer.js +306 -0
- package/dist/ChartRow.d.ts +29 -0
- package/dist/ChartRow.js +215 -0
- package/dist/Layers.d.ts +22 -0
- package/dist/Layers.js +399 -0
- package/dist/LineChart.d.ts +60 -0
- package/dist/LineChart.js +105 -0
- package/dist/ScatterChart.d.ts +84 -0
- package/dist/ScatterChart.js +139 -0
- package/dist/TimeAxis.d.ts +9 -0
- package/dist/TimeAxis.js +12 -0
- package/dist/XAxis.d.ts +39 -0
- package/dist/XAxis.js +84 -0
- package/dist/YAxis.d.ts +42 -0
- package/dist/YAxis.js +86 -0
- package/dist/annotations.d.ts +110 -0
- package/dist/annotations.js +459 -0
- package/dist/area.d.ts +54 -0
- package/dist/area.js +186 -0
- package/dist/band.d.ts +31 -0
- package/dist/band.js +57 -0
- package/dist/bars.d.ts +96 -0
- package/dist/bars.js +171 -0
- package/dist/box.d.ts +59 -0
- package/dist/box.js +140 -0
- package/dist/chip.d.ts +23 -0
- package/dist/chip.js +43 -0
- package/dist/cjs-fallback.cjs +16 -0
- package/dist/context.d.ts +362 -0
- package/dist/context.js +5 -0
- package/dist/curve.d.ts +22 -0
- package/dist/curve.js +13 -0
- package/dist/data.d.ts +154 -0
- package/dist/data.js +197 -0
- package/dist/domain.d.ts +19 -0
- package/dist/domain.js +61 -0
- package/dist/encoding.d.ts +89 -0
- package/dist/encoding.js +144 -0
- package/dist/format.d.ts +53 -0
- package/dist/format.js +47 -0
- package/dist/gaps.d.ts +146 -0
- package/dist/gaps.js +209 -0
- package/dist/grid.d.ts +11 -0
- package/dist/grid.js +29 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +34 -0
- package/dist/line.d.ts +46 -0
- package/dist/line.js +88 -0
- package/dist/range.d.ts +15 -0
- package/dist/range.js +27 -0
- package/dist/scatter.d.ts +70 -0
- package/dist/scatter.js +213 -0
- package/dist/select.d.ts +13 -0
- package/dist/select.js +23 -0
- package/dist/slots.d.ts +48 -0
- package/dist/slots.js +64 -0
- package/dist/theme.d.ts +224 -0
- package/dist/theme.js +232 -0
- package/dist/tracker.d.ts +30 -0
- package/dist/tracker.js +47 -0
- package/dist/use-slot-key.d.ts +21 -0
- package/dist/use-slot-key.js +25 -0
- package/dist/viewport.d.ts +20 -0
- package/dist/viewport.js +30 -0
- package/package.json +67 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { SeriesSchema, TimeSeries } from 'pond-ts';
|
|
2
|
+
export interface BarChartProps<S extends SeriesSchema> {
|
|
3
|
+
/**
|
|
4
|
+
* The source series. **Interval / timeRange-keyed** is the primary form — each
|
|
5
|
+
* event's key `[begin, end]` is a bar's x-span. A **point-keyed** (`time`)
|
|
6
|
+
* series is supported too: each bar's width is derived from neighbour spacing
|
|
7
|
+
* (see {@link barsFromTimeSeries}).
|
|
8
|
+
*/
|
|
9
|
+
series: TimeSeries<S>;
|
|
10
|
+
/** Name of the numeric value column for the bar height. */
|
|
11
|
+
column: string;
|
|
12
|
+
/**
|
|
13
|
+
* The series' semantic identifier — what the data _is_ / how it should read.
|
|
14
|
+
* The theme maps it to a {@link BarStyle} (`theme.bar[as] ?? theme.bar.default`).
|
|
15
|
+
* **Omitted ⇒ the `default` style** — `column` is the data, `as` is the
|
|
16
|
+
* identity, and there's no per-component colour override (the single styling
|
|
17
|
+
* channel; restyle via the theme).
|
|
18
|
+
*/
|
|
19
|
+
as?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Which `<YAxis>` (by its `id`) this bar scales against — picks the *scale*,
|
|
22
|
+
* where `as` picks the *style* (separate concerns). **Omitted ⇒ the row's
|
|
23
|
+
* default axis.**
|
|
24
|
+
*/
|
|
25
|
+
axis?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Pixel gap between adjacent bars — each bar's key span is inset by this total
|
|
28
|
+
* (half each side), so neighbours breathe. **Omitted ⇒ the theme's
|
|
29
|
+
* `bar[as].gap`.** A span the gap would invert collapses to the style's
|
|
30
|
+
* `minWidth`, so a too-thin bucket stays visible.
|
|
31
|
+
*/
|
|
32
|
+
gap?: number;
|
|
33
|
+
/**
|
|
34
|
+
* @internal Declaration position among the `<Layers>` children, injected by
|
|
35
|
+
* `Layers` so z-order follows JSX order. Do not set.
|
|
36
|
+
*/
|
|
37
|
+
index?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A bar draw layer: one rectangle per event, spanning the key's `[begin, end]`
|
|
41
|
+
* (inset by `gap`) from the axis baseline to a numeric `column`'s value. Reads
|
|
42
|
+
* the key endpoints + column into a {@link BarSeries}, registers into the
|
|
43
|
+
* enclosing {@link Layers} (scaling against its `axis`), and renders nothing to
|
|
44
|
+
* the DOM — the row draws it. A gap (missing value) is skipped (no bar).
|
|
45
|
+
*
|
|
46
|
+
* **Baseline.** Bars rest on the zero line when the axis domain spans zero (the
|
|
47
|
+
* common all-positive auto-fit case — {@link barExtent} pulls `0` into the
|
|
48
|
+
* domain), or on the axis floor when an explicit `<YAxis min={…}>` sits above
|
|
49
|
+
* zero (see {@link resolveBarBaseline}).
|
|
50
|
+
*
|
|
51
|
+
* **Interaction.** Hover joins the tracker (`sampleAt` → the value of the bar
|
|
52
|
+
* **under the cursor**) and lights that bar (hover-highlight). Click selects the
|
|
53
|
+
* hit bar (`hitTest`); the matching bar — same key **and** this series' `label`,
|
|
54
|
+
* so two series sharing a timestamp don't both light up — draws highlighted
|
|
55
|
+
* (outlined for the committed select, fill-only for the transient hover). Both
|
|
56
|
+
* resolve by **containment**: the tracker by the bar's `[begin, end]` time span
|
|
57
|
+
* (`barIndexAtTime`), the click by the bar's pixel rect (`barAt`) — so the
|
|
58
|
+
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
59
|
+
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
60
|
+
*
|
|
61
|
+
* **Distance domain is deferred** — v1 bars scale on the shared **time** xScale
|
|
62
|
+
* only. estela's distance-domain (records over a monotonic value axis) needs
|
|
63
|
+
* value-axis support that isn't built yet (charts RFC perf section).
|
|
64
|
+
*
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <Layers>
|
|
67
|
+
* <BarChart series={hourlyVolume} column="count" />
|
|
68
|
+
* </Layers>
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare function BarChart<S extends SeriesSchema>({ series, column, as: semantic, axis, gap, index, }: BarChartProps<S>): null;
|
|
72
|
+
//# sourceMappingURL=BarChart.d.ts.map
|
package/dist/BarChart.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
+
import { barsFromTimeSeries } from './data.js';
|
|
3
|
+
import { barAt, barExtent, barIndexAtTime, drawBars, resolveBarBaseline, } from './bars.js';
|
|
4
|
+
import { ContainerContext, LayersContext, } from './context.js';
|
|
5
|
+
import { useSlotKey } from './use-slot-key.js';
|
|
6
|
+
/**
|
|
7
|
+
* A bar draw layer: one rectangle per event, spanning the key's `[begin, end]`
|
|
8
|
+
* (inset by `gap`) from the axis baseline to a numeric `column`'s value. Reads
|
|
9
|
+
* the key endpoints + column into a {@link BarSeries}, registers into the
|
|
10
|
+
* enclosing {@link Layers} (scaling against its `axis`), and renders nothing to
|
|
11
|
+
* the DOM — the row draws it. A gap (missing value) is skipped (no bar).
|
|
12
|
+
*
|
|
13
|
+
* **Baseline.** Bars rest on the zero line when the axis domain spans zero (the
|
|
14
|
+
* common all-positive auto-fit case — {@link barExtent} pulls `0` into the
|
|
15
|
+
* domain), or on the axis floor when an explicit `<YAxis min={…}>` sits above
|
|
16
|
+
* zero (see {@link resolveBarBaseline}).
|
|
17
|
+
*
|
|
18
|
+
* **Interaction.** Hover joins the tracker (`sampleAt` → the value of the bar
|
|
19
|
+
* **under the cursor**) and lights that bar (hover-highlight). Click selects the
|
|
20
|
+
* hit bar (`hitTest`); the matching bar — same key **and** this series' `label`,
|
|
21
|
+
* so two series sharing a timestamp don't both light up — draws highlighted
|
|
22
|
+
* (outlined for the committed select, fill-only for the transient hover). Both
|
|
23
|
+
* resolve by **containment**: the tracker by the bar's `[begin, end]` time span
|
|
24
|
+
* (`barIndexAtTime`), the click by the bar's pixel rect (`barAt`) — so the
|
|
25
|
+
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
26
|
+
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
27
|
+
*
|
|
28
|
+
* **Distance domain is deferred** — v1 bars scale on the shared **time** xScale
|
|
29
|
+
* only. estela's distance-domain (records over a monotonic value axis) needs
|
|
30
|
+
* value-axis support that isn't built yet (charts RFC perf section).
|
|
31
|
+
*
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <Layers>
|
|
34
|
+
* <BarChart series={hourlyVolume} column="count" />
|
|
35
|
+
* </Layers>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function BarChart({ series, column, as: semantic, axis, gap, index = 0, }) {
|
|
39
|
+
const container = useContext(ContainerContext);
|
|
40
|
+
if (container === null) {
|
|
41
|
+
throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
|
|
42
|
+
}
|
|
43
|
+
const layers = useContext(LayersContext);
|
|
44
|
+
if (layers === null) {
|
|
45
|
+
throw new Error('<BarChart> must be rendered inside a <Layers>');
|
|
46
|
+
}
|
|
47
|
+
const bs = useMemo(() => barsFromTimeSeries(series, column), [series, column]);
|
|
48
|
+
// Styling: semantic identifier → theme bar style. The single styling channel.
|
|
49
|
+
const { bar } = container.theme;
|
|
50
|
+
const style = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
|
|
51
|
+
// Series identity for the readout + selection match (the `as` role, else the
|
|
52
|
+
// column name).
|
|
53
|
+
const label = semantic ?? column;
|
|
54
|
+
// The gap prop overrides the theme default; otherwise the style carries it.
|
|
55
|
+
const gapPx = gap ?? style.gap;
|
|
56
|
+
// The current selection, narrowed to what the highlight match needs (key +
|
|
57
|
+
// label). Read here so a selection change re-registers the layer (in the deps)
|
|
58
|
+
// → the data canvas repaints with the highlight. Infrequent (a click).
|
|
59
|
+
const selected = container.selected;
|
|
60
|
+
const selection = useMemo(() => selected === null ? null : { key: selected.key, label: selected.label }, [selected]);
|
|
61
|
+
// The transient hover-highlight, narrowed to the match key (key + label) like
|
|
62
|
+
// the selection. Read here so a hover change re-registers the layer → the data
|
|
63
|
+
// canvas repaints with the lit bar. Deduped in the container, so this only
|
|
64
|
+
// fires on a bar transition (not every pointer move).
|
|
65
|
+
const hoveredMark = container.hovered;
|
|
66
|
+
const hover = useMemo(() => hoveredMark === null
|
|
67
|
+
? null
|
|
68
|
+
: { key: hoveredMark.key, label: hoveredMark.label }, [hoveredMark]);
|
|
69
|
+
const entry = useMemo(() => ({
|
|
70
|
+
layer: {
|
|
71
|
+
yExtent: () => barExtent(bs),
|
|
72
|
+
xKind: 'time',
|
|
73
|
+
xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
|
|
74
|
+
sampleAt: (time) => {
|
|
75
|
+
// The flag belongs to the bar **under the cursor** — the bar whose
|
|
76
|
+
// span `[begin, end]` contains `time` (barIndexAtTime), NOT
|
|
77
|
+
// nearest-by-begin (which flips to the next bar past a wide bar's
|
|
78
|
+
// midpoint, landing the flag on the wrong bar). For a point key the
|
|
79
|
+
// span is the neighbour-derived Voronoi cell (`barsFromTimeSeries`
|
|
80
|
+
// widens `begin === end` into one), so the cells tile the axis and a
|
|
81
|
+
// moving cursor always lands in one. Before the first / after the last
|
|
82
|
+
// bar → no readout, matching the line/area tracker.
|
|
83
|
+
if (bs.length === 0)
|
|
84
|
+
return [];
|
|
85
|
+
const i = barIndexAtTime(bs, time);
|
|
86
|
+
if (i < 0)
|
|
87
|
+
return [];
|
|
88
|
+
const v = bs.y[i];
|
|
89
|
+
if (!Number.isFinite(v))
|
|
90
|
+
return []; // a gap bar (missing value) reads nothing
|
|
91
|
+
// Anchor at the bar's **top-centre** (RFC): the span's centre time
|
|
92
|
+
// `(begin + end) / 2` (the bucket mid for an interval key; the Voronoi
|
|
93
|
+
// cell centre — ~on the point — for a point key), at `yScale(value)` =
|
|
94
|
+
// the bar top. A tall bar (top above the flag stack) drops the staff for
|
|
95
|
+
// free (the shared `s.py > stackBottom` rule).
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
x: (bs.begin[i] + bs.end[i]) / 2,
|
|
99
|
+
value: v,
|
|
100
|
+
color: style.fill,
|
|
101
|
+
label,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
},
|
|
105
|
+
hitTest: (px, py, xScale, yScale) => {
|
|
106
|
+
const baseline = resolveBarBaseline(yScale);
|
|
107
|
+
const hit = barAt(bs, px, py, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
108
|
+
if (hit === null)
|
|
109
|
+
return null;
|
|
110
|
+
const [, begin, value] = hit;
|
|
111
|
+
// key = the bar's begin (its stable identity); colour = the resolved
|
|
112
|
+
// fill; label = this series' identity (so the highlight targets the
|
|
113
|
+
// exact clicked series, not another sharing the timestamp).
|
|
114
|
+
return { key: begin, value, color: style.fill, label };
|
|
115
|
+
},
|
|
116
|
+
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, style, resolveBarBaseline(yScale), gapPx, label, selection, hover),
|
|
117
|
+
},
|
|
118
|
+
axisId: axis,
|
|
119
|
+
index,
|
|
120
|
+
}), [bs, series, column, style, label, gapPx, selection, hover, axis, index]);
|
|
121
|
+
// A stable per-instance slot (see useSlotKey) keeps this layer's z-position
|
|
122
|
+
// fixed across series/style/selection updates (no jump to the front).
|
|
123
|
+
const slot = useSlotKey();
|
|
124
|
+
useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
layers.registerLayer(slot, entry);
|
|
127
|
+
}, [layers, slot, entry]);
|
|
128
|
+
// Also a tracker source: the container fans in this series' value at the
|
|
129
|
+
// cursor for the (outside-the-chart) readout.
|
|
130
|
+
const { registerTrackerSource, unregisterTrackerSource } = container;
|
|
131
|
+
useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
registerTrackerSource(slot, entry.layer);
|
|
134
|
+
}, [registerTrackerSource, slot, entry.layer]);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=BarChart.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SeriesSchema, TimeSeries } from 'pond-ts';
|
|
2
|
+
import { type BoxShape } from './box.js';
|
|
3
|
+
export interface BoxPlotProps<S extends SeriesSchema> {
|
|
4
|
+
/** The source series. Its interval key column supplies the time axis (the box
|
|
5
|
+
* x-span is the key's `[begin, end)`). */
|
|
6
|
+
series: TimeSeries<S>;
|
|
7
|
+
/** Name of the numeric column for the lower whisker end (e.g. `p5` / `min`). */
|
|
8
|
+
lower: string;
|
|
9
|
+
/** Name of the numeric column for the box bottom — first quartile (e.g. `p25`). */
|
|
10
|
+
q1: string;
|
|
11
|
+
/** Name of the numeric column for the median line (e.g. `p50`). */
|
|
12
|
+
median: string;
|
|
13
|
+
/** Name of the numeric column for the box top — third quartile (e.g. `p75`). */
|
|
14
|
+
q3: string;
|
|
15
|
+
/** Name of the numeric column for the upper whisker end (e.g. `p95` / `max`). */
|
|
16
|
+
upper: string;
|
|
17
|
+
/**
|
|
18
|
+
* The box series' semantic identifier — what the spread _is_ (e.g. `latency`).
|
|
19
|
+
* The theme maps it to a {@link BoxStyle} (`theme.box[as] ?? theme.box.default`
|
|
20
|
+
* — box fill/outline, median, whisker). **Omitted ⇒ the `default` box style**;
|
|
21
|
+
* there's no per-component colour/style override (restyle via the theme, the
|
|
22
|
+
* single styling channel).
|
|
23
|
+
*/
|
|
24
|
+
as?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Which `<YAxis>` (by its `id`) this box scales against — the *scale*, where
|
|
27
|
+
* `as` picks the *style*. **Omitted ⇒ the row's default axis.**
|
|
28
|
+
*/
|
|
29
|
+
axis?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Total horizontal inset between adjacent boxes in px (half each side), so they
|
|
32
|
+
* breathe — see `barSpanPx`. **Omitted ⇒ `0`** (boxes fill their interval span
|
|
33
|
+
* edge-to-edge). A box narrower than 1px after the inset collapses to a 1px
|
|
34
|
+
* mark centred in its slot, so a thin bucket stays visible.
|
|
35
|
+
*/
|
|
36
|
+
gap?: number;
|
|
37
|
+
/**
|
|
38
|
+
* How each box renders its spread — `'whisker'` (default; thin stems + caps),
|
|
39
|
+
* `'solid'` (the candlestick look: a light outer bar over the full range with a
|
|
40
|
+
* darker inner q1→q3 box, no stems), or `'none'` (the q1→q3 box only, no spread
|
|
41
|
+
* marks). See {@link BoxShape}.
|
|
42
|
+
*/
|
|
43
|
+
shape?: BoxShape;
|
|
44
|
+
/** Draw the median (centre) line across each box. Always optional; default
|
|
45
|
+
* `true`. (The `median` prop above names the *column*; this toggles the line.) */
|
|
46
|
+
showMedian?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* @internal Declaration position among the `<Layers>` children, injected by
|
|
49
|
+
* `Layers` so z-order follows JSX order. Do not set.
|
|
50
|
+
*/
|
|
51
|
+
index?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A discrete box-and-whisker draw layer — the bar-chart analog of the variance
|
|
55
|
+
* band. Reads five **pre-computed quantile columns** of `series` (typically a
|
|
56
|
+
* `rolling`/`aggregate` percentile pass — the chart does **not** compute them)
|
|
57
|
+
* into a {@link BoxSeries} and draws one box per key: the q1→q3 box, the median
|
|
58
|
+
* line, and whiskers out to lower/upper, over the key's interval x-span. Gap-aware
|
|
59
|
+
* (a key missing any quantile draws nothing) and registers itself into the
|
|
60
|
+
* enclosing {@link Layers}. Renders nothing to the DOM — the row draws it.
|
|
61
|
+
*
|
|
62
|
+
* There's no baseline — a box is a spread, not a bar to a floor; the y-domain
|
|
63
|
+
* auto-fits the whisker reach (lower→upper).
|
|
64
|
+
*
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <Layers>
|
|
67
|
+
* <BoxPlot
|
|
68
|
+
* series={q}
|
|
69
|
+
* lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
|
|
70
|
+
* as="latency"
|
|
71
|
+
* gap={6}
|
|
72
|
+
* />
|
|
73
|
+
* </Layers>
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export declare function BoxPlot<S extends SeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, index, }: BoxPlotProps<S>): null;
|
|
77
|
+
//# sourceMappingURL=BoxPlot.d.ts.map
|
package/dist/BoxPlot.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
+
import { boxFromTimeSeries } from './data.js';
|
|
3
|
+
import { boxExtent, boxIndexAtTime, drawBox, isFiniteBox, } from './box.js';
|
|
4
|
+
import { ContainerContext, LayersContext, } from './context.js';
|
|
5
|
+
import { useSlotKey } from './use-slot-key.js';
|
|
6
|
+
/** Whisker collapse floor (px) — a too-thin box still draws a 1px mark. */
|
|
7
|
+
const MIN_BOX_WIDTH_PX = 1;
|
|
8
|
+
/**
|
|
9
|
+
* A discrete box-and-whisker draw layer — the bar-chart analog of the variance
|
|
10
|
+
* band. Reads five **pre-computed quantile columns** of `series` (typically a
|
|
11
|
+
* `rolling`/`aggregate` percentile pass — the chart does **not** compute them)
|
|
12
|
+
* into a {@link BoxSeries} and draws one box per key: the q1→q3 box, the median
|
|
13
|
+
* line, and whiskers out to lower/upper, over the key's interval x-span. Gap-aware
|
|
14
|
+
* (a key missing any quantile draws nothing) and registers itself into the
|
|
15
|
+
* enclosing {@link Layers}. Renders nothing to the DOM — the row draws it.
|
|
16
|
+
*
|
|
17
|
+
* There's no baseline — a box is a spread, not a bar to a floor; the y-domain
|
|
18
|
+
* auto-fits the whisker reach (lower→upper).
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <Layers>
|
|
22
|
+
* <BoxPlot
|
|
23
|
+
* series={q}
|
|
24
|
+
* lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
|
|
25
|
+
* as="latency"
|
|
26
|
+
* gap={6}
|
|
27
|
+
* />
|
|
28
|
+
* </Layers>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, index = 0, }) {
|
|
32
|
+
const container = useContext(ContainerContext);
|
|
33
|
+
if (container === null) {
|
|
34
|
+
throw new Error('<BoxPlot> must be rendered inside a <ChartContainer>');
|
|
35
|
+
}
|
|
36
|
+
const layers = useContext(LayersContext);
|
|
37
|
+
if (layers === null) {
|
|
38
|
+
throw new Error('<BoxPlot> must be rendered inside a <Layers>');
|
|
39
|
+
}
|
|
40
|
+
const bx = useMemo(() => boxFromTimeSeries(series, { lower, q1, median, q3, upper }), [series, lower, q1, median, q3, upper]);
|
|
41
|
+
// Styling: semantic identifier → theme box style. The single styling channel.
|
|
42
|
+
const { box } = container.theme;
|
|
43
|
+
const style = (semantic !== undefined ? box[semantic] : undefined) ?? box.default;
|
|
44
|
+
const entry = useMemo(() => ({
|
|
45
|
+
layer: {
|
|
46
|
+
yExtent: () => boxExtent(bx),
|
|
47
|
+
xKind: 'time',
|
|
48
|
+
xExtent: () => bx.length === 0 ? null : [bx.x[0], bx.xEnd[bx.length - 1]],
|
|
49
|
+
sampleAt: (time) => {
|
|
50
|
+
// The readout reads the box **under the cursor** (boxIndexAtTime — span
|
|
51
|
+
// containment, not nearest-by-begin which flips past a wide box's
|
|
52
|
+
// midpoint), anchored at the box **centre** `(x + xEnd) / 2`. Outside
|
|
53
|
+
// every box → no readout. Off-chart fan-in only; the in-chart flag is
|
|
54
|
+
// `cursorFlag`.
|
|
55
|
+
if (bx.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
const i = boxIndexAtTime(bx, time);
|
|
58
|
+
if (i < 0)
|
|
59
|
+
return [];
|
|
60
|
+
const at = (bx.x[i] + bx.xEnd[i]) / 2;
|
|
61
|
+
const samples = [];
|
|
62
|
+
// The median is the primary readout (median colour); the four quantile
|
|
63
|
+
// edges ride the whisker colour, each labelled by its own column. A
|
|
64
|
+
// single non-finite quantile is omitted (a gap key yields nothing — all
|
|
65
|
+
// five missing — but a malformed partial set still reads what it has).
|
|
66
|
+
push(samples, at, bx.upper[i], style.whisker, upper);
|
|
67
|
+
push(samples, at, bx.q3[i], style.whisker, q3);
|
|
68
|
+
push(samples, at, bx.median[i], style.median, median);
|
|
69
|
+
push(samples, at, bx.q1[i], style.whisker, q1);
|
|
70
|
+
push(samples, at, bx.lower[i], style.whisker, lower);
|
|
71
|
+
return samples;
|
|
72
|
+
},
|
|
73
|
+
cursorFlag: (time) => {
|
|
74
|
+
// The in-chart `flag`: all five values on **one** flag at the box's
|
|
75
|
+
// top-centre. The staff rises from `upper` (the mark's top); the values
|
|
76
|
+
// run high→low across one horizontal row (Layers renders them
|
|
77
|
+
// left→right), each coloured to its box piece. All-or-nothing — a gap
|
|
78
|
+
// box (any quantile non-finite, not drawn) shows no flag.
|
|
79
|
+
if (bx.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
const i = boxIndexAtTime(bx, time);
|
|
82
|
+
if (i < 0 || !isFiniteBox(bx, i))
|
|
83
|
+
return null;
|
|
84
|
+
return {
|
|
85
|
+
x: (bx.x[i] + bx.xEnd[i]) / 2,
|
|
86
|
+
topValue: bx.upper[i],
|
|
87
|
+
lines: [
|
|
88
|
+
{ value: bx.upper[i], color: style.whisker, label: upper },
|
|
89
|
+
{ value: bx.q3[i], color: style.whisker, label: q3 },
|
|
90
|
+
{ value: bx.median[i], color: style.median, label: median },
|
|
91
|
+
{ value: bx.q1[i], color: style.whisker, label: q1 },
|
|
92
|
+
{ value: bx.lower[i], color: style.whisker, label: lower },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian),
|
|
97
|
+
},
|
|
98
|
+
axisId: axis,
|
|
99
|
+
index,
|
|
100
|
+
}), [
|
|
101
|
+
bx,
|
|
102
|
+
series,
|
|
103
|
+
lower,
|
|
104
|
+
q1,
|
|
105
|
+
median,
|
|
106
|
+
q3,
|
|
107
|
+
upper,
|
|
108
|
+
style,
|
|
109
|
+
gap,
|
|
110
|
+
shape,
|
|
111
|
+
showMedian,
|
|
112
|
+
axis,
|
|
113
|
+
index,
|
|
114
|
+
]);
|
|
115
|
+
// Stable per-instance slot (see useSlotKey): keeps this box's z-position +
|
|
116
|
+
// identity across prop updates; the injected index drives the sort.
|
|
117
|
+
const slot = useSlotKey();
|
|
118
|
+
useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
layers.registerLayer(slot, entry);
|
|
121
|
+
}, [layers, slot, entry]);
|
|
122
|
+
// Also a tracker source: the container fans in the box quantiles at the cursor
|
|
123
|
+
// for the (outside-the-chart) readout.
|
|
124
|
+
const { registerTrackerSource, unregisterTrackerSource } = container;
|
|
125
|
+
useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
registerTrackerSource(slot, entry.layer);
|
|
128
|
+
}, [registerTrackerSource, slot, entry.layer]);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/** Append a tracker sample for a quantile, skipping a non-finite value. */
|
|
132
|
+
function push(out, x, value, color, label) {
|
|
133
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
134
|
+
out.push({ x, value, color, label });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=BoxPlot.js.map
|
package/dist/Canvas.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type CSSProperties } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* A draw callback. The 2D context it receives is already transformed to device
|
|
4
|
+
* pixels, so draw in **CSS-pixel coordinates** (`0..width`, `0..height`) and it
|
|
5
|
+
* renders crisply at any device-pixel ratio. The context is freshly transformed
|
|
6
|
+
* and cleared before each invocation.
|
|
7
|
+
*/
|
|
8
|
+
export type CanvasDraw = (ctx: CanvasRenderingContext2D, width: number, height: number) => void;
|
|
9
|
+
export interface CanvasProps {
|
|
10
|
+
/** CSS width in pixels. */
|
|
11
|
+
width: number;
|
|
12
|
+
/** CSS height in pixels. */
|
|
13
|
+
height: number;
|
|
14
|
+
/** Synchronous draw callback, invoked after every (re)size or prop change. */
|
|
15
|
+
draw: CanvasDraw;
|
|
16
|
+
/**
|
|
17
|
+
* Device-pixel-ratio override (defaults to `window.devicePixelRatio || 1`).
|
|
18
|
+
* Exposed so tests can pin a deterministic backing-buffer size; production
|
|
19
|
+
* callers leave it unset.
|
|
20
|
+
*/
|
|
21
|
+
dpr?: number;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The DPR-aware `<canvas>` primitive every chart draw layer sits on. It sizes
|
|
27
|
+
* the backing buffer to `width*dpr × height*dpr`, keeps the CSS box at
|
|
28
|
+
* `width × height`, applies `setTransform(dpr, …)` so the {@link CanvasDraw}
|
|
29
|
+
* callback works in CSS-pixel coordinates, clears, and calls `draw`.
|
|
30
|
+
*
|
|
31
|
+
* Drawing runs in `useLayoutEffect` (synchronous, before paint) so there is no
|
|
32
|
+
* flash of an unsized or empty canvas. Setting `canvas.width`/`height` resets
|
|
33
|
+
* all context state, so the transform is re-applied on every run — see trap #7
|
|
34
|
+
* in `docs/rfcs/charts.md`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function Canvas({ width, height, draw, dpr, className, style, }: CanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
37
|
+
//# sourceMappingURL=Canvas.d.ts.map
|
package/dist/Canvas.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* The DPR-aware `<canvas>` primitive every chart draw layer sits on. It sizes
|
|
5
|
+
* the backing buffer to `width*dpr × height*dpr`, keeps the CSS box at
|
|
6
|
+
* `width × height`, applies `setTransform(dpr, …)` so the {@link CanvasDraw}
|
|
7
|
+
* callback works in CSS-pixel coordinates, clears, and calls `draw`.
|
|
8
|
+
*
|
|
9
|
+
* Drawing runs in `useLayoutEffect` (synchronous, before paint) so there is no
|
|
10
|
+
* flash of an unsized or empty canvas. Setting `canvas.width`/`height` resets
|
|
11
|
+
* all context state, so the transform is re-applied on every run — see trap #7
|
|
12
|
+
* in `docs/rfcs/charts.md`.
|
|
13
|
+
*/
|
|
14
|
+
export function Canvas({ width, height, draw, dpr, className, style, }) {
|
|
15
|
+
const ref = useRef(null);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
const canvas = ref.current;
|
|
18
|
+
if (!canvas)
|
|
19
|
+
return;
|
|
20
|
+
const ratio = dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1);
|
|
21
|
+
// Resizing the backing buffer clears it and resets context state, so this
|
|
22
|
+
// must happen before the transform + draw.
|
|
23
|
+
canvas.width = Math.round(width * ratio);
|
|
24
|
+
canvas.height = Math.round(height * ratio);
|
|
25
|
+
const ctx = canvas.getContext('2d');
|
|
26
|
+
if (!ctx)
|
|
27
|
+
return; // SSR / headless environments without a 2D backend
|
|
28
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
29
|
+
ctx.clearRect(0, 0, width, height);
|
|
30
|
+
draw(ctx, width, height);
|
|
31
|
+
}, [width, height, dpr, draw]);
|
|
32
|
+
return (_jsx("canvas", { ref: ref, className: className, style: {
|
|
33
|
+
width: `${width}px`,
|
|
34
|
+
height: `${height}px`,
|
|
35
|
+
display: 'block',
|
|
36
|
+
...style,
|
|
37
|
+
} }));
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=Canvas.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { TimeRange } from 'pond-ts';
|
|
3
|
+
import { type CursorMode, type SelectInfo, type TrackerInfo } from './context.js';
|
|
4
|
+
import { type AxisFormat } from './format.js';
|
|
5
|
+
import { type ChartTheme } from './theme.js';
|
|
6
|
+
export interface ChartContainerProps {
|
|
7
|
+
/**
|
|
8
|
+
* The shared x **domain** `[begin, end]` — a tuple, or a `TimeRange`
|
|
9
|
+
* (`series.timeRange()`). Units follow the data: epoch-ms for a time axis,
|
|
10
|
+
* the value units (distance, …) for a value axis. **Omit to auto-fit** to the
|
|
11
|
+
* rows' extents. The axis *kind* is never taken from here — it's inferred from
|
|
12
|
+
* the data — so a tuple stays a time domain on a time chart.
|
|
13
|
+
*/
|
|
14
|
+
range?: readonly [number, number] | TimeRange;
|
|
15
|
+
/** Total width in CSS pixels (plot + axis gutters). */
|
|
16
|
+
width: number;
|
|
17
|
+
/** Vertical space between rows in CSS pixels (not under the axis). Default 0. */
|
|
18
|
+
rowGap?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Auto-render the shared x axis under the rows. **Default `true`.** Set
|
|
21
|
+
* `false` for a bare plot (a sparkline), or when you place your own `<XAxis>`
|
|
22
|
+
* child (e.g. with a label, custom ticks, or on `side="top"`). Named
|
|
23
|
+
* `showAxis` (not `axis`) to avoid clashing with a layer's `axis` prop, which
|
|
24
|
+
* picks *which* `<YAxis>` it scales against — a different axis entirely.
|
|
25
|
+
*/
|
|
26
|
+
showAxis?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Controlled tracker position (epoch ms) — pins the synced crosshair across
|
|
29
|
+
* rows. Omit for uncontrolled (the chart tracks the pointer itself); pass
|
|
30
|
+
* `null` to force it hidden. See {@link onTrackerChanged}.
|
|
31
|
+
*/
|
|
32
|
+
trackerPosition?: number | null;
|
|
33
|
+
/**
|
|
34
|
+
* In-chart cursor presentation — the default for all rows (a row may override
|
|
35
|
+
* via `<ChartRow cursor>`). **Default `'line'`** — the synced vertical line,
|
|
36
|
+
* with values surfaced *outside* the chart via {@link onTrackerChanged}.
|
|
37
|
+
* `'point'` / `'inline'` / `'flag'` add per-series marks; `'none'` hides it.
|
|
38
|
+
* See {@link CursorMode}.
|
|
39
|
+
*/
|
|
40
|
+
cursor?: CursorMode;
|
|
41
|
+
/**
|
|
42
|
+
* Fires on pointer move with the hovered time + every series' value there (so
|
|
43
|
+
* you can render a readout outside the chart), and `null` on leave.
|
|
44
|
+
*/
|
|
45
|
+
onTrackerChanged?: (info: TrackerInfo | null) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Controlled selection — the selected mark (echo the `onSelect` arg back), or
|
|
48
|
+
* `null`. **Omitted ⇒ uncontrolled** (a click on a selectable layer manages it
|
|
49
|
+
* internally; pass `null` to force nothing selected). Selectable layers
|
|
50
|
+
* (`BarChart`, `BoxPlot`, `ScatterChart`) highlight the mark matching both its
|
|
51
|
+
* key and series — so two series sharing a timestamp don't both light up.
|
|
52
|
+
*/
|
|
53
|
+
selected?: SelectInfo | null;
|
|
54
|
+
/**
|
|
55
|
+
* Fires when a selectable layer's mark is clicked, with the hit mark, or `null`
|
|
56
|
+
* when a click misses every mark (clears the selection). Notification only —
|
|
57
|
+
* works in both controlled and uncontrolled mode.
|
|
58
|
+
*/
|
|
59
|
+
onSelect?: (hit: SelectInfo | null) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Enable pan/zoom: drag the plot to pan the time range, wheel to zoom around
|
|
62
|
+
* the cursor. **Default off** — so it doesn't capture drag/scroll unless asked.
|
|
63
|
+
*/
|
|
64
|
+
panZoom?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Controlled view range — fires on pan/zoom with the new `[start, end]`. Wire
|
|
67
|
+
* it back to `range` for a controlled chart; omit for uncontrolled (the
|
|
68
|
+
* container holds the view internally). **Uncontrolled + `panZoom` seeds the
|
|
69
|
+
* internal view from `range` whenever it isn't actively holding one — so
|
|
70
|
+
* toggling `panZoom` on, or a controlled→uncontrolled switch, starts from the
|
|
71
|
+
* current range, not the mount-time one. Once uncontrolled, later `range`
|
|
72
|
+
* changes are ignored so they can't fight the user's pan. To drive the range
|
|
73
|
+
* externally — or to follow a live sliding window — use controlled mode (this
|
|
74
|
+
* callback).**
|
|
75
|
+
*/
|
|
76
|
+
onTimeRangeChange?: (range: [number, number]) => void;
|
|
77
|
+
/** Zoom-in floor — the minimum visible duration in ms. Default `1`. */
|
|
78
|
+
minDuration?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Show the cursor's time atop the in-chart readout (when a row's `cursor` draws
|
|
81
|
+
* one). **Default `false`.** Formatted by {@link timeFormat} to match the time
|
|
82
|
+
* axis.
|
|
83
|
+
*/
|
|
84
|
+
cursorTime?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Time-axis value formatting — a d3 time specifier string (e.g. `'%H:%M'`) or a
|
|
87
|
+
* `(epochMs) => string` function ({@link AxisFormat}); applies to both the time
|
|
88
|
+
* axis labels and the cursor-time readout. **Omitted ⇒ d3's multi-scale time
|
|
89
|
+
* format** (`12 PM`, `12:10`, …).
|
|
90
|
+
*/
|
|
91
|
+
timeFormat?: AxisFormat;
|
|
92
|
+
/** Visual theme for all rows; defaults to {@link defaultTheme}. */
|
|
93
|
+
theme?: ChartTheme;
|
|
94
|
+
children?: ReactNode;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The top of the chart layout (react-timeseries-charts-style). Owns the shared
|
|
98
|
+
* **x geometry**: it collects each row's per-slot gutter widths, reserves each
|
|
99
|
+
* slot's max across rows (so the innermost axis aligns column-by-column and
|
|
100
|
+
* every row's plot left-aligns), and from the slot sums derives `plotWidth` and
|
|
101
|
+
* the shared time `xScale`. It renders its rows (separated by `rowGap`) then one
|
|
102
|
+
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
103
|
+
* (`<YAxis>`).
|
|
104
|
+
*/
|
|
105
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
106
|
+
//# sourceMappingURL=ChartContainer.d.ts.map
|