@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/XAxis.js CHANGED
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useContext } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { derivedTicks } from './derivedTicks.js';
5
- import { ContainerContext, CursorContext } from './context.js';
5
+ import { ContainerContext, CursorContext, } from './context.js';
6
+ import { xAxisCursorEntries } from './cursors.js';
6
7
  import { axisPillStyle } from './chip.js';
7
8
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
8
9
  /** Tick strip height (mark + value label) in CSS px. */
@@ -65,16 +66,19 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
65
66
  // gridlines and `formatTime` use, so labels and grid stay on the same instants
66
67
  // (width-derived on a trading-time axis).
67
68
  const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
68
- // The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
69
- // cursor is live in-bounds, pin the hovered time to this axis (covering the
70
- // tick behind it), matching the on-axis y value pills the rows draw. Gated on
71
- // the container default, so a per-row `cursor` override doesn't reach here.
69
+ // The cursor's x-axis slot: did the mounted cursor in effect register one
70
+ // (`renderXAxis` the crosshair's time pill)? While hovering, that is the
71
+ // **hovered row's** effective cursor so a per-row override reaches this
72
+ // axis, which the old `container.cursor === 'crosshair'` string gate never
73
+ // let it do (a row-level crosshair had no time pill); with no live pointer
74
+ // (a controlled `trackerPosition`), any scope's effective cursor keeps its
75
+ // pill. The slot renders below when the cursor is live in-bounds.
72
76
  const cursorX = cursor.cursorX;
73
- const showCursorTag = container.cursor === 'crosshair' &&
77
+ const xSlotEntries = xAxisCursorEntries(container.cursors, cursor.cursorRowKey).filter((e) => e.spec.renderXAxis !== undefined);
78
+ const showCursorTag = xSlotEntries.length > 0 &&
74
79
  cursorX !== null &&
75
80
  cursorX >= 0 &&
76
81
  cursorX <= plotWidth;
77
- const cursorColor = theme.cursor ?? theme.axis.label;
78
82
  const annotationColor = theme.annotation?.color ?? '#0d9488';
79
83
  // Derived-unit (`transform`) layout: nice ticks in the derived unit at
80
84
  // mixed step sizes, admitted where they keep pixel room (see derivedTicks).
@@ -417,20 +421,34 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
417
421
  [onTop ? 'bottom' : 'top']: `${laneY}px`,
418
422
  zIndex: 2,
419
423
  }, children: t.text })] }, t.id));
420
- }), showCursorTag && (_jsxs(Fragment, { children: [_jsx("div", { style: {
421
- position: 'absolute',
422
- left: `${cursorX}px`,
423
- [onTop ? 'bottom' : 'top']: 0,
424
- width: '1px',
425
- height: `${pillOffset}px`,
426
- background: cursorColor,
427
- zIndex: 3,
428
- } }), _jsx("div", { style: {
429
- ...axisPillStyle(theme, cursorColor),
430
- left: `${cursorX}px`,
431
- transform: 'translateX(-50%)',
432
- [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
433
- zIndex: 3,
434
- }, children: readoutFmt(+xScale.invert(cursorX)) })] }))] }));
424
+ }), showCursorTag &&
425
+ (() => {
426
+ // The x-axis {@link ResolvedCursorFrame}: no row, no samples — the
427
+ // slot draws a pill from the resolved cursor x + the axis's own
428
+ // readout-formatted time (this axis's precedence chain, `readoutFmt`),
429
+ // placed per the strip's side + tick-label offset.
430
+ const f = {
431
+ cursorX,
432
+ cursorY: null,
433
+ rowKey: null,
434
+ hoveredRowKey: cursor.cursorRowKey,
435
+ samples: [],
436
+ flags: [],
437
+ pointer: null,
438
+ band: null,
439
+ bandY: null, // …nor a transposed one: same reason as `rect`
440
+ bandLine: false,
441
+ bandDragging: false, // no band in the axis strip to edge
442
+ rect: null, // …and no 2-D brush: the strip has no y
443
+ restingCross: false,
444
+ formattedTime: readoutFmt(+xScale.invert(cursorX)),
445
+ plotWidth,
446
+ rowHeight: 0,
447
+ isFirstRow: false,
448
+ theme,
449
+ xAxis: { onTop, pillOffset },
450
+ };
451
+ return xSlotEntries.map((e, i) => (_jsx(Fragment, { children: e.spec.renderXAxis(f) }, `cursor-${i}`)));
452
+ })()] }));
435
453
  }
436
454
  //# sourceMappingURL=XAxis.js.map
package/dist/area.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { ChartSeries } from './data.js';
3
- import { type Scale } from './line.js';
3
+ import { type Scale, type TraceState } from './line.js';
4
4
  import type { AreaStyle } from './theme.js';
5
5
  import type { LayerDrawStats } from './context.js';
6
6
  import { type GapMode } from './gaps.js';
@@ -70,4 +70,37 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
70
70
  * are collected by one O(N) walk ({@link collectGapEdges}).
71
71
  */
72
72
  export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): LayerDrawStats;
73
+ /**
74
+ * **Is the pointer inside this area?** The filled-region counterpart of
75
+ * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
76
+ * click provenance, or `null` when the pointer is outside the fill.
77
+ *
78
+ * An area is not a stroke, so the test is not distance-to-path: the pointer is
79
+ * on the area when it lies **between the trace and the baseline** at that x.
80
+ * That is the honest reading of "you clicked the area", and it makes the whole
81
+ * filled shape the target rather than a 1.5px edge — which is the same reason
82
+ * the list family made the row the target rather than the bar.
83
+ *
84
+ * The x cut bisects `xScale(cs.x[i])` for `traceHitIndex`'s reasons (a layer's
85
+ * `hitTest` gets the forward scale only, and a trading-time scale has no honest
86
+ * inverse), and the trace's y is **interpolated** between the bracketing
87
+ * samples so the boundary follows the drawn edge rather than a step. A tolerance
88
+ * is still added, so the top edge is grabbable from just outside.
89
+ *
90
+ * A gap is a hole, not a bridge: either bracketing sample non-finite ⇒ no hit,
91
+ * matching what `drawArea` actually fills.
92
+ */
93
+ export declare function areaHitIndex(cs: ChartSeries, baseline: number | undefined, px: number, py: number, xScale: Scale, yScale: Scale, tolerance?: number): number | null;
94
+ /**
95
+ * The style an area fills with in a given interaction state
96
+ * ([PND-TRACESEL]) — the {@link AreaStyle} counterpart of `traceStateStyle`.
97
+ *
98
+ * The channels differ from a line's because what carries the mark differs: an
99
+ * area's mark is its **fill**, so state is the fill's strength plus the edge's
100
+ * weight. A line has only a stroke, so there weight is all there is.
101
+ *
102
+ * Alpha comes back separately, as it does for a line, so a muted area keeps its
103
+ * hue rather than having the fade baked into a colour.
104
+ */
105
+ export declare function areaStateStyle(style: AreaStyle, state: TraceState): readonly [AreaStyle, number];
73
106
  //# sourceMappingURL=area.d.ts.map
package/dist/area.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
- import { strokeAffinePolyline } from './line.js';
2
+ import { baselinePxFromScale, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
3
3
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
4
4
  import { cullChartSeries } from './culling.js';
5
5
  import { decimateM4Cached } from './decimate.js';
@@ -345,4 +345,91 @@ function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
345
345
  }
346
346
  return grad;
347
347
  }
348
+ /**
349
+ * **Is the pointer inside this area?** The filled-region counterpart of
350
+ * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
351
+ * click provenance, or `null` when the pointer is outside the fill.
352
+ *
353
+ * An area is not a stroke, so the test is not distance-to-path: the pointer is
354
+ * on the area when it lies **between the trace and the baseline** at that x.
355
+ * That is the honest reading of "you clicked the area", and it makes the whole
356
+ * filled shape the target rather than a 1.5px edge — which is the same reason
357
+ * the list family made the row the target rather than the bar.
358
+ *
359
+ * The x cut bisects `xScale(cs.x[i])` for `traceHitIndex`'s reasons (a layer's
360
+ * `hitTest` gets the forward scale only, and a trading-time scale has no honest
361
+ * inverse), and the trace's y is **interpolated** between the bracketing
362
+ * samples so the boundary follows the drawn edge rather than a step. A tolerance
363
+ * is still added, so the top edge is grabbable from just outside.
364
+ *
365
+ * A gap is a hole, not a bridge: either bracketing sample non-finite ⇒ no hit,
366
+ * matching what `drawArea` actually fills.
367
+ */
368
+ export function areaHitIndex(cs, baseline, px, py, xScale, yScale, tolerance = TRACE_HIT_PX) {
369
+ const n = cs.length;
370
+ if (n === 0)
371
+ return null;
372
+ let lo = 0;
373
+ let hi = n;
374
+ while (lo < hi) {
375
+ const mid = (lo + hi) >> 1;
376
+ if (xScale(cs.x[mid]) < px)
377
+ lo = mid + 1;
378
+ else
379
+ hi = mid;
380
+ }
381
+ // The bracketing pair around the pointer. At either end of the series the
382
+ // pair degenerates to one sample, which is correct — the fill stops there.
383
+ const i = Math.max(0, Math.min(n - 1, lo));
384
+ const j = Math.max(0, Math.min(n - 1, lo === 0 ? 0 : lo - 1));
385
+ const yi = cs.y[i];
386
+ const yj = cs.y[j];
387
+ if (!Number.isFinite(yi) || !Number.isFinite(yj))
388
+ return null;
389
+ const xi = xScale(cs.x[i]);
390
+ const xj = xScale(cs.x[j]);
391
+ // Interpolate the edge at the pointer's x, so the boundary is the drawn
392
+ // slope and not a staircase. Guard the degenerate same-pixel pair.
393
+ const t = xi === xj ? 0 : (px - xj) / (xi - xj);
394
+ const edgePx = yScale(yj + (yi - yj) * Math.max(0, Math.min(1, t)));
395
+ const basePx = baseline === undefined ? baselinePxFromScale(yScale) : yScale(baseline);
396
+ const top = Math.min(edgePx, basePx) - tolerance;
397
+ const bottom = Math.max(edgePx, basePx) + tolerance;
398
+ if (py < top || py > bottom)
399
+ return null;
400
+ // Outside the series' own x span there is no fill to be inside of.
401
+ if (px < xScale(cs.x[0]) - tolerance)
402
+ return null;
403
+ if (px > xScale(cs.x[n - 1]) + tolerance)
404
+ return null;
405
+ return Math.abs(px - xi) <= Math.abs(px - xj) ? i : j;
406
+ }
407
+ /**
408
+ * The style an area fills with in a given interaction state
409
+ * ([PND-TRACESEL]) — the {@link AreaStyle} counterpart of `traceStateStyle`.
410
+ *
411
+ * The channels differ from a line's because what carries the mark differs: an
412
+ * area's mark is its **fill**, so state is the fill's strength plus the edge's
413
+ * weight. A line has only a stroke, so there weight is all there is.
414
+ *
415
+ * Alpha comes back separately, as it does for a line, so a muted area keeps its
416
+ * hue rather than having the fade baked into a colour.
417
+ */
418
+ export function areaStateStyle(style, state) {
419
+ const emphasised = {
420
+ ...style,
421
+ width: style.selectedWidth ?? style.width * 2,
422
+ fillOpacity: style.selectedFillOpacity ?? Math.min(style.fillOpacity * 2, 1),
423
+ };
424
+ switch (state) {
425
+ case 'selected':
426
+ return [emphasised, 1];
427
+ case 'hover':
428
+ return [emphasised, 1];
429
+ case 'dimmed':
430
+ return [style, style.dimmedOpacity ?? 0.32];
431
+ case 'rest':
432
+ return [style, 1];
433
+ }
434
+ }
348
435
  //# sourceMappingURL=area.js.map
package/dist/bars.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { BarSeries, StackedBarSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BarStyle } from './theme.js';
4
- import type { LayerDrawStats } from './context.js';
4
+ import type { LayerDrawStats, SpanSelection } from './context.js';
5
5
  import { type DecimateOption } from './decimate.js';
6
6
  /**
7
7
  * Bar growth direction — the histogram orientation. `'vertical'` bars grow **up**
@@ -146,6 +146,21 @@ export interface BarMark {
146
146
  readonly key: number;
147
147
  readonly mark?: string;
148
148
  }
149
+ /**
150
+ * Stroke a selected mark's outline **inside** its ink rect. Canvas strokes
151
+ * centre on the path, so a plain `strokeRect(x0, yTop, …)` paints
152
+ * `lineWidth / 2` *outside* the rect — which, with the default
153
+ * `outlineWidth: 1.5` against the default `gap: 1`, is enough to bridge the
154
+ * whole inter-bar gap from both sides: a swept run of selected bars fused
155
+ * into one unreadable block (you could not count them). Insetting the path by
156
+ * half the line width keeps every stroked pixel within the bar's own ink, so
157
+ * adjacent selected marks stay separated by exactly the gap the resting bars
158
+ * show. A mark too thin to contain its outline (either dimension
159
+ * `<= lineWidth`) skips the stroke — the highlight fill is already the
160
+ * signal, and an outline wider than the bar would only smear into the
161
+ * neighbours this exists to keep distinct.
162
+ */
163
+ export declare function strokeSelectedOutline(ctx: CanvasRenderingContext2D, x0: number, x1: number, yTop: number, yBottom: number, lineWidth: number): void;
149
164
  /**
150
165
  * Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
151
166
  * (inset by `gapPx`) from the resolved `baseline` to the value.
@@ -198,7 +213,7 @@ export interface BarMark {
198
213
  * repaint them one flat colour; per-bar-coloured layers draw every visible bar.
199
214
  * Returns {@link LayerDrawStats} for `onDrawStats`.
200
215
  */
201
- export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[], banding?: BandLadder): LayerDrawStats;
216
+ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: readonly BarMark[], hovered: readonly BarMark[], decimate?: DecimateOption, binFills?: readonly (string | undefined)[], banding?: BandLadder, spans?: readonly SpanSelection[]): LayerDrawStats;
202
217
  /**
203
218
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
204
219
  * **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
@@ -335,6 +350,45 @@ export interface StackStyle {
335
350
  * resting and live, only the floor.
336
351
  */
337
352
  readonly emphasisOpacity?: number;
353
+ /**
354
+ * Fill for a segment **not** in a non-empty selection set — the themed
355
+ * de-emphasis ([PND-MULTISEL], RFC A2.3). Unset ⇒ nothing dims, so this is
356
+ * opt-in and back-compatible. Unlike {@link highlight}, this **does** apply
357
+ * over {@link binFills}: dimming is about recession, not identity, so a
358
+ * zone-coloured bar can recede without its colour changing meaning — the
359
+ * dimmed value simply replaces it while it is out of the set.
360
+ */
361
+ readonly dimmed?: string;
362
+ /**
363
+ * Per-group {@link dimmed}, parallel to {@link fills} — the resolved
364
+ * `theme.bar.default.groupsDimmed` ramp. Set only for a **group-ramped**
365
+ * stack; `undefined` falls back to the flat {@link dimmed}.
366
+ *
367
+ * A stack dimmed to one colour stops being a stack — the segment boundaries
368
+ * vanish and the receded bins read as solid blocks, which is exactly the
369
+ * structure a selection wants to keep visible for comparison.
370
+ */
371
+ readonly dimmedFills?: readonly string[];
372
+ /**
373
+ * Per-group {@link hover}, parallel to {@link fills} — the resolved
374
+ * `theme.bar.default.groupsHover` ramp. Set only for a **group-ramped**
375
+ * stack; `undefined` falls back to the flat {@link hover}.
376
+ *
377
+ * A single hover colour repaints the pointed-at segment in a hue belonging
378
+ * to another group, so hovering erases the ramp exactly where the reader is
379
+ * looking — and block-scoped hover erases the whole bin.
380
+ */
381
+ readonly hoverFills?: readonly string[];
382
+ /**
383
+ * Whether {@link fills} carry **group identity** (a resolved group ramp)
384
+ * rather than being one repeated role colour.
385
+ *
386
+ * When they do, a selected segment keeps its own fill and the outline plus
387
+ * the receded neighbours are the cue — the same exclusion {@link binFills}
388
+ * already gets, and for the same reason: replacing a meaning-carrying colour
389
+ * with the flat `highlight` destroys the thing the selection is *about*.
390
+ */
391
+ readonly groupColored?: boolean;
338
392
  }
339
393
  /** The narrowed selection / hover identity a stacked segment matches against:
340
394
  * the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
@@ -418,7 +472,7 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
418
472
  *
419
473
  * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
420
474
  */
421
- export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: StackMark | null, hover: StackMark | null, banding?: BandLadder): void;
475
+ export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: readonly StackMark[], hover: readonly StackMark[], banding?: BandLadder, spans?: readonly SpanSelection[]): void;
422
476
  /**
423
477
  * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
424
478
  * segment whose rect contains the point, or `null`. The geometry is