@pond-ts/charts 0.57.0 → 0.58.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +1070 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.js +184 -30
  5. package/dist/BarList.d.ts +85 -5
  6. package/dist/BarList.js +25 -4
  7. package/dist/BoxList.d.ts +70 -3
  8. package/dist/BoxList.js +21 -7
  9. package/dist/BoxPlot.d.ts +2 -1
  10. package/dist/BoxPlot.js +101 -9
  11. package/dist/Candlestick.d.ts +13 -1
  12. package/dist/Candlestick.js +89 -3
  13. package/dist/ChartContainer.d.ts +36 -48
  14. package/dist/ChartContainer.js +465 -59
  15. package/dist/ChartRow.d.ts +9 -2
  16. package/dist/ChartRow.js +86 -12
  17. package/dist/HeatMap.d.ts +176 -0
  18. package/dist/HeatMap.js +344 -0
  19. package/dist/Layers.d.ts +5 -1
  20. package/dist/Layers.js +1014 -253
  21. package/dist/Legend.js +8 -4
  22. package/dist/LineChart.d.ts +18 -1
  23. package/dist/LineChart.js +165 -4
  24. package/dist/ListTable.d.ts +30 -3
  25. package/dist/ListTable.js +381 -23
  26. package/dist/ScatterChart.d.ts +3 -2
  27. package/dist/ScatterChart.js +68 -4
  28. package/dist/XAxis.js +40 -22
  29. package/dist/area.d.ts +34 -1
  30. package/dist/area.js +88 -1
  31. package/dist/bars.d.ts +57 -3
  32. package/dist/bars.js +237 -26
  33. package/dist/box.d.ts +2 -2
  34. package/dist/box.js +158 -40
  35. package/dist/brush.d.ts +142 -0
  36. package/dist/brush.js +179 -0
  37. package/dist/child-index.d.ts +27 -0
  38. package/dist/child-index.js +57 -0
  39. package/dist/context.d.ts +859 -33
  40. package/dist/cursors.d.ts +161 -0
  41. package/dist/cursors.js +503 -0
  42. package/dist/decimate.d.ts +78 -1
  43. package/dist/decimate.js +157 -0
  44. package/dist/heat.d.ts +163 -0
  45. package/dist/heat.js +659 -0
  46. package/dist/index.d.ts +11 -2
  47. package/dist/index.js +22 -0
  48. package/dist/line.d.ts +137 -0
  49. package/dist/line.js +328 -0
  50. package/dist/ohlc.d.ts +16 -1
  51. package/dist/ohlc.js +93 -4
  52. package/dist/scatter.d.ts +17 -9
  53. package/dist/scatter.js +221 -33
  54. package/dist/select.d.ts +13 -5
  55. package/dist/select.js +14 -6
  56. package/dist/selection-fixtures.d.ts +174 -0
  57. package/dist/selection-fixtures.js +569 -0
  58. package/dist/selection-stories.d.ts +73 -0
  59. package/dist/selection-stories.js +301 -0
  60. package/dist/selectors.d.ts +316 -0
  61. package/dist/selectors.js +391 -0
  62. package/dist/span.d.ts +122 -0
  63. package/dist/span.js +203 -0
  64. package/dist/sweep.d.ts +154 -0
  65. package/dist/sweep.js +282 -0
  66. package/dist/theme.d.ts +456 -5
  67. package/dist/theme.js +217 -41
  68. package/dist/tracker.d.ts +6 -0
  69. package/dist/tracker.js +6 -0
  70. package/dist/tradingAxis.fixture.d.ts +78 -0
  71. package/dist/tradingAxis.fixture.js +215 -0
  72. package/dist/useChartLegend.js +18 -3
  73. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -33,6 +33,10 @@ export type { XAxisProps } from './XAxis.js';
33
33
  export type { AxisTransform } from './derivedTicks.js';
34
34
  export { TimeAxis } from './TimeAxis.js';
35
35
  export { CategoryAxis } from './CategoryAxis.js';
36
+ export { HeatMap } from './HeatMap.js';
37
+ export type { HeatMapProps } from './HeatMap.js';
38
+ export { bandedColor, heatValueExtent } from './heat.js';
39
+ export type { HeatStyle } from './heat.js';
36
40
  export type { AxisFormat, CursorFormat } from './format.js';
37
41
  export { LineChart } from './LineChart.js';
38
42
  export type { LineChartProps } from './LineChart.js';
@@ -77,11 +81,16 @@ export type { Curve } from './curve.js';
77
81
  export type { GapMode } from './gaps.js';
78
82
  export type { DecimateOption } from './decimate.js';
79
83
  export { defaultTheme, estelaTheme } from './theme.js';
80
- export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, CandleStyle, BarStyle, } from './theme.js';
84
+ export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, ScatterStates, BoxStyle, BoxStates, BoxLadder, HeatStates, CandleStyle, BarStyle, } from './theme.js';
81
85
  export { cssVarTheme } from './css-theme.js';
82
86
  export type { ChartThemeOverrides, VarReader } from './css-theme.js';
83
87
  export { useChartTheme } from './useChartTheme.js';
84
88
  export type { UseChartThemeOptions } from './useChartTheme.js';
85
- export type { CursorMode, TrackerInfo, TrackerSample, SelectInfo, } from './context.js';
89
+ export { LineCursor, PointCursor, InlineCursor, FlagCursor, CrosshairCursor, RangeCursor, } from './cursors.js';
90
+ export type { LineCursorProps, PointCursorProps, InlineCursorProps, FlagCursorProps, CrosshairCursorProps, RangeCursorProps, } from './cursors.js';
91
+ export { Selector, MultiSelector } from './selectors.js';
92
+ export type { SelectorProps, MultiSelectorProps } from './selectors.js';
93
+ export type { CursorMode, TrackerInfo, TrackerSample, SelectInfo, SelectModifiers, RangeSpan, SpanSelection, SelectionEntry, } from './context.js';
94
+ export { selectionContains, isSpanSelection, sameMark } from './span.js';
86
95
  export type { DrawStatsFrame, LayerDrawInfo } from './context.js';
87
96
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -26,6 +26,11 @@ export { YAxis } from './YAxis.js';
26
26
  export { XAxis } from './XAxis.js';
27
27
  export { TimeAxis } from './TimeAxis.js';
28
28
  export { CategoryAxis } from './CategoryAxis.js';
29
+ // Heat map — a grid of cells, bins on x and the series' columns as rows,
30
+ // colour encoding the value ([PND-HEATMAP]). A single column is a stripe,
31
+ // drawn by the same path.
32
+ export { HeatMap } from './HeatMap.js';
33
+ export { bandedColor, heatValueExtent } from './heat.js';
29
34
  export { LineChart } from './LineChart.js';
30
35
  export { BandChart } from './BandChart.js';
31
36
  export { AreaChart } from './AreaChart.js';
@@ -71,4 +76,21 @@ export { defaultTheme, estelaTheme } from './theme.js';
71
76
  // toggle so a canvas chart follows dark/light (`useChartTheme`).
72
77
  export { cssVarTheme } from './css-theme.js';
73
78
  export { useChartTheme } from './useChartTheme.js';
79
+ // Cursor presets — the mounted-component successors of the `cursor` string
80
+ // modes (interaction RFC §4/A4.1): mount one as a child of <ChartContainer>
81
+ // (the default for every row) or inside a <ChartRow> (the per-row override).
82
+ // The underlying CursorSpec / ResolvedCursorFrame contract stays unpublished
83
+ // until the presets have proven it (RFC Q3).
84
+ export { LineCursor, PointCursor, InlineCursor, FlagCursor, CrosshairCursor, RangeCursor, } from './cursors.js';
85
+ // Selection as a mounted component (interaction RFC §7 / A10): `<Selector>`
86
+ // wraps what it applies to and owns both the gesture and the state it
87
+ // produces — `selected` / `hovered` / `onSelect` / `onHover` are its props,
88
+ // not the container's. Mounting is what enables a plot click at all.
89
+ // `<MultiSelector>` (RFC §8) is its sweep superset: a click still selects one
90
+ // mark, a drag sweeps many and releases `(hits, modifiers, spans)` (A5.2).
91
+ export { Selector, MultiSelector } from './selectors.js';
92
+ // Span-selection membership (interaction RFC A5.2) — the same predicate the
93
+ // layers run per mark, exported so a consumer editing a mixed `selected` array
94
+ // never re-implements the interval test, plus the entry discriminant.
95
+ export { selectionContains, isSpanSelection, sameMark } from './span.js';
74
96
  //# sourceMappingURL=index.js.map
package/dist/line.d.ts CHANGED
@@ -82,4 +82,141 @@ export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries,
82
82
  * negligible next to the row sweep.
83
83
  */
84
84
  export declare function sessionRuns(x: Float64Array, length: number, boundaries: readonly number[]): Array<[number, number]>;
85
+ /** How near the pointer must come to a trace's path to count as a hit, in px.
86
+ * Generous on purpose: a 1.5px stroke is not a target anyone can hit, and a
87
+ * trace has no fat mark to aim at the way a bar does. */
88
+ export declare const TRACE_HIT_PX = 6;
89
+ /**
90
+ * **Is the pointer on this trace?** Distance from `(px, py)` to the drawn
91
+ * polyline, in pixels, or `null` past the tolerance.
92
+ *
93
+ * Returns the **nearest sample's index** on a hit, purely as click provenance —
94
+ * the selection a trace commits is series-scoped ([PND-TRACESEL]), because a
95
+ * sample is not a mark. The index is what the readout can name, not what the
96
+ * selection is keyed on.
97
+ *
98
+ * The x cut is a binary search over `xScale(cs.x[i])` rather than an inverse:
99
+ * a layer's `hitTest` is handed the forward scale only, and a trading-time or
100
+ * band scale has no honest inverse anyway. Monotonic either way, so bisection
101
+ * holds. Then only the two segments either side of that index are measured —
102
+ * the pointer cannot be nearest to any other, so this is `O(log N)` and not a
103
+ * scan, which matters on a decimated million-point trace.
104
+ *
105
+ * Gaps are skipped: a segment with a non-finite end is not drawn, so it cannot
106
+ * be hit. That is the same rule `drawLine` paints by, and it is why clicking a
107
+ * dropout selects nothing rather than the bridge across it.
108
+ */
109
+ export declare function traceHitIndex(cs: ChartSeries, px: number, py: number, xScale: Scale, yScale: Scale, tolerance?: number): number | null;
110
+ /**
111
+ * The **interaction state** a trace draws in, resolved from the selection
112
+ * ([PND-TRACESEL]).
113
+ *
114
+ * - `'rest'` — nothing selected anywhere, or this trace is what's selected and
115
+ * nothing needs emphasis beyond its own.
116
+ * - `'selected'` — this series is the selected one: thicken it.
117
+ * - `'hover'` — transient echo of `selected`.
118
+ * - `'dimmed'` — something *else* is selected: recede, hue intact.
119
+ */
120
+ export type TraceState = 'rest' | 'selected' | 'hover' | 'dimmed';
121
+ /**
122
+ * The style a trace strokes with in a given state — weight and alpha only, so a
123
+ * muted or emphasised line still reads as *which* series it is
124
+ * (`LineStyle.selectedWidth`'s doc has the argument).
125
+ *
126
+ * Returned as a `[style, alpha]` pair rather than folded into the style,
127
+ * because alpha belongs to the canvas' `globalAlpha` and not to a stroke
128
+ * colour: baking it into the colour would lose the hue on a themed line that
129
+ * already carries an alpha of its own.
130
+ */
131
+ export declare function traceStateStyle(style: LineStyle, state: TraceState): readonly [LineStyle, number];
132
+ /**
133
+ * Run `draw` twice to paint a trace **partitioned by a swept window**: the
134
+ * whole trace in `outside`, then the same trace again in `inside`, **clipped to
135
+ * the window's pixel band**.
136
+ *
137
+ * Clipping rather than slicing the series, and that is the load-bearing choice.
138
+ * Slicing would have to re-cull, re-decimate and re-split each piece — three
139
+ * cache misses and three chances for the seam to disagree with itself, and the
140
+ * decimated polyline's bucket edges would not line up with the window's, so
141
+ * the boundary would visibly jitter as the drag moved. Clipping strokes the
142
+ * *identical* geometry twice and lets the rasteriser cut it, so the seam is
143
+ * exact by construction and both passes hit the same decimation cache entry.
144
+ *
145
+ * The window arrives in **pixels** because that is what a clip rect wants and
146
+ * the caller has already mapped it through the scale it drew with.
147
+ */
148
+ export declare function drawPartitioned(ctx: CanvasRenderingContext2D, windowPx: readonly [number, number], height: number, outside: () => LayerDrawStats, inside: () => LayerDrawStats,
149
+ /**
150
+ * Clip the emphasised pass to the window. **`true` for a fill**, whose
151
+ * boundary is a vertical wall by construction; **`false` for a stroke that
152
+ * has sliced its own path** (see {@link sliceTrace}), because a clip would
153
+ * shear the ribbon flat and defeat the round cap the slice exists to allow.
154
+ */
155
+ clipInside?: boolean,
156
+ /**
157
+ * Half the emphasised stroke's width, in px — how far its **round cap**
158
+ * overhangs the window boundary. The outside pass's clip is inset by this,
159
+ * so the cap lands on bare ground instead of on top of the muted trace.
160
+ *
161
+ * Without it the boundary column carries two antialiased edges stacked (the
162
+ * muted stroke's clipped end, plus the cap) and the seam reads muddy at
163
+ * zoom. Measured, not guessed: the draw issues exactly two strokes per
164
+ * trace, so the murk was overlap, never a double render.
165
+ */
166
+ capOverhangPx?: number,
167
+ /** The plot's CSS width — see {@link plotExtentOf}. Defaults to the device
168
+ * width only so a bare test stub still clips something sane. */
169
+ plotWidthCss?: number): LayerDrawStats;
170
+ /**
171
+ * A trace sliced to the key window `[lo, hi]`, with its **endpoints
172
+ * interpolated** onto the path — or `null` when nothing of it falls inside.
173
+ *
174
+ * This exists so the emphasised pass of a partitioned draw can be a real path
175
+ * whose own ends are the window's ends, which is what lets a **round cap**
176
+ * show. A clipped stroke cannot have one: the clip shears the ribbon on a
177
+ * vertical line wherever the rect cuts it, so the cap is drawn off in the
178
+ * hidden part of the path and the visible end is always a hard vertical edge,
179
+ * whatever `lineCap` says.
180
+ *
181
+ * The endpoints are interpolated rather than snapped to the nearest sample
182
+ * because at low density snapping would visibly overshoot or undershoot the
183
+ * window the reader just swept — the emphasis would not line up with the band
184
+ * that produced it.
185
+ *
186
+ * A boundary landing on a **gap** contributes no interpolated point: there is
187
+ * no drawn segment there to sit on, and inventing one would bridge a hole the
188
+ * trace deliberately shows.
189
+ */
190
+ export declare function sliceTrace(cs: ChartSeries, lo: number, hi: number): ChartSeries | null;
191
+ /**
192
+ * **EXPERIMENT ([PND-ANNSNAP]).** Vertical rules at a swept window's edges, in
193
+ * the annotation register — a preview of what "promote this sweep to an
194
+ * annotation" would look like, drawn *underneath* the trace.
195
+ *
196
+ * Two caveats, and the second decides whether this survives:
197
+ *
198
+ * - **Opaque on purpose.** Every spanned layer in the row draws its own edges
199
+ * at the same x, so a translucent stroke would composite once per trace and
200
+ * darken with the number of series. Opaque makes the overdraw idempotent.
201
+ * - **A real annotation could not sit here.** Annotations render in the SVG
202
+ * overlay *above* the canvas, so a promoted span's rules would land on top of
203
+ * the traces, not under them. This is canvas-side precisely because "under"
204
+ * was asked for — if the look is kept, the honest options are to accept rules
205
+ * above the ink, or to give the annotation register a canvas-underlay pass.
206
+ */
207
+ /**
208
+ * The plot's extent in **CSS pixels**, from the scales' own ranges.
209
+ *
210
+ * Not `ctx.canvas.width`/`height`: those are **device** pixels, while the
211
+ * context is pre-transformed by the device ratio, so mixing them overshoots at
212
+ * dpr>1 and — the case that actually breaks — *undershoots* at dpr<1 (a
213
+ * zoomed-out browser), collapsing a clip that should span the plot. The scales
214
+ * are the honest source, and this needs no dpr arithmetic at all. Falls back to
215
+ * the canvas dims only when a scale exposes no range (a bare test stub).
216
+ */
217
+ export declare function plotExtentOf(ctx: CanvasRenderingContext2D, xScale: Scale, yScale: Scale): {
218
+ readonly width: number;
219
+ readonly height: number;
220
+ };
221
+ export declare function strokeSpanEdges(ctx: CanvasRenderingContext2D, windowPx: readonly [number, number], height: number, color: string, width?: number): void;
85
222
  //# sourceMappingURL=line.d.ts.map
package/dist/line.js CHANGED
@@ -260,4 +260,332 @@ export function sessionRuns(x, length, boundaries) {
260
260
  runs.push([start, length]);
261
261
  return runs;
262
262
  }
263
+ /** How near the pointer must come to a trace's path to count as a hit, in px.
264
+ * Generous on purpose: a 1.5px stroke is not a target anyone can hit, and a
265
+ * trace has no fat mark to aim at the way a bar does. */
266
+ export const TRACE_HIT_PX = 6;
267
+ /**
268
+ * **Is the pointer on this trace?** Distance from `(px, py)` to the drawn
269
+ * polyline, in pixels, or `null` past the tolerance.
270
+ *
271
+ * Returns the **nearest sample's index** on a hit, purely as click provenance —
272
+ * the selection a trace commits is series-scoped ([PND-TRACESEL]), because a
273
+ * sample is not a mark. The index is what the readout can name, not what the
274
+ * selection is keyed on.
275
+ *
276
+ * The x cut is a binary search over `xScale(cs.x[i])` rather than an inverse:
277
+ * a layer's `hitTest` is handed the forward scale only, and a trading-time or
278
+ * band scale has no honest inverse anyway. Monotonic either way, so bisection
279
+ * holds. Then only the two segments either side of that index are measured —
280
+ * the pointer cannot be nearest to any other, so this is `O(log N)` and not a
281
+ * scan, which matters on a decimated million-point trace.
282
+ *
283
+ * Gaps are skipped: a segment with a non-finite end is not drawn, so it cannot
284
+ * be hit. That is the same rule `drawLine` paints by, and it is why clicking a
285
+ * dropout selects nothing rather than the bridge across it.
286
+ */
287
+ export function traceHitIndex(cs, px, py, xScale, yScale, tolerance = TRACE_HIT_PX) {
288
+ const n = cs.length;
289
+ if (n === 0)
290
+ return null;
291
+ // Bisect to the first sample at or past the pointer's x.
292
+ let lo = 0;
293
+ let hi = n;
294
+ while (lo < hi) {
295
+ const mid = (lo + hi) >> 1;
296
+ if (xScale(cs.x[mid]) < px)
297
+ lo = mid + 1;
298
+ else
299
+ hi = mid;
300
+ }
301
+ let best = Infinity;
302
+ let bestIdx = null;
303
+ // The pointer's nearest point on the path lies on one of the two segments
304
+ // touching `lo`, so `lo - 1 … lo + 1` bounds everything worth measuring.
305
+ // The segments touching `lo` are `[lo-1, lo]` and `[lo, lo+1]`, so the
306
+ // indices to measure are `lo-1` and `lo` — inclusive of `lo`. The previous
307
+ // exclusive bound `< min(n-1, lo+1)` collapsed to an empty range when the
308
+ // pointer sat past the last sample (`lo === n`), so a within-tolerance click
309
+ // off the right end missed while the left-end mirror worked (reviewer find).
310
+ for (let i = Math.max(0, lo - 1); i <= Math.min(n - 2, lo); i += 1) {
311
+ const ay = cs.y[i];
312
+ const by = cs.y[i + 1];
313
+ if (!Number.isFinite(ay) || !Number.isFinite(by))
314
+ continue;
315
+ const ax = xScale(cs.x[i]);
316
+ const bx = xScale(cs.x[i + 1]);
317
+ const d = pointSegmentDistance(px, py, ax, yScale(ay), bx, yScale(by));
318
+ if (d < best) {
319
+ best = d;
320
+ // Attribute to whichever END the pointer is nearer, so the provenance
321
+ // index is the sample a reader would say they clicked.
322
+ const da = Math.hypot(px - ax, py - yScale(ay));
323
+ const db = Math.hypot(px - bx, py - yScale(by));
324
+ bestIdx = da <= db ? i : i + 1;
325
+ }
326
+ }
327
+ // A single-sample trace draws no segment, so measure the point itself —
328
+ // otherwise a one-point series would be unhittable.
329
+ if (bestIdx === null && n === 1 && Number.isFinite(cs.y[0])) {
330
+ const d = Math.hypot(px - xScale(cs.x[0]), py - yScale(cs.y[0]));
331
+ if (d <= tolerance)
332
+ return 0;
333
+ return null;
334
+ }
335
+ return best <= tolerance ? bestIdx : null;
336
+ }
337
+ /** Euclidean distance from a point to a line segment, in the same units. */
338
+ function pointSegmentDistance(px, py, ax, ay, bx, by) {
339
+ const dx = bx - ax;
340
+ const dy = by - ay;
341
+ const lenSq = dx * dx + dy * dy;
342
+ // A degenerate segment (both ends on one pixel) is a point.
343
+ if (lenSq === 0)
344
+ return Math.hypot(px - ax, py - ay);
345
+ // Project onto the segment, clamped to its ends.
346
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
347
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
348
+ }
349
+ /**
350
+ * The style a trace strokes with in a given state — weight and alpha only, so a
351
+ * muted or emphasised line still reads as *which* series it is
352
+ * (`LineStyle.selectedWidth`'s doc has the argument).
353
+ *
354
+ * Returned as a `[style, alpha]` pair rather than folded into the style,
355
+ * because alpha belongs to the canvas' `globalAlpha` and not to a stroke
356
+ * colour: baking it into the colour would lose the hue on a themed line that
357
+ * already carries an alpha of its own.
358
+ */
359
+ export function traceStateStyle(style, state) {
360
+ const selected = style.selectedWidth ?? style.width * 2;
361
+ switch (state) {
362
+ case 'selected':
363
+ return [{ ...style, width: selected }, 1];
364
+ case 'hover':
365
+ return [{ ...style, width: style.hoverWidth ?? selected }, 1];
366
+ case 'dimmed':
367
+ return [style, style.dimmedOpacity ?? 0.32];
368
+ case 'rest':
369
+ return [style, 1];
370
+ }
371
+ }
372
+ /**
373
+ * Run `draw` twice to paint a trace **partitioned by a swept window**: the
374
+ * whole trace in `outside`, then the same trace again in `inside`, **clipped to
375
+ * the window's pixel band**.
376
+ *
377
+ * Clipping rather than slicing the series, and that is the load-bearing choice.
378
+ * Slicing would have to re-cull, re-decimate and re-split each piece — three
379
+ * cache misses and three chances for the seam to disagree with itself, and the
380
+ * decimated polyline's bucket edges would not line up with the window's, so
381
+ * the boundary would visibly jitter as the drag moved. Clipping strokes the
382
+ * *identical* geometry twice and lets the rasteriser cut it, so the seam is
383
+ * exact by construction and both passes hit the same decimation cache entry.
384
+ *
385
+ * The window arrives in **pixels** because that is what a clip rect wants and
386
+ * the caller has already mapped it through the scale it drew with.
387
+ */
388
+ export function drawPartitioned(ctx, windowPx, height, outside, inside,
389
+ /**
390
+ * Clip the emphasised pass to the window. **`true` for a fill**, whose
391
+ * boundary is a vertical wall by construction; **`false` for a stroke that
392
+ * has sliced its own path** (see {@link sliceTrace}), because a clip would
393
+ * shear the ribbon flat and defeat the round cap the slice exists to allow.
394
+ */
395
+ clipInside = true,
396
+ /**
397
+ * Half the emphasised stroke's width, in px — how far its **round cap**
398
+ * overhangs the window boundary. The outside pass's clip is inset by this,
399
+ * so the cap lands on bare ground instead of on top of the muted trace.
400
+ *
401
+ * Without it the boundary column carries two antialiased edges stacked (the
402
+ * muted stroke's clipped end, plus the cap) and the seam reads muddy at
403
+ * zoom. Measured, not guessed: the draw issues exactly two strokes per
404
+ * trace, so the murk was overlap, never a double render.
405
+ */
406
+ capOverhangPx = 0,
407
+ /** The plot's CSS width — see {@link plotExtentOf}. Defaults to the device
408
+ * width only so a bare test stub still clips something sane. */
409
+ plotWidthCss) {
410
+ const [x0, x1] = windowPx;
411
+ // A collapsed window has no inside to paint, so one plain pass is the whole
412
+ // picture — and `clip()` on a zero-width rect would suppress the second pass
413
+ // anyway.
414
+ if (x1 <= x0)
415
+ return outside();
416
+ // **Both passes are clipped, and the outside one to the window's COMPLEMENT.**
417
+ // Painting the muted trace full-width and the emphasised one over it works
418
+ // for a line, whose stroke is opaque and covers itself. It is wrong for an
419
+ // **area**: a semi-transparent fill composites with whatever is under it, so
420
+ // the window would carry muted-plus-emphasised stacked and read darker than
421
+ // either — the emphasis would depend on what it was drawn over. Clipping the
422
+ // outside pass away from the window means each pixel is painted exactly once,
423
+ // whatever its alpha.
424
+ ctx.save();
425
+ ctx.beginPath();
426
+ // The complement as two rects in one path: everything left of the window,
427
+ // everything right of it. A negative-width rect is legal but not portable
428
+ // across every canvas impl, so clamp rather than rely on it.
429
+ const inset = Math.max(0, capOverhangPx);
430
+ const leftEdge = x0 - inset;
431
+ const rightEdge = x1 + inset;
432
+ if (leftEdge > 0)
433
+ ctx.rect(0, 0, leftEdge, height);
434
+ const right = plotWidthCss ?? ctx.canvas.width;
435
+ ctx.rect(rightEdge, 0, Math.max(0, right - rightEdge), height);
436
+ ctx.clip();
437
+ const stats = outside();
438
+ ctx.restore();
439
+ if (!clipInside) {
440
+ // The caller's path already ends where the window does, so it needs no
441
+ // clip — and its round caps may overhang the boundary by half a stroke,
442
+ // which is exactly the look: a rounded end sitting on the muted trace.
443
+ inside();
444
+ return stats;
445
+ }
446
+ ctx.save();
447
+ ctx.beginPath();
448
+ ctx.rect(x0, 0, x1 - x0, height);
449
+ ctx.clip();
450
+ inside();
451
+ ctx.restore();
452
+ // The stats describe the geometry walked, and the clipped pass walks the
453
+ // same series — reporting it twice would double every count in the draw
454
+ // budget for what is one trace.
455
+ return stats;
456
+ }
457
+ /**
458
+ * A trace sliced to the key window `[lo, hi]`, with its **endpoints
459
+ * interpolated** onto the path — or `null` when nothing of it falls inside.
460
+ *
461
+ * This exists so the emphasised pass of a partitioned draw can be a real path
462
+ * whose own ends are the window's ends, which is what lets a **round cap**
463
+ * show. A clipped stroke cannot have one: the clip shears the ribbon on a
464
+ * vertical line wherever the rect cuts it, so the cap is drawn off in the
465
+ * hidden part of the path and the visible end is always a hard vertical edge,
466
+ * whatever `lineCap` says.
467
+ *
468
+ * The endpoints are interpolated rather than snapped to the nearest sample
469
+ * because at low density snapping would visibly overshoot or undershoot the
470
+ * window the reader just swept — the emphasis would not line up with the band
471
+ * that produced it.
472
+ *
473
+ * A boundary landing on a **gap** contributes no interpolated point: there is
474
+ * no drawn segment there to sit on, and inventing one would bridge a hole the
475
+ * trace deliberately shows.
476
+ */
477
+ export function sliceTrace(cs, lo, hi) {
478
+ const n = cs.length;
479
+ if (n === 0 || hi <= lo)
480
+ return null;
481
+ // First index at or past `lo`, first past `hi` — the interior run.
482
+ let a = 0;
483
+ let b = n;
484
+ while (a < b) {
485
+ const mid = (a + b) >> 1;
486
+ if (cs.x[mid] < lo)
487
+ a = mid + 1;
488
+ else
489
+ b = mid;
490
+ }
491
+ let e = a;
492
+ while (e < n && cs.x[e] <= hi)
493
+ e += 1;
494
+ const xs = [];
495
+ const ys = [];
496
+ /** Value on the segment `[i-1, i]` at key `k`, or null across a gap/edge. */
497
+ const at = (i, k) => {
498
+ if (i <= 0 || i >= n)
499
+ return null;
500
+ const y0 = cs.y[i - 1];
501
+ const y1 = cs.y[i];
502
+ if (!Number.isFinite(y0) || !Number.isFinite(y1))
503
+ return null;
504
+ const x0 = cs.x[i - 1];
505
+ const x1 = cs.x[i];
506
+ if (x1 === x0)
507
+ return y1;
508
+ const t = (k - x0) / (x1 - x0);
509
+ return y0 + (y1 - y0) * t;
510
+ };
511
+ const head = at(a, lo);
512
+ if (head !== null) {
513
+ xs.push(lo);
514
+ ys.push(head);
515
+ }
516
+ for (let i = a; i < e; i += 1) {
517
+ xs.push(cs.x[i]);
518
+ ys.push(cs.y[i]);
519
+ }
520
+ const tail = at(e, hi);
521
+ if (tail !== null) {
522
+ xs.push(hi);
523
+ ys.push(tail);
524
+ }
525
+ if (xs.length === 0)
526
+ return null;
527
+ return {
528
+ x: Float64Array.from(xs),
529
+ y: Float64Array.from(ys),
530
+ length: xs.length,
531
+ };
532
+ }
533
+ /**
534
+ * **EXPERIMENT ([PND-ANNSNAP]).** Vertical rules at a swept window's edges, in
535
+ * the annotation register — a preview of what "promote this sweep to an
536
+ * annotation" would look like, drawn *underneath* the trace.
537
+ *
538
+ * Two caveats, and the second decides whether this survives:
539
+ *
540
+ * - **Opaque on purpose.** Every spanned layer in the row draws its own edges
541
+ * at the same x, so a translucent stroke would composite once per trace and
542
+ * darken with the number of series. Opaque makes the overdraw idempotent.
543
+ * - **A real annotation could not sit here.** Annotations render in the SVG
544
+ * overlay *above* the canvas, so a promoted span's rules would land on top of
545
+ * the traces, not under them. This is canvas-side precisely because "under"
546
+ * was asked for — if the look is kept, the honest options are to accept rules
547
+ * above the ink, or to give the annotation register a canvas-underlay pass.
548
+ */
549
+ /**
550
+ * The plot's extent in **CSS pixels**, from the scales' own ranges.
551
+ *
552
+ * Not `ctx.canvas.width`/`height`: those are **device** pixels, while the
553
+ * context is pre-transformed by the device ratio, so mixing them overshoots at
554
+ * dpr>1 and — the case that actually breaks — *undershoots* at dpr<1 (a
555
+ * zoomed-out browser), collapsing a clip that should span the plot. The scales
556
+ * are the honest source, and this needs no dpr arithmetic at all. Falls back to
557
+ * the canvas dims only when a scale exposes no range (a bare test stub).
558
+ */
559
+ export function plotExtentOf(ctx, xScale, yScale) {
560
+ const span = (s, fallback) => {
561
+ const r = s.range?.();
562
+ if (r === undefined || r.length < 2)
563
+ return fallback;
564
+ return Math.abs(+r[r.length - 1] - +r[0]);
565
+ };
566
+ return {
567
+ width: span(xScale, ctx.canvas.width),
568
+ height: span(yScale, ctx.canvas.height),
569
+ };
570
+ }
571
+ export function strokeSpanEdges(ctx, windowPx, height, color, width = 1) {
572
+ const prior = ctx.strokeStyle;
573
+ const priorWidth = ctx.lineWidth;
574
+ const priorAlpha = ctx.globalAlpha;
575
+ ctx.globalAlpha = 1;
576
+ ctx.strokeStyle = color;
577
+ ctx.lineWidth = width;
578
+ for (const x of windowPx) {
579
+ // Half-pixel offset so a 1px rule lands on one device column instead of
580
+ // straddling two and rendering as a 2px smear.
581
+ const px = Math.round(x) + 0.5;
582
+ ctx.beginPath();
583
+ ctx.moveTo(px, 0);
584
+ ctx.lineTo(px, height);
585
+ ctx.stroke();
586
+ }
587
+ ctx.strokeStyle = prior;
588
+ ctx.lineWidth = priorWidth;
589
+ ctx.globalAlpha = priorAlpha;
590
+ }
263
591
  //# sourceMappingURL=line.js.map
package/dist/ohlc.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { Scale } from './line.js';
3
3
  import type { CandleStyle } from './theme.js';
4
4
  import type { LayerDrawStats } from './context.js';
5
5
  import { type DecimateOption } from './decimate.js';
6
+ import type { SpanSelection } from './context.js';
6
7
  /**
7
8
  * How an OHLC mark renders (pjm17971's fork 2 — bundled as one component, like
8
9
  * {@link BoxShape}, not split into a separate `<OHLCBar>`):
@@ -79,5 +80,19 @@ export declare function resolveCandleStyle(style: CandleStyle, open: number, clo
79
80
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
80
81
  * beyond the `barSpanPx` tuple.
81
82
  */
82
- export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number, decimate?: DecimateOption): LayerDrawStats;
83
+ /**
84
+ * The candle whose **slot** contains `(px, py)` — the same rect-containment
85
+ * `boxAt` does, over the candle's full `[x0, x1] × [high, low]` extent.
86
+ *
87
+ * Deliberately the slot and not the ink. A candle's body can be a doji a
88
+ * pixel tall and its wick is a hairline; requiring the pointer to land on
89
+ * drawn pixels would make most candles unclickable. (That the box layer makes
90
+ * the same choice *without* saying so is [PND-BOXHIT].)
91
+ */
92
+ export declare function ohlcAt(ohlc: OhlcSeries, px: number, py: number, xScale: Scale, yScale: Scale, gapPx: number, minWidthPx: number): [index: number, begin: number, close: number] | null;
93
+ export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number, decimate?: DecimateOption,
94
+ /** Candle keys (each candle's `x`) currently selected / hovered, and the
95
+ * selection's span entries — the same three channels the bar and box draws
96
+ * take. Empty ⇒ a display-only candle, byte-identical to before. */
97
+ selectedKeys?: readonly number[], hoveredKeys?: readonly number[], spans?: readonly SpanSelection[]): LayerDrawStats;
83
98
  //# sourceMappingURL=ohlc.d.ts.map