@pond-ts/charts 0.48.1 → 0.50.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 +225 -1
- package/dist/AreaChart.d.ts +18 -1
- package/dist/AreaChart.js +23 -2
- package/dist/BandChart.d.ts +21 -2
- package/dist/BandChart.js +68 -9
- package/dist/BarChart.d.ts +12 -1
- package/dist/BarChart.js +34 -1
- package/dist/BoxPlot.d.ts +29 -1
- package/dist/BoxPlot.js +61 -3
- package/dist/Candlestick.d.ts +21 -1
- package/dist/Candlestick.js +42 -7
- package/dist/ChartContainer.d.ts +23 -13
- package/dist/ChartContainer.js +86 -32
- package/dist/ChartRow.js +22 -3
- package/dist/Layers.js +37 -14
- package/dist/Legend.d.ts +62 -0
- package/dist/Legend.js +169 -0
- package/dist/LineChart.d.ts +20 -1
- package/dist/LineChart.js +23 -2
- package/dist/ScatterChart.d.ts +8 -1
- package/dist/ScatterChart.js +24 -1
- package/dist/XAxis.js +9 -2
- package/dist/YAxis.d.ts +9 -1
- package/dist/YAxis.js +27 -6
- package/dist/annotations.d.ts +21 -3
- package/dist/annotations.js +36 -15
- package/dist/area.d.ts +2 -1
- package/dist/area.js +29 -4
- package/dist/band.d.ts +2 -1
- package/dist/band.js +18 -1
- package/dist/bars.js +8 -1
- package/dist/box.d.ts +15 -1
- package/dist/box.js +71 -2
- package/dist/context.d.ts +51 -4
- package/dist/culling.d.ts +165 -0
- package/dist/culling.js +286 -0
- package/dist/data.d.ts +3 -1
- package/dist/decimate.d.ts +231 -0
- package/dist/decimate.js +478 -0
- package/dist/format.d.ts +20 -11
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/line.d.ts +2 -1
- package/dist/line.js +38 -3
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +23 -2
- package/dist/scatter.js +42 -7
- package/dist/swatch.d.ts +104 -0
- package/dist/swatch.js +96 -0
- package/dist/theme.d.ts +27 -0
- package/dist/theme.js +12 -0
- package/dist/useChartLegend.d.ts +106 -0
- package/dist/useChartLegend.js +122 -0
- package/dist/yticks.d.ts +20 -0
- package/dist/yticks.js +28 -0
- package/package.json +3 -3
package/dist/annotations.d.ts
CHANGED
|
@@ -84,6 +84,12 @@ export interface MarkerProps {
|
|
|
84
84
|
* `false` it's inert background context — drawn at the back (level 3) always,
|
|
85
85
|
* no hover, no select, no edit. */
|
|
86
86
|
selectable?: boolean;
|
|
87
|
+
/** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
|
|
88
|
+
* (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
|
|
89
|
+
* several marks can differ (a green ATM baseline, a distinct marker) without
|
|
90
|
+
* splitting the register. Omitted / unknown ⇒ the base annotation colour.
|
|
91
|
+
* Colour is a theme concern — there is no per-mark colour prop. */
|
|
92
|
+
role?: string;
|
|
87
93
|
/** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
|
|
88
94
|
* remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
|
|
89
95
|
hovered?: boolean;
|
|
@@ -105,7 +111,7 @@ export interface MarkerProps {
|
|
|
105
111
|
indicator?: boolean;
|
|
106
112
|
}
|
|
107
113
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
108
|
-
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
114
|
+
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, role, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
109
115
|
export interface BaselineProps {
|
|
110
116
|
/** y value in the linked axis's units. */
|
|
111
117
|
value: number;
|
|
@@ -127,6 +133,12 @@ export interface BaselineProps {
|
|
|
127
133
|
/** Whether the baseline responds to hover + selection (default `true`). When
|
|
128
134
|
* `false` it's inert background context — drawn at the back (level 3) always. */
|
|
129
135
|
selectable?: boolean;
|
|
136
|
+
/** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
|
|
137
|
+
* (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
|
|
138
|
+
* several marks can differ (a green ATM baseline, a distinct marker) without
|
|
139
|
+
* splitting the register. Omitted / unknown ⇒ the base annotation colour.
|
|
140
|
+
* Colour is a theme concern — there is no per-mark colour prop. */
|
|
141
|
+
role?: string;
|
|
130
142
|
/** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
|
|
131
143
|
* remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
|
|
132
144
|
hovered?: boolean;
|
|
@@ -148,7 +160,7 @@ export interface BaselineProps {
|
|
|
148
160
|
}
|
|
149
161
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
150
162
|
* Its label anchors at the left, at the line's height. */
|
|
151
|
-
export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
163
|
+
export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, role, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
152
164
|
export interface RegionProps {
|
|
153
165
|
/** Start x in axis units (time or value). */
|
|
154
166
|
from: number;
|
|
@@ -170,6 +182,12 @@ export interface RegionProps {
|
|
|
170
182
|
* `false` it's inert background context — drawn at the back (level 3) always,
|
|
171
183
|
* and the double-click hit-test skips it. */
|
|
172
184
|
selectable?: boolean;
|
|
185
|
+
/** Theme **role** — recolours this mark from `theme.annotation.roles[role]`
|
|
186
|
+
* (its `color`, optionally `fillOpacity`), keeping the shared depth ramp, so
|
|
187
|
+
* several marks can differ (a green ATM band, a distinct marker) without
|
|
188
|
+
* splitting the register. Omitted / unknown ⇒ the base annotation colour.
|
|
189
|
+
* Colour is a theme concern — there is no per-mark colour prop. */
|
|
190
|
+
role?: string;
|
|
173
191
|
/** Controlled hover (OR'd with pointer hover) — lets a legend row light the mark
|
|
174
192
|
* remotely. Pair with the container's `onHoverAnnotation` to sync both ways. */
|
|
175
193
|
hovered?: boolean;
|
|
@@ -192,6 +210,6 @@ export interface RegionProps {
|
|
|
192
210
|
}
|
|
193
211
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
194
212
|
* flies as a flag off the left edge. */
|
|
195
|
-
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
213
|
+
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, role, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
196
214
|
export {};
|
|
197
215
|
//# sourceMappingURL=annotations.d.ts.map
|
package/dist/annotations.js
CHANGED
|
@@ -97,8 +97,16 @@ const overlayStyle = {
|
|
|
97
97
|
left: 0,
|
|
98
98
|
pointerEvents: 'none',
|
|
99
99
|
};
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Read the container + row frames an annotation needs (throw if misplaced), and
|
|
102
|
+
* resolve the mark's annotation style for its optional `role`. A `role` recolors
|
|
103
|
+
* *this* mark from the theme's `annotation.roles[role]` map (`color`, and
|
|
104
|
+
* optionally `fillOpacity`) while keeping the shared depth ramp — so a smile can
|
|
105
|
+
* place a green ATM baseline, a neutral vertical, and a distinct marker at once
|
|
106
|
+
* without splitting the whole register. An unknown / unset role falls back to
|
|
107
|
+
* the base `annotation` register (`roles[role] ?? annotation`).
|
|
108
|
+
*/
|
|
109
|
+
function useAnnotationFrame(name, role) {
|
|
102
110
|
const container = useContext(ContainerContext);
|
|
103
111
|
if (container === null) {
|
|
104
112
|
throw new Error(`<${name}> must be rendered inside a <ChartContainer>`);
|
|
@@ -107,7 +115,17 @@ function useAnnotationFrame(name) {
|
|
|
107
115
|
if (row === null) {
|
|
108
116
|
throw new Error(`<${name}> must be rendered inside a <ChartRow>`);
|
|
109
117
|
}
|
|
110
|
-
const
|
|
118
|
+
const base = container.theme.annotation ?? DEFAULT_ANNOTATION;
|
|
119
|
+
const roleStyle = role !== undefined ? base.roles?.[role] : undefined;
|
|
120
|
+
// The role overrides only colour (+ optional fill); depth stays the shared
|
|
121
|
+
// ramp, so selection / hover / edit levels read identically per role.
|
|
122
|
+
const ann = roleStyle
|
|
123
|
+
? {
|
|
124
|
+
...base,
|
|
125
|
+
color: roleStyle.color,
|
|
126
|
+
fillOpacity: roleStyle.fillOpacity ?? base.fillOpacity,
|
|
127
|
+
}
|
|
128
|
+
: base;
|
|
111
129
|
return { container, row, ann };
|
|
112
130
|
}
|
|
113
131
|
/** Register this annotation with the container (so it can draw the mark's guide on
|
|
@@ -450,8 +468,8 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
450
468
|
} }));
|
|
451
469
|
}
|
|
452
470
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
453
|
-
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
454
|
-
const { container, row, ann } = useAnnotationFrame('Marker');
|
|
471
|
+
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, role, }) {
|
|
472
|
+
const { container, row, ann } = useAnnotationFrame('Marker', role);
|
|
455
473
|
const selfKey = useSlotKey();
|
|
456
474
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
457
475
|
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
@@ -460,8 +478,11 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
460
478
|
container.creating === null &&
|
|
461
479
|
onChange !== undefined;
|
|
462
480
|
const xs = useMemo(() => [at], [at]);
|
|
463
|
-
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the
|
|
464
|
-
|
|
481
|
+
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the readout
|
|
482
|
+
// channel (else the x label formatter).
|
|
483
|
+
const text = label === false
|
|
484
|
+
? ''
|
|
485
|
+
: (label ?? (container.formatReadout ?? container.formatTime)(at));
|
|
465
486
|
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text, indicator);
|
|
466
487
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
467
488
|
const select = id !== undefined && container.creating === null
|
|
@@ -491,8 +512,8 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
491
512
|
}
|
|
492
513
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
493
514
|
* Its label anchors at the left, at the line's height. */
|
|
494
|
-
export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
495
|
-
const { container, row, ann } = useAnnotationFrame('Baseline');
|
|
515
|
+
export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, role, }) {
|
|
516
|
+
const { container, row, ann } = useAnnotationFrame('Baseline', role);
|
|
496
517
|
const selfKey = useSlotKey();
|
|
497
518
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
498
519
|
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
@@ -551,8 +572,8 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
|
|
|
551
572
|
}
|
|
552
573
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
553
574
|
* flies as a flag off the left edge. */
|
|
554
|
-
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, }) {
|
|
555
|
-
const { container, row, ann } = useAnnotationFrame('Region');
|
|
575
|
+
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, role, }) {
|
|
576
|
+
const { container, row, ann } = useAnnotationFrame('Region', role);
|
|
556
577
|
const selfKey = useSlotKey();
|
|
557
578
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
558
579
|
// Draggable right now: global edit mode OR this mark's single-edit flag, and no
|
|
@@ -561,10 +582,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
561
582
|
container.creating === null &&
|
|
562
583
|
onChange !== undefined;
|
|
563
584
|
const xs = useMemo(() => [from, to], [from, to]);
|
|
564
|
-
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label the `from–to`
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
585
|
+
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label the `from–to`
|
|
586
|
+
// span off the readout channel (else the x label formatter).
|
|
587
|
+
const fmtX = container.formatReadout ?? container.formatTime;
|
|
588
|
+
const text = label === false ? '' : (label ?? `${fmtX(from)}–${fmtX(to)}`);
|
|
568
589
|
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text, false);
|
|
569
590
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
570
591
|
const select = id !== undefined && container.creating === null
|
package/dist/area.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ChartSeries } from './data.js';
|
|
|
3
3
|
import type { Scale } from './line.js';
|
|
4
4
|
import type { AreaStyle } from './theme.js';
|
|
5
5
|
import { type GapMode } from './gaps.js';
|
|
6
|
+
import { type DecimateOption } from './decimate.js';
|
|
6
7
|
/**
|
|
7
8
|
* The `[min, max]` vertical extent an area occupies — the finite values of
|
|
8
9
|
* `cs.y` widened to include `baseline`, since the fill spans from each value to
|
|
@@ -50,5 +51,5 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
|
|
|
50
51
|
* bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
|
|
51
52
|
* are collected by one O(N) walk ({@link collectGapEdges}).
|
|
52
53
|
*/
|
|
53
|
-
export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number): void;
|
|
54
|
+
export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): void;
|
|
54
55
|
//# sourceMappingURL=area.d.ts.map
|
package/dist/area.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { area as d3area, curveLinear } from 'd3-shape';
|
|
2
2
|
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
3
|
+
import { cullChartSeries } from './culling.js';
|
|
4
|
+
import { decimateM4 } from './decimate.js';
|
|
3
5
|
/**
|
|
4
6
|
* The `[min, max]` vertical extent an area occupies — the finite values of
|
|
5
7
|
* `cs.y` widened to include `baseline`, since the fill spans from each value to
|
|
@@ -70,8 +72,31 @@ export function areaExtent(cs, baseline) {
|
|
|
70
72
|
* bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
|
|
71
73
|
* are collected by one O(N) walk ({@link collectGapEdges}).
|
|
72
74
|
*/
|
|
73
|
-
export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
|
|
75
|
+
export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true) {
|
|
74
76
|
const baselinePx = yScale(baselineValue);
|
|
77
|
+
// The fill gradient's vertical extent is computed from the **full** series (a
|
|
78
|
+
// vertical, position-anchored gradient spanning the data's whole pixel extent)
|
|
79
|
+
// so viewport culling stays behavior-neutral: the culled path below paints the
|
|
80
|
+
// exact same visible pixels under the same gradient. This is a cheap O(N)
|
|
81
|
+
// min/max scan; the expensive per-point path work (fill + outline) culls. (Cull
|
|
82
|
+
// the region too and the shade would drift under pan as off-screen extrema
|
|
83
|
+
// enter/leave — a visible change culling must not make.)
|
|
84
|
+
const fullYs = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
|
|
85
|
+
const fill = buildGradient(ctx, fullYs, cs.length, yScale, baselinePx, style);
|
|
86
|
+
// Viewport culling (Phase 2): the path, outline, and gap bridges walk the
|
|
87
|
+
// visible slice (+1 entry/exit point) only. A no-op — the same `cs` back — when
|
|
88
|
+
// fully in view or `xScale` has no domain (a test stub), keeping that hot path
|
|
89
|
+
// byte-identical.
|
|
90
|
+
cs = cullChartSeries(cs, xScale);
|
|
91
|
+
// M4 decimation (Phase 3): the outline is a line, so the same {@link decimateM4}
|
|
92
|
+
// pre-pass shrinks the fill + outline + gap-bridge work to O(plot width) once
|
|
93
|
+
// dense — with the §2.2 gap-edge union so every gap mode composes. The gradient
|
|
94
|
+
// above is over the FULL series, so the decimated fill paints identical pixels
|
|
95
|
+
// under it. Gated off a smoothing `curve`; a no-op on a sparse slice / test scale.
|
|
96
|
+
if (decimate !== false && curve === curveLinear) {
|
|
97
|
+
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
98
|
+
cs = decimateM4(cs, xScale, ctx, k);
|
|
99
|
+
}
|
|
75
100
|
// `none` interpolates interior gaps so the fill + outline bridge them; every
|
|
76
101
|
// other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
|
|
77
102
|
// a separate overlay pass below).
|
|
@@ -86,9 +111,9 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
|
|
|
86
111
|
ctx.save();
|
|
87
112
|
// The fill: a vertical gradient anchored at the baseline pixel, opaque at the
|
|
88
113
|
// line and transparent at the baseline (see buildGradient — handles both the
|
|
89
|
-
// one-sided elevation form and the two-sided above/below form).
|
|
90
|
-
//
|
|
91
|
-
ctx.fillStyle =
|
|
114
|
+
// one-sided elevation form and the two-sided above/below form). Spans the full
|
|
115
|
+
// data region (above), so the culled `ys` paints identical pixels under it.
|
|
116
|
+
ctx.fillStyle = fill;
|
|
92
117
|
ctx.globalAlpha = style.fillOpacity;
|
|
93
118
|
ctx.beginPath();
|
|
94
119
|
gen(ys);
|
package/dist/band.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type CurveFactory } from 'd3-shape';
|
|
|
2
2
|
import type { BandSeries } from './data.js';
|
|
3
3
|
import type { Scale } from './line.js';
|
|
4
4
|
import type { BandStyle } from './theme.js';
|
|
5
|
+
import { type DecimateOption } from './decimate.js';
|
|
5
6
|
/**
|
|
6
7
|
* The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
|
|
7
8
|
* and highest `upper` over samples where both edges are finite — or `null` if
|
|
@@ -27,5 +28,5 @@ export declare function bandExtent(band: BandSeries): [number, number] | null;
|
|
|
27
28
|
* index, so there's no per-point object allocation. `globalAlpha` carries the
|
|
28
29
|
* opacity and is restored so it doesn't leak into later layers.
|
|
29
30
|
*/
|
|
30
|
-
export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory): void;
|
|
31
|
+
export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): void;
|
|
31
32
|
//# sourceMappingURL=band.d.ts.map
|
package/dist/band.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { area as d3area, curveLinear } from 'd3-shape';
|
|
2
|
+
import { cullBandSeries } from './culling.js';
|
|
3
|
+
import { decimateBand } from './decimate.js';
|
|
2
4
|
/**
|
|
3
5
|
* The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
|
|
4
6
|
* and highest `upper` over samples where both edges are finite — or `null` if
|
|
@@ -38,7 +40,22 @@ export function bandExtent(band) {
|
|
|
38
40
|
* index, so there's no per-point object allocation. `globalAlpha` carries the
|
|
39
41
|
* opacity and is restored so it doesn't leak into later layers.
|
|
40
42
|
*/
|
|
41
|
-
export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear) {
|
|
43
|
+
export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
|
|
44
|
+
// Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
|
|
45
|
+
// side) before filling, so a pan strokes O(visible). The solid fill has no
|
|
46
|
+
// cross-point state, so a zero-copy subarray view is exact; a no-op (same
|
|
47
|
+
// object) when fully in view or the scale has no domain (a test stub).
|
|
48
|
+
band = cullBandSeries(band, xScale);
|
|
49
|
+
// M4 band decimation (Phase 3): once the culled envelope is denser than ~2
|
|
50
|
+
// samples per device pixel, replace it with the per-column min-lower / max-upper
|
|
51
|
+
// envelope ({@link decimateBand}) — O(plot width) points that cover the same
|
|
52
|
+
// pixels. Gated off a smoothing `curve` (which would distort the per-column
|
|
53
|
+
// envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
|
|
54
|
+
// envelope or a domainless test scale, so this stays byte-identical there.
|
|
55
|
+
if (decimate !== false && curve === curveLinear) {
|
|
56
|
+
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
57
|
+
band = decimateBand(band, xScale, ctx, k);
|
|
58
|
+
}
|
|
42
59
|
const gen = d3area()
|
|
43
60
|
.defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
|
|
44
61
|
.x((_, i) => xScale(band.x[i]))
|
package/dist/bars.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
|
+
import { visibleSpanRange } from './culling.js';
|
|
2
3
|
/**
|
|
3
4
|
* The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
|
|
4
5
|
* **widened to include `0`**, since a bar spans from its value to the baseline
|
|
@@ -96,7 +97,13 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
96
97
|
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered) {
|
|
97
98
|
ctx.save();
|
|
98
99
|
ctx.globalAlpha = style.opacity;
|
|
99
|
-
|
|
100
|
+
// Viewport culling (Phase 2): draw only the bars whose span overlaps the
|
|
101
|
+
// visible x-window (+1 each side). The loop keeps the original index `i`, so
|
|
102
|
+
// the `begin[i]` selection/hover match stays correct; full range when `xScale`
|
|
103
|
+
// has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
|
|
104
|
+
// (its highlight would be off-screen anyway).
|
|
105
|
+
const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
|
|
106
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
100
107
|
const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
101
108
|
if (rect === null)
|
|
102
109
|
continue;
|
package/dist/box.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BoxSeries } from './data.js';
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { BoxStyle } from './theme.js';
|
|
4
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
5
|
/**
|
|
5
6
|
* The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
|
|
6
7
|
* whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
|
|
@@ -24,6 +25,19 @@ export declare function boxExtent(box: BoxSeries): [number, number] | null;
|
|
|
24
25
|
* the caller drops it on the finiteness check. O(N) over the boxes (view-scale).
|
|
25
26
|
*/
|
|
26
27
|
export declare function boxIndexAtTime(box: BoxSeries, time: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Hit-test plot-pixel `(px, py)` against `box`'s marks — the **first** box whose
|
|
30
|
+
* bounding rect (its x-slot × its `[upper, lower]` whisker extent) contains the
|
|
31
|
+
* point, or `null`. The x-span is {@link barSpanPx} (same `gapPx`/`minWidthPx`/
|
|
32
|
+
* `offsetPx` as {@link drawBox}), so the hit rect matches the drawn mark; the y
|
|
33
|
+
* extent is the full whisker reach, so a click anywhere on the box (body or
|
|
34
|
+
* whisker) selects it — a range-only bid→ask segment included. The returned
|
|
35
|
+
* tuple is `[index, begin, value]` (`begin` = the box's `x`, `value` = its
|
|
36
|
+
* `upper` — provenance) for the chart to assemble a `SelectInfo`; keeping this
|
|
37
|
+
* theme-free mirrors {@link barAt} and stays unit-testable without a
|
|
38
|
+
* `ChartTheme`. A gap box (some drawn quantile non-finite) is skipped. O(N).
|
|
39
|
+
*/
|
|
40
|
+
export declare function boxAt(box: BoxSeries, px: number, py: number, xScale: Scale, yScale: Scale, gapPx: number, minWidthPx: number, offsetPx?: number): [index: number, begin: number, value: number] | null;
|
|
27
41
|
/**
|
|
28
42
|
* How a box renders its spread (pjm17971): **`whisker`** (today's thin stems +
|
|
29
43
|
* end-caps), **`solid`** (the candlestick look — a light outer bar over the full
|
|
@@ -64,7 +78,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
|
|
|
64
78
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
65
79
|
* beyond the `barSpanPx` tuple.
|
|
66
80
|
*/
|
|
67
|
-
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number): void;
|
|
81
|
+
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): void;
|
|
68
82
|
/**
|
|
69
83
|
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|
|
70
84
|
* `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
|
package/dist/box.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
|
+
import { visibleSpanRange } from './culling.js';
|
|
3
|
+
import { decimateBox } from './decimate.js';
|
|
2
4
|
/** Fraction of the box width the whisker end-caps span (centred on the stem). */
|
|
3
5
|
const WHISKER_CAP_FRACTION = 0.5;
|
|
4
6
|
/**
|
|
@@ -44,6 +46,36 @@ export function boxIndexAtTime(box, time) {
|
|
|
44
46
|
}
|
|
45
47
|
return -1;
|
|
46
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Hit-test plot-pixel `(px, py)` against `box`'s marks — the **first** box whose
|
|
51
|
+
* bounding rect (its x-slot × its `[upper, lower]` whisker extent) contains the
|
|
52
|
+
* point, or `null`. The x-span is {@link barSpanPx} (same `gapPx`/`minWidthPx`/
|
|
53
|
+
* `offsetPx` as {@link drawBox}), so the hit rect matches the drawn mark; the y
|
|
54
|
+
* extent is the full whisker reach, so a click anywhere on the box (body or
|
|
55
|
+
* whisker) selects it — a range-only bid→ask segment included. The returned
|
|
56
|
+
* tuple is `[index, begin, value]` (`begin` = the box's `x`, `value` = its
|
|
57
|
+
* `upper` — provenance) for the chart to assemble a `SelectInfo`; keeping this
|
|
58
|
+
* theme-free mirrors {@link barAt} and stays unit-testable without a
|
|
59
|
+
* `ChartTheme`. A gap box (some drawn quantile non-finite) is skipped. O(N).
|
|
60
|
+
*/
|
|
61
|
+
export function boxAt(box, px, py, xScale, yScale, gapPx, minWidthPx, offsetPx = 0) {
|
|
62
|
+
for (let i = 0; i < box.length; i += 1) {
|
|
63
|
+
if (!isFiniteBox(box, i))
|
|
64
|
+
continue;
|
|
65
|
+
const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
|
|
66
|
+
const x0 = span0 + offsetPx;
|
|
67
|
+
const x1 = span1 + offsetPx;
|
|
68
|
+
const yUpper = yScale(box.upper[i]);
|
|
69
|
+
const yLower = yScale(box.lower[i]);
|
|
70
|
+
// upper is the higher value ⇒ the smaller pixel y; order defensively.
|
|
71
|
+
const yTop = Math.min(yUpper, yLower);
|
|
72
|
+
const yBottom = Math.max(yUpper, yLower);
|
|
73
|
+
if (px >= x0 && px <= x1 && py >= yTop && py <= yBottom) {
|
|
74
|
+
return [i, box.x[i], box.upper[i]];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
47
79
|
/**
|
|
48
80
|
* Draw a discrete box per key of `box`, mapping data→pixels through
|
|
49
81
|
* `xScale`/`yScale`. The bar-chart analog of {@link drawBand}: each key gets its
|
|
@@ -76,12 +108,36 @@ export function boxIndexAtTime(box, time) {
|
|
|
76
108
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
77
109
|
* beyond the `barSpanPx` tuple.
|
|
78
110
|
*/
|
|
79
|
-
export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx
|
|
111
|
+
export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx,
|
|
112
|
+
// Selection / hover highlight, keyed by the box's `x` (its `begin`, matched to
|
|
113
|
+
// the container selection's `key` by the caller). `null` ⇒ none. A selected
|
|
114
|
+
// box gets a full-strength bounding outline; a hovered one a fainter one —
|
|
115
|
+
// the box analog of the bar highlight, drawn without a new theme token.
|
|
116
|
+
selectedKey = null, hoveredKey = null, decimate = true) {
|
|
117
|
+
// Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
|
|
118
|
+
// the window (+1 each side). Full range when `xScale` has no domain (a stub);
|
|
119
|
+
// `offsetPx` is a small pixel nudge the ±1 margin absorbs.
|
|
120
|
+
let [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
|
|
121
|
+
// M4 box decimation (Phase 5): once the *visible* boxes are denser than ~2 per
|
|
122
|
+
// device pixel, replace them with per-column **aggregate boxes** ({@link
|
|
123
|
+
// decimateBox}). Gate on the visible count, NOT `box.length`: a box's width is
|
|
124
|
+
// its slot, so decimating when only a handful are on screen (deep zoom) would
|
|
125
|
+
// re-slot each to a 1px sliver. `decimateBox` no-ops (returns the same object)
|
|
126
|
+
// below the visible-density threshold or on a domainless scale, leaving the
|
|
127
|
+
// loop-bound cull above. A selection/hover highlight keyed by the source box's
|
|
128
|
+
// `x` won't match an aggregate column edge — but per-box highlight is
|
|
129
|
+
// meaningless at decimation density, and hit-testing still reads the source.
|
|
130
|
+
const decimated = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
|
|
131
|
+
if (decimated !== box) {
|
|
132
|
+
box = decimated; // aggregate boxes are already the visible set
|
|
133
|
+
vStart = 0;
|
|
134
|
+
vEnd = box.length;
|
|
135
|
+
}
|
|
80
136
|
// A range-only box (bid→ask segment) has no body / median; the whisker (or the
|
|
81
137
|
// solid bar) runs the full lower→upper. Flags default true (a full box).
|
|
82
138
|
const hasBox = box.hasBox !== false;
|
|
83
139
|
const drawMedian = showMedian && box.hasMedian !== false;
|
|
84
|
-
for (let i =
|
|
140
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
85
141
|
if (!isFiniteBox(box, i))
|
|
86
142
|
continue;
|
|
87
143
|
const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
|
|
@@ -159,6 +215,19 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
|
|
|
159
215
|
ctx.lineTo(x1, yMedian);
|
|
160
216
|
ctx.stroke();
|
|
161
217
|
}
|
|
218
|
+
// Selection / hover: outline the whole mark (x-slot × whisker extent) so a
|
|
219
|
+
// click / pointer-over reads back on the canvas. Selected = full strength;
|
|
220
|
+
// hovered = fainter. Bracketed so alpha/width don't leak to the next box.
|
|
221
|
+
const key = box.x[i];
|
|
222
|
+
if (key === selectedKey || key === hoveredKey) {
|
|
223
|
+
ctx.save();
|
|
224
|
+
ctx.strokeStyle = style.stroke;
|
|
225
|
+
ctx.lineWidth =
|
|
226
|
+
key === selectedKey ? style.strokeWidth + 1 : style.strokeWidth;
|
|
227
|
+
ctx.globalAlpha = key === selectedKey ? 1 : 0.5;
|
|
228
|
+
ctx.strokeRect(x0, yUpper, x1 - x0, yLower - yUpper);
|
|
229
|
+
ctx.restore();
|
|
230
|
+
}
|
|
162
231
|
}
|
|
163
232
|
}
|
|
164
233
|
/**
|
package/dist/context.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ScaleLinear, ScaleTime } from 'd3-scale';
|
|
2
2
|
import type { ChartTheme } from './theme.js';
|
|
3
3
|
import type { AxisFormat } from './format.js';
|
|
4
|
+
import type { LegendItemSpec } from './swatch.js';
|
|
4
5
|
import type { Interval } from 'pond-ts';
|
|
5
6
|
import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
|
|
6
7
|
import type { ScaleBand } from './bandScale.js';
|
|
@@ -139,7 +140,8 @@ export interface ContainerFrame {
|
|
|
139
140
|
* a row may override it via its own `cursor`. */
|
|
140
141
|
readonly cursor: CursorMode;
|
|
141
142
|
/** Show the cursor's time atop the in-chart readout (when a row's cursor draws
|
|
142
|
-
* one), formatted by {@link
|
|
143
|
+
* one), formatted by {@link formatReadout} (else {@link formatTime}, matching
|
|
144
|
+
* the time axis). */
|
|
143
145
|
readonly cursorTime: boolean;
|
|
144
146
|
/**
|
|
145
147
|
* Whether the chart is in **annotation-edit mode** — suppresses the data cursor
|
|
@@ -149,8 +151,18 @@ export interface ContainerFrame {
|
|
|
149
151
|
*/
|
|
150
152
|
readonly editAnnotations: boolean;
|
|
151
153
|
/** Format an epoch-ms instant the same way the time axis labels its ticks —
|
|
152
|
-
* shared by `<TimeAxis>` and the cursor-time
|
|
154
|
+
* shared by `<TimeAxis>` and (absent {@link formatReadout}) the cursor-time
|
|
155
|
+
* readout. Shaped by the container `timeFormat` only, never `cursorFormat`. */
|
|
153
156
|
readonly formatTime: (epochMs: number) => string;
|
|
157
|
+
/**
|
|
158
|
+
* The **readout** channel — defined only when the container's `cursorFormat`
|
|
159
|
+
* is set (time or value axis; a category axis reads names). Readout
|
|
160
|
+
* consumers — the crosshair x pill and in-plot cursor time, marker axis
|
|
161
|
+
* indicators, annotation auto-labels — read `formatReadout ?? <their label
|
|
162
|
+
* formatter>`, so the readout can be shaped (or made more precise than the
|
|
163
|
+
* tick labels) without moving them.
|
|
164
|
+
*/
|
|
165
|
+
readonly formatReadout?: ((value: number) => string) | undefined;
|
|
154
166
|
/** Whether an explicit container `timeFormat` shaped {@link formatTime}. The
|
|
155
167
|
* x axis suppresses its boundary (second) label row when it's set — a
|
|
156
168
|
* custom format owns the whole label, so the ladder mustn't second-line it. */
|
|
@@ -181,6 +193,25 @@ export interface ContainerFrame {
|
|
|
181
193
|
*/
|
|
182
194
|
registerSelectable(key: symbol): void;
|
|
183
195
|
unregisterSelectable(key: symbol): void;
|
|
196
|
+
/**
|
|
197
|
+
* Register this layer's **legend row** — its display label + resolved
|
|
198
|
+
* {@link SwatchSpec} (and selection `id` when it has one) — keyed by the
|
|
199
|
+
* layer's per-instance slot; unregister on unmount (see
|
|
200
|
+
* {@link useLegendItems}). `<Legend>` renders this registry in
|
|
201
|
+
* {@link rowOrder}-then-declaration order, deduped by `id ?? label`; a layer
|
|
202
|
+
* that opted out (`legend={false}`) simply never registers.
|
|
203
|
+
*/
|
|
204
|
+
registerLegendItem(key: symbol, item: LegendItemSpec): void;
|
|
205
|
+
unregisterLegendItem(key: symbol): void;
|
|
206
|
+
/** The registered legend rows, keyed by layer slot (see
|
|
207
|
+
* {@link registerLegendItem}). */
|
|
208
|
+
readonly legendItems: ReadonlyMap<symbol, LegendItemSpec>;
|
|
209
|
+
/**
|
|
210
|
+
* The chart rows' keys in **display (top-to-bottom) order** — mount order,
|
|
211
|
+
* exactly the ordering {@link firstRowKey} is head of. `<Legend>` sorts its
|
|
212
|
+
* rows by this so a two-row chart lists the top row's series first.
|
|
213
|
+
*/
|
|
214
|
+
readonly rowOrder: readonly symbol[];
|
|
184
215
|
/**
|
|
185
216
|
* Shared x→pixel scale, range `[0, plotWidth]`. A d3 `scaleTime` (default) so
|
|
186
217
|
* ticks land on wall-clock boundaries, or a `scaleLinear` when the data is
|
|
@@ -448,7 +479,10 @@ export interface TrackerSample {
|
|
|
448
479
|
readonly value: number;
|
|
449
480
|
/** Dot / label colour — the layer's resolved style colour. */
|
|
450
481
|
readonly color: string;
|
|
451
|
-
/**
|
|
482
|
+
/** Labels the value in a readout: the series identity (`as` ?? column) for a
|
|
483
|
+
* single-value mark; a multi-value mark (band edges, box quantiles, an OHLC
|
|
484
|
+
* quote) emits `"<as> <role>"` composites (`iv lower`, `SPY high`) when its
|
|
485
|
+
* `as` is set, else the raw column / role word. */
|
|
452
486
|
readonly label: string;
|
|
453
487
|
}
|
|
454
488
|
/** One line of a {@link CursorFlag} — a labelled, coloured value. */
|
|
@@ -503,9 +537,13 @@ export interface SelectInfo {
|
|
|
503
537
|
/**
|
|
504
538
|
* The clicked sample's key as epoch ms (its event's `begin`) — click
|
|
505
539
|
* **provenance**, informational. NOT the selection identity (that is {@link id}).
|
|
540
|
+
* A **series-scoped** selection with no sample under it (a `<Legend>` row's
|
|
541
|
+
* default hover/select) carries `NaN` here and in {@link value} — check
|
|
542
|
+
* `Number.isFinite` before treating them as a sample.
|
|
506
543
|
*/
|
|
507
544
|
readonly key: number;
|
|
508
|
-
/** The clicked sample's value (the plotted column) — provenance.
|
|
545
|
+
/** The clicked sample's value (the plotted column) — provenance. `NaN` for a
|
|
546
|
+
* series-scoped selection (see {@link key}). */
|
|
509
547
|
readonly value: number;
|
|
510
548
|
/** The mark's resolved style colour. */
|
|
511
549
|
readonly color: string;
|
|
@@ -581,6 +619,10 @@ export interface AxisSpec {
|
|
|
581
619
|
/** Explicit tick values (from `<YAxis ticks>`), driving BOTH the axis labels
|
|
582
620
|
* and the row's gridlines so they align; `undefined` auto-picks from the scale. */
|
|
583
621
|
readonly tickValues: readonly number[] | undefined;
|
|
622
|
+
/** Explicit auto-tick **count** (from `<YAxis tickCount>`) — a `ticks(count)`
|
|
623
|
+
* target; `undefined` derives the count from the row height (see
|
|
624
|
+
* {@link resolveYTickCount}). Ignored when {@link tickValues} is set. */
|
|
625
|
+
readonly tickCount: number | undefined;
|
|
584
626
|
/**
|
|
585
627
|
* Declaration position among the row's children, injected by `ChartRow`. The
|
|
586
628
|
* row sorts axes by this, so the **first declared** axis is the default
|
|
@@ -606,6 +648,11 @@ export interface RowFrame {
|
|
|
606
648
|
* that set `<YAxis ticks>` — so `Layers` draws gridlines at the same positions
|
|
607
649
|
* the axis labels. Absent id ⇒ that axis auto-picks. */
|
|
608
650
|
readonly tickValues: ReadonlyMap<string, readonly number[]>;
|
|
651
|
+
/** Resolved auto-tick **count** per axis id — the explicit `<YAxis tickCount>`
|
|
652
|
+
* or the row-height-derived default ({@link resolveYTickCount}). The single
|
|
653
|
+
* source both the `<YAxis>` labels and the `Layers` gridlines read, so a
|
|
654
|
+
* label and its gridline stay on the same `ticks(count)`. */
|
|
655
|
+
readonly tickCounts: ReadonlyMap<string, number>;
|
|
609
656
|
/** The side each axis sits on, keyed by id — so an axis-edge overlay (the
|
|
610
657
|
* crosshair value pills) hugs the correct gutter. */
|
|
611
658
|
readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
|