@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
package/dist/line.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { line as d3line, curveLinear } from 'd3-shape';
|
|
2
|
+
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
3
|
+
/**
|
|
4
|
+
* The y-scale's domain lower bound (the axis floor) in pixels — where the
|
|
5
|
+
* `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
|
|
6
|
+
* `ScaleLinear` (it carries `.domain()`); read the bound through a localized,
|
|
7
|
+
* documented shape rather than widening the draw contract to d3-scale. Falls
|
|
8
|
+
* back to `0` if the scale exposes no domain.
|
|
9
|
+
*/
|
|
10
|
+
export function baselinePxFromScale(yScale) {
|
|
11
|
+
const d = yScale.domain?.();
|
|
12
|
+
return yScale(d && d.length > 0 ? d[0] : 0);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The `[min, max]` of the **finite** values in `cs.y`, or `null` if none are
|
|
16
|
+
* finite. NaN (the gap signal) is ignored, so a coast doesn't drag the domain.
|
|
17
|
+
*/
|
|
18
|
+
export function yExtent(cs) {
|
|
19
|
+
let min = Infinity;
|
|
20
|
+
let max = -Infinity;
|
|
21
|
+
for (let i = 0; i < cs.length; i += 1) {
|
|
22
|
+
const v = cs.y[i];
|
|
23
|
+
if (Number.isFinite(v)) {
|
|
24
|
+
if (v < min)
|
|
25
|
+
min = v;
|
|
26
|
+
if (v > max)
|
|
27
|
+
max = v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return min === Infinity ? null : [min, max];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Stroke a line for `cs`, mapping data→pixels through `xScale`/`yScale` and
|
|
34
|
+
* connecting points with `curve` (d3-shape; default linear).
|
|
35
|
+
*
|
|
36
|
+
* Built on d3-shape's `line()`. **Gap handling is driven by `gaps`** (a
|
|
37
|
+
* {@link GapMode}, default `'empty'`):
|
|
38
|
+
*
|
|
39
|
+
* - `'empty'` (default) — `.defined(Number.isFinite)`: a non-finite value ends
|
|
40
|
+
* the current subpath and the next finite point starts a fresh one (`moveTo`,
|
|
41
|
+
* not `lineTo`), so a coast reads as a break, not a `lineTo(NaN, …)` bridge
|
|
42
|
+
* (`docs/rfcs/charts.md` trap #2).
|
|
43
|
+
* - `'none'` — interior gaps are linearly interpolated ({@link bridgeGaps}) so
|
|
44
|
+
* the line bridges straight across (real `lineTo`s, robust to leading /
|
|
45
|
+
* trailing gaps, which stay a break). The one non-honest mode.
|
|
46
|
+
* - `'dashed'` / `'step'` / `'fade'` — the **solid** segments break exactly as
|
|
47
|
+
* in `'empty'`, then a second pass draws the inferred bridge across each
|
|
48
|
+
* interior gap: a dashed straight line, a flat dashed line at the average of
|
|
49
|
+
* the edge values, or estela's fade-to-baseline (the axis floor). `dashed` /
|
|
50
|
+
* `step` are drawn faint (`gapConnectorOpacity`); the gap edges are collected
|
|
51
|
+
* by one O(N) walk ({@link collectGapEdges}).
|
|
52
|
+
*
|
|
53
|
+
* The generator writes path ops to `ctx`; we bracket with `beginPath`/`stroke`.
|
|
54
|
+
* `cs.y` (a `Float64Array`) is the datum iterable — `y` reads the value, `x`
|
|
55
|
+
* reads `cs.x[i]` by index, so there's no per-point object allocation.
|
|
56
|
+
*/
|
|
57
|
+
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
|
|
58
|
+
// `none` interpolates interior gaps so the line bridges them; every other mode
|
|
59
|
+
// keeps NaN so d3 breaks the solid path (the inferred bridge, if any, is a
|
|
60
|
+
// separate overlay pass below).
|
|
61
|
+
const ys = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
|
|
62
|
+
const gen = d3line()
|
|
63
|
+
.defined((v) => Number.isFinite(v))
|
|
64
|
+
.x((_, i) => xScale(cs.x[i]))
|
|
65
|
+
.y((v) => yScale(v))
|
|
66
|
+
.curve(curve)
|
|
67
|
+
.context(ctx);
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
gen(ys);
|
|
70
|
+
ctx.strokeStyle = style.color;
|
|
71
|
+
ctx.lineWidth = style.width;
|
|
72
|
+
ctx.stroke();
|
|
73
|
+
// Overlay bridges for the inferred-gap modes. `dashed` / `step` are faint
|
|
74
|
+
// dashed connectors (gapConnectorOpacity); only `fade` drops to the axis floor.
|
|
75
|
+
if (gaps === 'dashed' || gaps === 'step' || gaps === 'fade') {
|
|
76
|
+
const edges = collectGapEdges(cs.length, cs.x, (i) => cs.y[i], xScale, (i) => yScale(cs.y[i]));
|
|
77
|
+
if (gaps === 'dashed') {
|
|
78
|
+
drawGapBridges(ctx, edges, style.color, style.width, gapConnectorOpacity);
|
|
79
|
+
}
|
|
80
|
+
else if (gaps === 'step') {
|
|
81
|
+
drawGapSteps(ctx, edges, style.color, style.width, gapConnectorOpacity);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
drawGapFades(ctx, edges, baselinePxFromScale(yScale), style.color, style.width);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=line.js.map
|
package/dist/range.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel x-span for a range/interval-keyed mark (a bar or a box) spanning
|
|
3
|
+
* `[beginMs, endMs]`, inset by `gapPx` total (half each side) so adjacent marks
|
|
4
|
+
* breathe. Returns `[x0, x1]` with `x0 <= x1`.
|
|
5
|
+
*
|
|
6
|
+
* The chart supplies the range — the key's `begin`/`end` for an interval series,
|
|
7
|
+
* or a derived width for a point-keyed one — plus the gap; this is just the math,
|
|
8
|
+
* so it unit-tests without a canvas. Shared by `BarChart` + `BoxPlot`.
|
|
9
|
+
*
|
|
10
|
+
* A span that the gap would invert (narrower than `minWidthPx` after the inset)
|
|
11
|
+
* collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
|
|
12
|
+
* stays visible and the bar never flips inside-out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number): [number, number];
|
|
15
|
+
//# sourceMappingURL=range.d.ts.map
|
package/dist/range.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel x-span for a range/interval-keyed mark (a bar or a box) spanning
|
|
3
|
+
* `[beginMs, endMs]`, inset by `gapPx` total (half each side) so adjacent marks
|
|
4
|
+
* breathe. Returns `[x0, x1]` with `x0 <= x1`.
|
|
5
|
+
*
|
|
6
|
+
* The chart supplies the range — the key's `begin`/`end` for an interval series,
|
|
7
|
+
* or a derived width for a point-keyed one — plus the gap; this is just the math,
|
|
8
|
+
* so it unit-tests without a canvas. Shared by `BarChart` + `BoxPlot`.
|
|
9
|
+
*
|
|
10
|
+
* A span that the gap would invert (narrower than `minWidthPx` after the inset)
|
|
11
|
+
* collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
|
|
12
|
+
* stays visible and the bar never flips inside-out.
|
|
13
|
+
*/
|
|
14
|
+
export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1) {
|
|
15
|
+
const a = xScale(beginMs);
|
|
16
|
+
const b = xScale(endMs);
|
|
17
|
+
const lo = Math.min(a, b);
|
|
18
|
+
const hi = Math.max(a, b);
|
|
19
|
+
const inset = gapPx / 2;
|
|
20
|
+
const x0 = lo + inset;
|
|
21
|
+
const x1 = hi - inset;
|
|
22
|
+
if (x1 - x0 >= minWidthPx)
|
|
23
|
+
return [x0, x1];
|
|
24
|
+
const mid = (lo + hi) / 2;
|
|
25
|
+
return [mid - minWidthPx / 2, mid + minWidthPx / 2];
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=range.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ChartSeries } from './data.js';
|
|
2
|
+
import type { Scale } from './line.js';
|
|
3
|
+
import type { ScatterStyle } from './theme.js';
|
|
4
|
+
import type { ResolvedEncoding } from './encoding.js';
|
|
5
|
+
import type { SelectInfo } from './context.js';
|
|
6
|
+
/**
|
|
7
|
+
* Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
|
|
8
|
+
* finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
|
|
9
|
+
* search finds the insertion point in O(log N); the two straddling rows are then
|
|
10
|
+
* the only nearest candidates — but either may be a gap (non-finite y), so we
|
|
11
|
+
* step outward from each until we find a real point and keep the closer.
|
|
12
|
+
*
|
|
13
|
+
* Ties go to the **earlier** point (strict `<` on the distance comparison),
|
|
14
|
+
* matching core's `nearest(select:'nearest')`. Used by the tracker's `sampleAt`
|
|
15
|
+
* so the readout snaps to a drawn mark (not a gap) — and reads everything by
|
|
16
|
+
* index, so the encoded colour at that row comes along for free.
|
|
17
|
+
*/
|
|
18
|
+
export declare function nearestIndex(cs: ChartSeries, time: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* The `[min, max]` of the **finite** plotted values — identical to a line's
|
|
21
|
+
* vertical extent (the marks sit at their values; radius is a pixel-space
|
|
22
|
+
* concern that doesn't widen the data domain). `null` if no point is finite.
|
|
23
|
+
* Mirrors `yExtent` so a scatter auto-fits its axis the same way a line does.
|
|
24
|
+
*/
|
|
25
|
+
export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
26
|
+
/**
|
|
27
|
+
* Draw the scatter: one filled, outlined circle per finite point, sized +
|
|
28
|
+
* coloured by `encoding` (data-driven radius / colour) over `style`. A gap
|
|
29
|
+
* (non-finite y) draws nothing — points are discrete, there is no path to break.
|
|
30
|
+
*
|
|
31
|
+
* The **selected** point (when `selected` matches this layer's `label` *and* a
|
|
32
|
+
* point's `begin` key) is restroked with the style's wider highlight ring after
|
|
33
|
+
* the base pass, so it lifts above its neighbours regardless of draw order.
|
|
34
|
+
* Matching on both key and label is what keeps two series sharing a timestamp
|
|
35
|
+
* from both lighting up (the container's selection contract).
|
|
36
|
+
*
|
|
37
|
+
* Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
|
|
38
|
+
* brackets the whole pass so fill/stroke state doesn't leak into later layers.
|
|
39
|
+
* Reads `cs.x`/`cs.y` by index — no per-point object allocation in the hot loop.
|
|
40
|
+
*
|
|
41
|
+
* @param keyAt maps a row index to the point's stable key (its event `begin`),
|
|
42
|
+
* for selection matching — the row's `x` is epoch ms, so this is
|
|
43
|
+
* usually `(i) => cs.x[i]`.
|
|
44
|
+
* @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
|
|
45
|
+
* @param font `theme.font` (family + size) for label text.
|
|
46
|
+
* @param selected the container's current selection (or `null`).
|
|
47
|
+
* @param seriesLabel this layer's series identity (`as` ?? column) — the
|
|
48
|
+
* `label` half of the selection match.
|
|
49
|
+
*/
|
|
50
|
+
export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
|
|
51
|
+
readonly family: string;
|
|
52
|
+
readonly size: number;
|
|
53
|
+
}, selected: SelectInfo | null, seriesLabel: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
56
|
+
* point whose circle contains the click, or `null`. "Topmost" = the
|
|
57
|
+
* last-drawn at that spot, so a later point drawn over an earlier one wins; we
|
|
58
|
+
* walk **backwards** and return the first containing point.
|
|
59
|
+
*
|
|
60
|
+
* A point's hit radius is its drawn radius (data-driven or base) — clicking the
|
|
61
|
+
* visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
|
|
62
|
+
* Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
|
|
63
|
+
* `begin`), the encoded fill colour (so the readout swatch matches the mark),
|
|
64
|
+
* and the series `label`.
|
|
65
|
+
*
|
|
66
|
+
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
67
|
+
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
68
|
+
*/
|
|
69
|
+
export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, seriesLabel: string): SelectInfo | null;
|
|
70
|
+
//# sourceMappingURL=scatter.d.ts.map
|
package/dist/scatter.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scatter geometry + the canvas draw — pure, like {@link drawLine} /
|
|
3
|
+
* {@link drawBand}, so the recording-mock tests assert the op sequence and the
|
|
4
|
+
* hit math without a browser.
|
|
5
|
+
*
|
|
6
|
+
* A scatter plots one mark per finite point at `(xScale(x), yScale(y))`, sized
|
|
7
|
+
* + coloured by the resolved {@link ResolvedEncoding} (data-driven radius /
|
|
8
|
+
* colour) over the style's base. All three of `drawScatter`, `scatterExtent`,
|
|
9
|
+
* and {@link hitTestScatter} are **O(N)** in the point count (a single pass; no
|
|
10
|
+
* spatial index — a chart row holds far fewer points than a dense line, and a
|
|
11
|
+
* click happens at human cadence). If a scatter ever needs 100k+ points this is
|
|
12
|
+
* the place to add a coarse x-bucket index; today the linear walk is the right
|
|
13
|
+
* tradeoff.
|
|
14
|
+
*/
|
|
15
|
+
/** A non-finite y (the gap signal) means "no point here" — skip it everywhere. */
|
|
16
|
+
function isPoint(cs, i) {
|
|
17
|
+
return Number.isFinite(cs.y[i]);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
|
|
21
|
+
* finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
|
|
22
|
+
* search finds the insertion point in O(log N); the two straddling rows are then
|
|
23
|
+
* the only nearest candidates — but either may be a gap (non-finite y), so we
|
|
24
|
+
* step outward from each until we find a real point and keep the closer.
|
|
25
|
+
*
|
|
26
|
+
* Ties go to the **earlier** point (strict `<` on the distance comparison),
|
|
27
|
+
* matching core's `nearest(select:'nearest')`. Used by the tracker's `sampleAt`
|
|
28
|
+
* so the readout snaps to a drawn mark (not a gap) — and reads everything by
|
|
29
|
+
* index, so the encoded colour at that row comes along for free.
|
|
30
|
+
*/
|
|
31
|
+
export function nearestIndex(cs, time) {
|
|
32
|
+
const n = cs.length;
|
|
33
|
+
if (n === 0)
|
|
34
|
+
return -1;
|
|
35
|
+
// Binary search for the first index with x >= time.
|
|
36
|
+
let lo = 0;
|
|
37
|
+
let hi = n;
|
|
38
|
+
while (lo < hi) {
|
|
39
|
+
const mid = (lo + hi) >>> 1;
|
|
40
|
+
if (cs.x[mid] < time)
|
|
41
|
+
lo = mid + 1;
|
|
42
|
+
else
|
|
43
|
+
hi = mid;
|
|
44
|
+
}
|
|
45
|
+
// Candidates straddle `lo`: the row at `lo` (>= time) and `lo - 1` (< time).
|
|
46
|
+
// Either side may be a run of gaps, so scan outward for the nearest real point.
|
|
47
|
+
let best = -1;
|
|
48
|
+
let bestDist = Infinity;
|
|
49
|
+
// Walk right from lo for the first finite point.
|
|
50
|
+
for (let i = lo; i < n; i += 1) {
|
|
51
|
+
if (Number.isFinite(cs.y[i])) {
|
|
52
|
+
best = i;
|
|
53
|
+
bestDist = Math.abs(cs.x[i] - time);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Walk left from lo-1; take it only if strictly closer (ties → earlier index,
|
|
58
|
+
// which on the left side means this earlier point wins an equal distance).
|
|
59
|
+
for (let i = lo - 1; i >= 0; i -= 1) {
|
|
60
|
+
if (Number.isFinite(cs.y[i])) {
|
|
61
|
+
const d = Math.abs(cs.x[i] - time);
|
|
62
|
+
if (d <= bestDist) {
|
|
63
|
+
best = i;
|
|
64
|
+
bestDist = d;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The `[min, max]` of the **finite** plotted values — identical to a line's
|
|
73
|
+
* vertical extent (the marks sit at their values; radius is a pixel-space
|
|
74
|
+
* concern that doesn't widen the data domain). `null` if no point is finite.
|
|
75
|
+
* Mirrors `yExtent` so a scatter auto-fits its axis the same way a line does.
|
|
76
|
+
*/
|
|
77
|
+
export function scatterExtent(cs) {
|
|
78
|
+
let min = Infinity;
|
|
79
|
+
let max = -Infinity;
|
|
80
|
+
for (let i = 0; i < cs.length; i += 1) {
|
|
81
|
+
const v = cs.y[i];
|
|
82
|
+
if (Number.isFinite(v)) {
|
|
83
|
+
if (v < min)
|
|
84
|
+
min = v;
|
|
85
|
+
if (v > max)
|
|
86
|
+
max = v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return min === Infinity ? null : [min, max];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Draw the scatter: one filled, outlined circle per finite point, sized +
|
|
93
|
+
* coloured by `encoding` (data-driven radius / colour) over `style`. A gap
|
|
94
|
+
* (non-finite y) draws nothing — points are discrete, there is no path to break.
|
|
95
|
+
*
|
|
96
|
+
* The **selected** point (when `selected` matches this layer's `label` *and* a
|
|
97
|
+
* point's `begin` key) is restroked with the style's wider highlight ring after
|
|
98
|
+
* the base pass, so it lifts above its neighbours regardless of draw order.
|
|
99
|
+
* Matching on both key and label is what keeps two series sharing a timestamp
|
|
100
|
+
* from both lighting up (the container's selection contract).
|
|
101
|
+
*
|
|
102
|
+
* Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
|
|
103
|
+
* brackets the whole pass so fill/stroke state doesn't leak into later layers.
|
|
104
|
+
* Reads `cs.x`/`cs.y` by index — no per-point object allocation in the hot loop.
|
|
105
|
+
*
|
|
106
|
+
* @param keyAt maps a row index to the point's stable key (its event `begin`),
|
|
107
|
+
* for selection matching — the row's `x` is epoch ms, so this is
|
|
108
|
+
* usually `(i) => cs.x[i]`.
|
|
109
|
+
* @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
|
|
110
|
+
* @param font `theme.font` (family + size) for label text.
|
|
111
|
+
* @param selected the container's current selection (or `null`).
|
|
112
|
+
* @param seriesLabel this layer's series identity (`as` ?? column) — the
|
|
113
|
+
* `label` half of the selection match.
|
|
114
|
+
*/
|
|
115
|
+
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesLabel) {
|
|
116
|
+
ctx.save();
|
|
117
|
+
// The selection only lights up a point of *this* series; resolve the key once.
|
|
118
|
+
const selectedKey = selected !== null && selected.label === seriesLabel ? selected.key : null;
|
|
119
|
+
let selPx = 0;
|
|
120
|
+
let selPy = 0;
|
|
121
|
+
let selR = 0;
|
|
122
|
+
let selHit = false;
|
|
123
|
+
for (let i = 0; i < cs.length; i += 1) {
|
|
124
|
+
if (!isPoint(cs, i))
|
|
125
|
+
continue;
|
|
126
|
+
const px = xScale(cs.x[i]);
|
|
127
|
+
const py = yScale(cs.y[i]);
|
|
128
|
+
const r = encoding.radiusAt(i);
|
|
129
|
+
ctx.beginPath();
|
|
130
|
+
ctx.arc(px, py, r, 0, Math.PI * 2);
|
|
131
|
+
ctx.fillStyle = encoding.colorAt(i);
|
|
132
|
+
ctx.fill();
|
|
133
|
+
if (style.outlineWidth > 0) {
|
|
134
|
+
ctx.lineWidth = style.outlineWidth;
|
|
135
|
+
ctx.strokeStyle = style.outline;
|
|
136
|
+
ctx.stroke();
|
|
137
|
+
}
|
|
138
|
+
// Defer the selected point's highlight ring to a second pass so it sits on
|
|
139
|
+
// top of any neighbour drawn after it.
|
|
140
|
+
if (selectedKey !== null && keyAt(i) === selectedKey) {
|
|
141
|
+
selPx = px;
|
|
142
|
+
selPy = py;
|
|
143
|
+
selR = r;
|
|
144
|
+
selHit = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Highlight ring for the selected point (after the base pass — always on top).
|
|
148
|
+
if (selHit) {
|
|
149
|
+
ctx.beginPath();
|
|
150
|
+
ctx.arc(selPx, selPy, selR, 0, Math.PI * 2);
|
|
151
|
+
ctx.lineWidth = style.selectedWidth;
|
|
152
|
+
ctx.strokeStyle = style.selectedOutline;
|
|
153
|
+
ctx.stroke();
|
|
154
|
+
}
|
|
155
|
+
// Optional per-point labels, after all marks so text isn't overpainted.
|
|
156
|
+
if (labelAt !== undefined) {
|
|
157
|
+
ctx.fillStyle = style.label;
|
|
158
|
+
ctx.font = `${font.size}px ${font.family}`;
|
|
159
|
+
ctx.textBaseline = 'middle';
|
|
160
|
+
for (let i = 0; i < cs.length; i += 1) {
|
|
161
|
+
if (!isPoint(cs, i))
|
|
162
|
+
continue;
|
|
163
|
+
const text = labelAt(i);
|
|
164
|
+
if (text === undefined || text === '')
|
|
165
|
+
continue;
|
|
166
|
+
const px = xScale(cs.x[i]);
|
|
167
|
+
const py = yScale(cs.y[i]);
|
|
168
|
+
const r = encoding.radiusAt(i);
|
|
169
|
+
// Sit the label just right of the point (past its radius), vertically
|
|
170
|
+
// centred — simple, theme-styled placement.
|
|
171
|
+
ctx.fillText(text, px + r + LABEL_GAP, py);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
ctx.restore();
|
|
175
|
+
}
|
|
176
|
+
/** Gap (px) between a point's edge and its label text. */
|
|
177
|
+
const LABEL_GAP = 4;
|
|
178
|
+
/**
|
|
179
|
+
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
180
|
+
* point whose circle contains the click, or `null`. "Topmost" = the
|
|
181
|
+
* last-drawn at that spot, so a later point drawn over an earlier one wins; we
|
|
182
|
+
* walk **backwards** and return the first containing point.
|
|
183
|
+
*
|
|
184
|
+
* A point's hit radius is its drawn radius (data-driven or base) — clicking the
|
|
185
|
+
* visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
|
|
186
|
+
* Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
|
|
187
|
+
* `begin`), the encoded fill colour (so the readout swatch matches the mark),
|
|
188
|
+
* and the series `label`.
|
|
189
|
+
*
|
|
190
|
+
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
191
|
+
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
192
|
+
*/
|
|
193
|
+
export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seriesLabel) {
|
|
194
|
+
for (let i = cs.length - 1; i >= 0; i -= 1) {
|
|
195
|
+
if (!isPoint(cs, i))
|
|
196
|
+
continue;
|
|
197
|
+
const px = xScale(cs.x[i]);
|
|
198
|
+
const py = yScale(cs.y[i]);
|
|
199
|
+
const r = encoding.radiusAt(i);
|
|
200
|
+
const dx = qx - px;
|
|
201
|
+
const dy = qy - py;
|
|
202
|
+
if (dx * dx + dy * dy <= r * r) {
|
|
203
|
+
return {
|
|
204
|
+
key: keyAt(i),
|
|
205
|
+
value: cs.y[i],
|
|
206
|
+
color: encoding.colorAt(i),
|
|
207
|
+
label: seriesLabel,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=scatter.js.map
|
package/dist/select.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LayerEntry, SelectInfo } from './context.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a click at plot-pixel `(px, py)` to the selected mark, or `null`.
|
|
4
|
+
* Walks the row's layers **top-down** (reverse z-order — the topmost mark wins,
|
|
5
|
+
* matching what the user sees) and returns the first `hitTest` hit. A layer with
|
|
6
|
+
* no `hitTest` (line / band / area) or no resolvable y-scale is skipped.
|
|
7
|
+
*
|
|
8
|
+
* Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
|
|
9
|
+
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
10
|
+
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined): SelectInfo | null;
|
|
13
|
+
//# sourceMappingURL=select.d.ts.map
|
package/dist/select.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a click at plot-pixel `(px, py)` to the selected mark, or `null`.
|
|
3
|
+
* Walks the row's layers **top-down** (reverse z-order — the topmost mark wins,
|
|
4
|
+
* matching what the user sees) and returns the first `hitTest` hit. A layer with
|
|
5
|
+
* no `hitTest` (line / band / area) or no resolvable y-scale is skipped.
|
|
6
|
+
*
|
|
7
|
+
* Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
|
|
8
|
+
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
9
|
+
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSelection(entries, px, py, xScale, yScaleFor) {
|
|
12
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
13
|
+
const entry = entries[i];
|
|
14
|
+
const yScale = yScaleFor(entry.axisId);
|
|
15
|
+
if (yScale === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
const hit = entry.layer.hitTest?.(px, py, xScale, yScale);
|
|
18
|
+
if (hit)
|
|
19
|
+
return hit;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=select.js.map
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-slot gutter math, shared by {@link ChartContainer} (reserve) and
|
|
3
|
+
* {@link ChartRow} (place). A "slot" is one axis column on a side; slots are
|
|
4
|
+
* indexed **from the plot outward** — slot 0 is the axis nearest the plot. Each
|
|
5
|
+
* row contributes a list of its axis widths for one side in slot order (slot 0
|
|
6
|
+
* first). Kept pure + free of React so the column-alignment rule is unit-tested
|
|
7
|
+
* directly.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The reserved width of each slot: the max any row needs in that slot. A row
|
|
11
|
+
* with fewer axes simply has no entry for the outer slots, contributing nothing
|
|
12
|
+
* there — so `leftSlots[i]` is `max` over the rows that *have* a slot `i`.
|
|
13
|
+
*
|
|
14
|
+
* The sum is the side's total gutter; the plot starts after the left sum and
|
|
15
|
+
* ends before the right sum, identically on every row.
|
|
16
|
+
*/
|
|
17
|
+
export declare function maxSlotWidths(rows: readonly (readonly number[])[]): number[];
|
|
18
|
+
/** Sum of an array of widths (a side's total gutter). */
|
|
19
|
+
export declare function sum(widths: readonly number[]): number;
|
|
20
|
+
/** A row axis as the layout sees it: its per-instance slot **key** + width. */
|
|
21
|
+
export interface SlotAxis {
|
|
22
|
+
/** Stable per-instance key (the `useSlotKey` symbol), NOT the data id. */
|
|
23
|
+
readonly key: symbol;
|
|
24
|
+
readonly width: number;
|
|
25
|
+
}
|
|
26
|
+
/** What {@link placeAxisSlots} computes for one row. */
|
|
27
|
+
export interface AxisSlotPlacement {
|
|
28
|
+
/** Axis instance key → its reserved slot width (that column's max across rows). */
|
|
29
|
+
readonly axisSlots: ReadonlyMap<symbol, number>;
|
|
30
|
+
/** Width of the outer slots this row lacks — padded so its plot stays aligned. */
|
|
31
|
+
readonly leftPad: number;
|
|
32
|
+
readonly rightPad: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Place a row's real axes into the container's reserved slots (slot 0 nearest the
|
|
36
|
+
* plot). `leftAxes` are in author order (outer→inner), so the i-th sits in slot
|
|
37
|
+
* `len-1-i`; `rightAxes` are author order (inner→outer), so the j-th sits in slot
|
|
38
|
+
* `j`. Each axis's reserved width is its slot's max (falling back to its own
|
|
39
|
+
* width until the container has reserved); a row with fewer axes than the widest
|
|
40
|
+
* pads the outer slots it lacks — so a no-axis row pads the whole gutter and its
|
|
41
|
+
* plot still left-aligns.
|
|
42
|
+
*
|
|
43
|
+
* Keyed by **instance** (the `key` symbol), not the data `id`: two axes can share
|
|
44
|
+
* an id (a left/right mirror of one scale, or a duplicate) yet must keep distinct
|
|
45
|
+
* slots, or the rendered gutter would not match what the container reserved.
|
|
46
|
+
*/
|
|
47
|
+
export declare function placeAxisSlots(leftAxes: readonly SlotAxis[], rightAxes: readonly SlotAxis[], leftSlots: readonly number[], rightSlots: readonly number[]): AxisSlotPlacement;
|
|
48
|
+
//# sourceMappingURL=slots.d.ts.map
|
package/dist/slots.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-slot gutter math, shared by {@link ChartContainer} (reserve) and
|
|
3
|
+
* {@link ChartRow} (place). A "slot" is one axis column on a side; slots are
|
|
4
|
+
* indexed **from the plot outward** — slot 0 is the axis nearest the plot. Each
|
|
5
|
+
* row contributes a list of its axis widths for one side in slot order (slot 0
|
|
6
|
+
* first). Kept pure + free of React so the column-alignment rule is unit-tested
|
|
7
|
+
* directly.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The reserved width of each slot: the max any row needs in that slot. A row
|
|
11
|
+
* with fewer axes simply has no entry for the outer slots, contributing nothing
|
|
12
|
+
* there — so `leftSlots[i]` is `max` over the rows that *have* a slot `i`.
|
|
13
|
+
*
|
|
14
|
+
* The sum is the side's total gutter; the plot starts after the left sum and
|
|
15
|
+
* ends before the right sum, identically on every row.
|
|
16
|
+
*/
|
|
17
|
+
export function maxSlotWidths(rows) {
|
|
18
|
+
const len = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
19
|
+
const slots = new Array(len).fill(0);
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
for (let i = 0; i < row.length; i++) {
|
|
22
|
+
slots[i] = Math.max(slots[i], row[i]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return slots;
|
|
26
|
+
}
|
|
27
|
+
/** Sum of an array of widths (a side's total gutter). */
|
|
28
|
+
export function sum(widths) {
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const w of widths)
|
|
31
|
+
total += w;
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Place a row's real axes into the container's reserved slots (slot 0 nearest the
|
|
36
|
+
* plot). `leftAxes` are in author order (outer→inner), so the i-th sits in slot
|
|
37
|
+
* `len-1-i`; `rightAxes` are author order (inner→outer), so the j-th sits in slot
|
|
38
|
+
* `j`. Each axis's reserved width is its slot's max (falling back to its own
|
|
39
|
+
* width until the container has reserved); a row with fewer axes than the widest
|
|
40
|
+
* pads the outer slots it lacks — so a no-axis row pads the whole gutter and its
|
|
41
|
+
* plot still left-aligns.
|
|
42
|
+
*
|
|
43
|
+
* Keyed by **instance** (the `key` symbol), not the data `id`: two axes can share
|
|
44
|
+
* an id (a left/right mirror of one scale, or a duplicate) yet must keep distinct
|
|
45
|
+
* slots, or the rendered gutter would not match what the container reserved.
|
|
46
|
+
*/
|
|
47
|
+
export function placeAxisSlots(leftAxes, rightAxes, leftSlots, rightSlots) {
|
|
48
|
+
const axisSlots = new Map();
|
|
49
|
+
leftAxes.forEach((ax, i) => {
|
|
50
|
+
axisSlots.set(ax.key, leftSlots[leftAxes.length - 1 - i] ?? ax.width);
|
|
51
|
+
});
|
|
52
|
+
rightAxes.forEach((ax, j) => {
|
|
53
|
+
axisSlots.set(ax.key, rightSlots[j] ?? ax.width);
|
|
54
|
+
});
|
|
55
|
+
let leftPad = 0;
|
|
56
|
+
for (let i = leftAxes.length; i < leftSlots.length; i++)
|
|
57
|
+
leftPad += leftSlots[i] ?? 0;
|
|
58
|
+
let rightPad = 0;
|
|
59
|
+
for (let j = rightAxes.length; j < rightSlots.length; j++) {
|
|
60
|
+
rightPad += rightSlots[j] ?? 0;
|
|
61
|
+
}
|
|
62
|
+
return { axisSlots, leftPad, rightPad };
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=slots.js.map
|