@pond-ts/charts 0.62.0 → 0.64.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/dist/chip.js CHANGED
@@ -96,17 +96,74 @@ export function pointerStyle(side, color) {
96
96
  };
97
97
  }
98
98
  /**
99
- * CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
100
- * edge at the plot boundary (`plotWidth`) and let it overflow outward across the
101
- * reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
102
- * sibling axis column (rendered later in the row) so it covers the tick behind
103
- * it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
104
- * cursor's per-series value pills, so both sit identically on the axis.
99
+ * CSS placing a value pill **on an axis gutter** at `side`: anchor its inner
100
+ * edge at that axis's inner edge — the plot boundary (`plotWidth`) plus the
101
+ * axis's own `offset` out into the gutter and let it overflow outward (the
102
+ * plot div doesn't clip), lifted with `zIndex` above the sibling axis columns
103
+ * (rendered later in the row) so it covers the tick behind it. The one placement
104
+ * every on-axis pill goes through the crosshair cursor's value pill, a
105
+ * `<Baseline indicator>`, and {@link YAxisIndicator} — so they cannot drift
106
+ * apart.
107
+ *
108
+ * `offset` is `0` for the innermost axis on a side (the single-axis case, and the
109
+ * behaviour before it existed) and the reserved widths of the axes nearer the
110
+ * plot for one further out — {@link RowFrame.axisOffsets}. Passing it is what
111
+ * puts the pill on the axis whose scale produced the number, rather than on
112
+ * whichever axis happens to sit against the plot. **`YAxisIndicator` does not
113
+ * pass it** and so still lands innermost: it takes an explicit `side` beside its
114
+ * `axis`, and what an offset should mean when those two disagree is unsettled
115
+ * (see `[PND-XHAIRAXIS]` in the charts plan).
116
+ *
117
+ * A pill is deliberately unclipped, so a long formatted value can overflow past
118
+ * the gutter it sits in — further out for an outer-axis pill, which has only its
119
+ * own column left before the container's edge. Sized-to-content and unclipped
120
+ * beats truncating a number, but a very wide readout on a narrow outer axis will
121
+ * spill outside the chart box.
105
122
  */
106
- export function axisPillX(side, plotWidth) {
123
+ export function axisPillX(side, plotWidth, offset = 0) {
124
+ const inner = plotWidth + offset;
107
125
  return side === 'right'
108
- ? { left: `${plotWidth}px`, zIndex: 3 }
109
- : { right: `${plotWidth}px`, zIndex: 3 };
126
+ ? { left: `${inner}px`, zIndex: 3 }
127
+ : { right: `${inner}px`, zIndex: 3 };
128
+ }
129
+ /**
130
+ * The **connector** for a pill placed further out than the innermost axis: a 1px
131
+ * bridge from the plot's `side` edge across `offset` px of gutter to the pill's
132
+ * inner edge, so the in-plot line and its pill read as one object rather than as
133
+ * a value floating in a gutter two columns away. The y-side twin of the
134
+ * crosshair's x-axis time connector, which exists for exactly this reason.
135
+ *
136
+ * The caller positions it vertically (`top` + a `translateY(-50%)`), at the
137
+ * **pill's** centre rather than the raw value's — the two agree except where the
138
+ * pill is clamped inside the row, and a connector attached to the pill is what
139
+ * sells them as one object.
140
+ *
141
+ * In the pill's own colour and above the axis column (`zIndex`, as the pill is),
142
+ * but at **half opacity** — unlike the x-axis time connector, which is solid.
143
+ * The difference is what each one crosses: the time connector runs over an empty
144
+ * strip, while this one runs over *another axis's tick labels* (measured: a
145
+ * connector at a value whose neighbouring axis has a tick at the same height
146
+ * overlaps that label's glyphs). Half opacity keeps the labels legible and reads
147
+ * the bridge as subordinate chrome — the weight the flag cursor's staffs already
148
+ * use for "this line only connects two things I have drawn".
149
+ *
150
+ * Only drawn when `offset > 0`: at offset `0` the pill already touches the plot
151
+ * edge where the line ends, so a connector would be zero-length ink over a tick
152
+ * label for nothing.
153
+ */
154
+ export function axisPillConnector(side, plotWidth, offset, color) {
155
+ return {
156
+ position: 'absolute',
157
+ ...(side === 'right'
158
+ ? { left: `${plotWidth}px` }
159
+ : { right: `${plotWidth}px` }),
160
+ width: `${offset}px`,
161
+ height: '1px',
162
+ background: color,
163
+ opacity: 0.5,
164
+ pointerEvents: 'none',
165
+ zIndex: 3,
166
+ };
110
167
  }
111
168
  /** Gap (px) between a flag chip and its pole — the cursor staff or an annotation's
112
169
  * line — so the chip floats just beside the pole rather than sitting on it. */
package/dist/context.d.ts CHANGED
@@ -40,7 +40,27 @@ export interface LabelPlacement {
40
40
  export type ChartXScale = ScaleTime<number, number> | ScaleLinear<number, number> | ScaleLogarithmic<number, number> | ScaleSymLog<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
41
41
  export interface ContainerFrame {
42
42
  readonly timeRange: readonly [number, number];
43
+ /**
44
+ * The **declared** view — the container's `range` prop, normalized — as
45
+ * against {@link timeRange}, which is where gestures have moved it. The
46
+ * x-axis strip's double-click reset returns here.
47
+ *
48
+ * On a **controlled** chart (one passing `onTimeRangeChange`) the two are the
49
+ * same object of truth by construction: the consumer owns the view, so `range`
50
+ * *is* the panned view and there is no declared home to go back to. The reset
51
+ * is then a no-op, and a consumer who wants one holds their own home range and
52
+ * wires it through `onMouseEvent` — which is exactly the shape of that job.
53
+ */
54
+ readonly seedRange: readonly [number, number];
43
55
  readonly width: number;
56
+ /**
57
+ * Whether the container is managing vertical layout ([PND-HEIGHT]) — it was
58
+ * given a `height` (number or `'auto'`), renders as a flex column, and flex
59
+ * rows have real space to divide. `false` is the classic mode: rows declare
60
+ * pixel heights and the container's height is their sum. A `<ChartRow
61
+ * flex>` reads this to warn when mounted somewhere it can never resolve.
62
+ */
63
+ readonly managesHeight: boolean;
44
64
  readonly theme: ChartTheme;
45
65
  /** Plot width in px after the gutters — shared by every row. */
46
66
  readonly plotWidth: number;
@@ -427,6 +447,16 @@ export interface ContainerFrame {
427
447
  * the **aspect ratio** fixed. The x half stays in domain space, where
428
448
  * `bounds`, `minDuration` and the trading-calendar zoom maths live.
429
449
  */
450
+ /**
451
+ * Whether the **axis strips** take gestures, from `<ChartContainer axisPanZoom>`
452
+ * — resolved per dimension, and independent of the plot's own
453
+ * {@link zoomX}/{@link zoomY}. `x` gives the `<XAxis>` strip the canvas
454
+ * gesture (drag pans, wheel zooms); `y` makes each `<YAxis>` gutter scale its
455
+ * own axis. Both default to `false`, so a chart that doesn't ask keeps inert
456
+ * axes however interactive its plot is.
457
+ */
458
+ readonly axisPanZoomX: boolean;
459
+ readonly axisPanZoomY: boolean;
430
460
  /** Which axes the gestures own; pan follows zoom's degrees of freedom. */
431
461
  readonly zoomX: boolean;
432
462
  readonly zoomY: boolean;
@@ -792,7 +822,7 @@ export interface RowLayer {
792
822
  * chart. Layers whose hit target is already the drawn mark (stacks,
793
823
  * scatter, boxes, heat cells) ignore `mode`.
794
824
  */
795
- hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select'): SelectInfo | null;
825
+ hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select', baseYScale?: (value: number) => number): SelectInfo | null;
796
826
  /**
797
827
  * Begin a **sweep session** over this layer's marks — `<MultiSelector>`'s
798
828
  * range query (interaction RFC A7.6/A7.7), the range analog of
@@ -866,8 +896,18 @@ export interface RowLayer {
866
896
  * {@link LayerDrawStats} (source/drawn counts + whether decimation engaged) so
867
897
  * the container can surface them via {@link ContainerProps.onDrawStats}; a
868
898
  * layer that returns `void` still contributes its measured `drawMs`.
869
- */
870
- draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number): LayerDrawStats | void;
899
+ *
900
+ * `baseYScale` is this axis's *declared* scale resolved from `min`/`max`/
901
+ * auto-fit, before either the container's or this axis's own pan/zoom pixel
902
+ * transform narrows it (the same scale `row.baseYScales` exposes). A layer
903
+ * whose geometry depends on where the domain sits relative to a fixed value
904
+ * (a bar or stack resting on zero — see `resolveBarBaseline`) reads *that*
905
+ * domain for the decision, not `yScale`'s: the live scale is a *viewport*
906
+ * onto the axis, and a pan sliding that viewport away from zero must not
907
+ * relocate the bars' own baseline out from under them. Only bars currently
908
+ * use it; every other layer type ignores the extra argument.
909
+ */
910
+ draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number, baseYScale?: (value: number) => number): LayerDrawStats | void;
871
911
  }
872
912
  /**
873
913
  * A layer's per-drag **sweep session** ({@link RowLayer.beginSweep} — RFC
@@ -1356,15 +1396,24 @@ export type CursorSnapX = 'none' | 'sample' | 'sequence';
1356
1396
  /**
1357
1397
  * One resolved per-series measurement at the cursor — **finished numbers, not
1358
1398
  * raw materials** (interaction RFC A2.3): the sample's plot pixels, the axis it
1359
- * scales against (id + side, so a pill can hug the right gutter), and its value
1360
- * already formatted by that axis's formatter. A cursor slot draws these; it
1361
- * never sees a scale, a format map, or an axis-side map.
1399
+ * scales against (id + side + gutter offset + colour, so a pill can sit *on
1400
+ * that* axis in *its* ink), and its value already formatted by that axis's
1401
+ * formatter. A cursor slot draws these; it never sees a scale, a format map, or
1402
+ * an axis-side map.
1362
1403
  */
1363
1404
  export interface ResolvedCursorSample {
1364
1405
  readonly px: number;
1365
1406
  readonly py: number;
1366
1407
  readonly axisId: string;
1367
1408
  readonly side: 'left' | 'right';
1409
+ /** Distance in px from the plot's `side` edge to this axis's inner edge —
1410
+ * `0` for the innermost axis, the reserved widths of the axes between it and
1411
+ * the plot otherwise ({@link RowFrame.axisOffsets}). What lets an axis-edge
1412
+ * pill land on the axis that measured the value rather than the innermost. */
1413
+ readonly axisOffset: number;
1414
+ /** This axis's own `<YAxis color>`, or `undefined` for the theme's axis ink
1415
+ * ({@link RowFrame.axisColors}) — so an axis-edge pill matches its axis. */
1416
+ readonly axisColor: string | undefined;
1368
1417
  readonly formatted: string;
1369
1418
  readonly color: string;
1370
1419
  readonly label: string;
@@ -1406,7 +1455,8 @@ export interface ResolvedCursorFrame {
1406
1455
  readonly flags: readonly ResolvedCursorFlag[];
1407
1456
  /**
1408
1457
  * The **raw pointer**'s y resolved against the row's default axis — position,
1409
- * formatted value, and axis side or `null` when this row isn't hovered.
1458
+ * formatted value, and that axis's placement (side + gutter offset + colour,
1459
+ * as on {@link ResolvedCursorSample}) — or `null` when this row isn't hovered.
1410
1460
  * The free (non-snapping) crosshair reads this; it is resolved here because a
1411
1461
  * slot has no `yScale.invert` to do it itself.
1412
1462
  */
@@ -1414,6 +1464,8 @@ export interface ResolvedCursorFrame {
1414
1464
  readonly py: number;
1415
1465
  readonly formatted: string;
1416
1466
  readonly side: 'left' | 'right';
1467
+ readonly axisOffset: number;
1468
+ readonly axisColor: string | undefined;
1417
1469
  } | null;
1418
1470
  /** The range cursor's **band** under the pointer (bucket-snapped via the
1419
1471
  * declared sequence, else the drag span), as clamped plot pixels; `null`
@@ -1661,6 +1713,11 @@ export interface AxisSpec {
1661
1713
  * target; `undefined` derives the count from the row height (see
1662
1714
  * {@link resolveYTickCount}). Ignored when {@link tickValues} is set. */
1663
1715
  readonly tickCount: number | undefined;
1716
+ /** This axis's own ink (`<YAxis color>`), or `undefined` for the theme's.
1717
+ * Registered — not merely rendered — because the axis-edge chrome the row
1718
+ * and the cursor draw (the crosshair's value pill) has to match the axis it
1719
+ * sits on, and only the registry knows every axis. */
1720
+ readonly color: string | undefined;
1664
1721
  /**
1665
1722
  * Declaration position among the row's children, injected by `ChartRow`. The
1666
1723
  * row sorts axes by this, so the **first declared** axis is the default
@@ -1689,6 +1746,52 @@ export interface RowFrame {
1689
1746
  */
1690
1747
  readonly topInset: number;
1691
1748
  readonly yScales: ReadonlyMap<string, YScale>;
1749
+ /**
1750
+ * Each axis's scale **before** any gesture transform — the domain it resolved
1751
+ * to from `min`/`max`/`pad`/auto-fit, which is the space a controlled
1752
+ * consumer's bounds live in.
1753
+ *
1754
+ * {@link yScales} carries the *visible* scales: the uniform
1755
+ * {@link ContainerFrame.yTransform} and the axis's own
1756
+ * {@link axisTransforms} entry have already narrowed them. A gutter gesture
1757
+ * that reported values read off those and had them fed back as `min`/`max`
1758
+ * would have the transforms applied a second time — the visible domain then
1759
+ * diverges from every value the consumer was told.
1760
+ */
1761
+ readonly baseYScales: ReadonlyMap<string, YScale>;
1762
+ /**
1763
+ * **Per-axis** pixel zoom, keyed by axis id — what a drag on that axis's
1764
+ * gutter produces, layered *under* the container's uniform
1765
+ * {@link ContainerFrame.yTransform}. Identity (`{ k: 1, ty: 0 }`) for any axis
1766
+ * nobody has grabbed, which is every axis until one is.
1767
+ *
1768
+ * The uniform transform exists precisely so a *plot* gesture never has to
1769
+ * answer "which of this row's axes does a vertical drag own?" — see
1770
+ * {@link ContainerFrame.yTransform}. Grabbing one gutter answers it by
1771
+ * construction, and this is where that answer lives. Both are applied the same
1772
+ * way (narrowing the domain to the window the transform makes visible), the
1773
+ * uniform one first, so an axis carrying both reads as an ordinary axis over
1774
+ * the doubly-narrowed window and nothing downstream knows either exists.
1775
+ *
1776
+ * Unlike the uniform transform this is **not** floored at `k ≥ 1`: that floor
1777
+ * stops a plot gesture zooming every axis out past its natural fit into blank
1778
+ * canvas, whereas squashing one axis you deliberately grabbed is the point of
1779
+ * the gesture (and costs nothing — a `k < 1` widens the domain rather than
1780
+ * exposing empty plot).
1781
+ */
1782
+ readonly axisTransforms: ReadonlyMap<string, {
1783
+ readonly k: number;
1784
+ readonly ty: number;
1785
+ }>;
1786
+ /**
1787
+ * Set one axis's {@link axisTransforms} entry — the y counterpart of
1788
+ * {@link ContainerFrame.applyRange}. Passing identity clears it (the
1789
+ * double-click reset).
1790
+ */
1791
+ applyAxisTransform(id: string, next: {
1792
+ k: number;
1793
+ ty: number;
1794
+ }): void;
1692
1795
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
1693
1796
  * against its scale) — used by both the tick labels and the cursor readout, so
1694
1797
  * a value reads identically in both. */
@@ -1705,6 +1808,30 @@ export interface RowFrame {
1705
1808
  /** The side each axis sits on, keyed by id — so an axis-edge overlay (the
1706
1809
  * crosshair value pills) hugs the correct gutter. */
1707
1810
  readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
1811
+ /**
1812
+ * How far out in its gutter each axis sits, keyed by id: the px distance from
1813
+ * the plot's edge to that axis's **inner** edge — `0` for the innermost axis
1814
+ * on a side, the sum of the reserved widths of the axes between it and the
1815
+ * plot for the ones beyond it (the slots of {@link ContainerFrame.leftSlots} /
1816
+ * `rightSlots` it sits behind).
1817
+ *
1818
+ * The companion to {@link axisSides}: side alone puts an axis-edge pill on the
1819
+ * *innermost* axis of that side, which is the wrong axis whenever a side
1820
+ * carries more than one — the pill then reads as a value on a scale that never
1821
+ * measured it. Together they place it on the axis that did.
1822
+ *
1823
+ * Keyed by id, so a **mirrored** id (one scale registered on both sides, or a
1824
+ * duplicate) resolves to the **last declared** instance — the same winner
1825
+ * {@link axisSides} picks, deliberately, so that side and offset always
1826
+ * describe one axis. Picking them by different rules would pair one
1827
+ * instance's gutter with another's column, which is exactly the mis-placement
1828
+ * this map exists to remove.
1829
+ */
1830
+ readonly axisOffsets: ReadonlyMap<string, number>;
1831
+ /** Each axis's own ink (`<YAxis color>`), keyed by id; an axis that sets none
1832
+ * is absent. The other half of matching an axis-edge pill to its axis (see
1833
+ * {@link axisOffsets}) — the pill takes this colour, else the theme's. */
1834
+ readonly axisColors: ReadonlyMap<string, string>;
1708
1835
  /** This row's cursor-mode override, or `undefined` to inherit the container's
1709
1836
  * default ({@link ContainerFrame.cursor}). */
1710
1837
  readonly cursor: CursorMode | undefined;
package/dist/cursors.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useContext, useEffect, useMemo } from 'react';
3
3
  import { ContainerContext, RowContext, } from './context.js';
4
4
  import { renderBrushBand } from './brush.js';
5
- import { flagChipStyle, flagChipX, axisPillStyle, axisPillX } from './chip.js';
5
+ import { flagChipStyle, flagChipX, axisPillStyle, axisPillX, axisPillConnector, } from './chip.js';
6
6
  import { useSlotKey } from './use-slot-key.js';
7
7
  import { isDev } from './dev.js';
8
8
  /**
@@ -188,6 +188,12 @@ function buildFlagCursor(o) {
188
188
  * sample nearest the pointer y in the hovered row — or the first sample when
189
189
  * nothing is hovered (a pinned tracker shows a reticle in every row); free
190
190
  * mode reads the container-resolved raw-pointer measurement.
191
+ *
192
+ * It carries the picked sample's **axis placement** (side + gutter offset +
193
+ * axis ink) as well as its value, because the reticle reads one series and its
194
+ * pill has to land on *that series' axis*: with two axes on a side, the value
195
+ * belongs to only one of the two scales, and a pill on the other one is a
196
+ * number pinned to a ruler that never measured it.
191
197
  */
192
198
  function crosshairPick(f, snap) {
193
199
  if (inBoundsX(f) === null)
@@ -204,7 +210,13 @@ function crosshairPick(f, snap) {
204
210
  ? f.samples[0]
205
211
  : null;
206
212
  return pick
207
- ? { py: pick.py, formatted: pick.formatted, side: pick.side }
213
+ ? {
214
+ py: pick.py,
215
+ formatted: pick.formatted,
216
+ side: pick.side,
217
+ axisOffset: pick.axisOffset,
218
+ axisColor: pick.axisColor,
219
+ }
208
220
  : null;
209
221
  }
210
222
  /** `cursor="crosshair"` as a spec: the dashed reticle (renderPlot), the axis
@@ -223,17 +235,30 @@ function buildCrosshairCursor(o) {
223
235
  const reticle = crosshairPick(f, o.snap);
224
236
  return (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(x), y1: 0, x2: Math.round(x), y2: f.rowHeight, stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: f.plotWidth, y2: Math.round(reticle.py), stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: x, cy: reticle.py, r: 3, fill: ink, stroke: background, strokeWidth: background ? 1 : 0 })] }))] }));
225
237
  },
238
+ // The value pill goes **on the reticle's own axis**: its side, its offset
239
+ // out into that gutter (so a second axis on a side gets its own pill
240
+ // position rather than the innermost axis's), and its `<YAxis color>` when
241
+ // it has one — with several axes the pill's ink is what says which scale
242
+ // the number is on. An uncoloured axis keeps the cursor's own ink.
226
243
  renderYGutter: (f) => {
227
244
  const reticle = crosshairPick(f, o.snap);
228
245
  if (reticle === null)
229
246
  return null;
230
247
  const lh = chipLineHeight(f.theme);
231
- return (_jsx("div", { style: {
232
- ...axisPillStyle(f.theme, cursorInk(f.theme)),
233
- top: `${Math.max(lh / 2, Math.min(f.rowHeight - lh / 2, reticle.py))}px`,
234
- transform: 'translateY(-50%)',
235
- ...axisPillX(reticle.side, f.plotWidth),
236
- }, children: reticle.formatted }));
248
+ const ink = reticle.axisColor ?? cursorInk(f.theme);
249
+ // Clamped inside the row like the y-tick labels; the connector shares it
250
+ // so the bridge always meets the pill it belongs to.
251
+ const top = Math.max(lh / 2, Math.min(f.rowHeight - lh / 2, reticle.py));
252
+ return (_jsxs(_Fragment, { children: [reticle.axisOffset > 0 && (_jsx("div", { style: {
253
+ ...axisPillConnector(reticle.side, f.plotWidth, reticle.axisOffset, ink),
254
+ top: `${top}px`,
255
+ transform: 'translateY(-50%)',
256
+ } })), _jsx("div", { style: {
257
+ ...axisPillStyle(f.theme, ink),
258
+ top: `${top}px`,
259
+ transform: 'translateY(-50%)',
260
+ ...axisPillX(reticle.side, f.plotWidth, reticle.axisOffset),
261
+ }, children: reticle.formatted })] }));
237
262
  },
238
263
  ...(o.showTime
239
264
  ? {
package/dist/domain.d.ts CHANGED
@@ -29,6 +29,24 @@ import type { YScaleKind } from './context.js';
29
29
  * the *decades* spanned rather than of the difference.
30
30
  */
31
31
  export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
32
+ /**
33
+ * The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
34
+ * back in as `min`/`max`, re-pad to exactly the domain passed in.
35
+ *
36
+ * **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
37
+ * so a scale's live domain is the **padded** one. Anything that reads a domain
38
+ * off the scale and hands it back as bounds (a y-gutter gesture reporting through
39
+ * `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
40
+ * inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
41
+ * walks outward a notch at a time under a gesture that should be zooming *in*.
42
+ *
43
+ * Both directions preserve the domain's centre, so this is the same arithmetic
44
+ * run backwards: in value space for linear / symlog, in log space for `'log'`
45
+ * (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
46
+ * the identity, and a log domain that is not strictly positive is returned
47
+ * untouched rather than taken through `log10`.
48
+ */
49
+ export declare function unpadDomain(domain: readonly [number, number], pad?: number, scale?: YScaleKind): [number, number];
32
50
  /**
33
51
  * Does resolving this axis's domain need its layers' extents walked?
34
52
  * `yExtent()` is O(points) per layer, so the caller only pays it when a side
package/dist/domain.js CHANGED
@@ -39,6 +39,39 @@ export function resolveYDomain(min, max, extents, pad = 0, scale = 'linear') {
39
39
  }
40
40
  return result;
41
41
  }
42
+ /**
43
+ * The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
44
+ * back in as `min`/`max`, re-pad to exactly the domain passed in.
45
+ *
46
+ * **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
47
+ * so a scale's live domain is the **padded** one. Anything that reads a domain
48
+ * off the scale and hands it back as bounds (a y-gutter gesture reporting through
49
+ * `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
50
+ * inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
51
+ * walks outward a notch at a time under a gesture that should be zooming *in*.
52
+ *
53
+ * Both directions preserve the domain's centre, so this is the same arithmetic
54
+ * run backwards: in value space for linear / symlog, in log space for `'log'`
55
+ * (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
56
+ * the identity, and a log domain that is not strictly positive is returned
57
+ * untouched rather than taken through `log10`.
58
+ */
59
+ export function unpadDomain(domain, pad = 0, scale = 'linear') {
60
+ const [lo, hi] = domain;
61
+ if (!pad)
62
+ return [lo, hi];
63
+ const shrink = 1 + 2 * pad;
64
+ if (scale === 'log') {
65
+ if (!(lo > 0) || !(hi > lo))
66
+ return [lo, hi];
67
+ const midLog = (Math.log10(lo) + Math.log10(hi)) / 2;
68
+ const halfLog = (Math.log10(hi) - Math.log10(lo)) / (2 * shrink);
69
+ return [10 ** (midLog - halfLog), 10 ** (midLog + halfLog)];
70
+ }
71
+ const mid = (lo + hi) / 2;
72
+ const half = (hi - lo) / (2 * shrink);
73
+ return [mid - half, mid + half];
74
+ }
42
75
  /** Smallest positive value a log domain will fall back to when the data offers
43
76
  * nothing positive at all. Arbitrary but finite — a log scale has no natural
44
77
  * zero to anchor on, and `[0, 1]` (the linear empty-data domain) has no
package/dist/select.d.ts CHANGED
@@ -17,5 +17,5 @@ import type { LayerEntry, SelectInfo } from './context.js';
17
17
  * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
18
18
  * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
19
19
  */
20
- export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select'): SelectInfo | null;
20
+ export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select', baseYScaleFor?: (axisId: string | undefined) => ((value: number) => number) | undefined): SelectInfo | null;
21
21
  //# sourceMappingURL=select.d.ts.map
package/dist/select.js CHANGED
@@ -16,13 +16,18 @@
16
16
  * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
17
17
  * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
18
18
  */
19
- export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover') {
19
+ export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover',
20
+ // The axis's declared (pre-pan/zoom) scale — see `RowLayer.hitTest`. Kept
21
+ // alongside `yScaleFor` rather than folded into it: most callers have no
22
+ // base-scale lookup at hand (a bar-free row, a test harness), and every
23
+ // layer but bars ignores the extra argument regardless.
24
+ baseYScaleFor) {
20
25
  for (let i = entries.length - 1; i >= 0; i -= 1) {
21
26
  const entry = entries[i];
22
27
  const yScale = yScaleFor(entry.axisId);
23
28
  if (yScale === undefined)
24
29
  continue;
25
- const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode);
30
+ const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode, baseYScaleFor?.(entry.axisId));
26
31
  if (hit)
27
32
  return hit;
28
33
  }
@@ -170,7 +170,7 @@ export interface TradingTimeScale {
170
170
  * The **date bands** — the segmented second row of the stacked style. One
171
171
  * entry per next-coarser calendar period touching the domain (day bands
172
172
  * under intraday ticks, month bands under day ticks, year bands under
173
- * month/quarter ticks), each `{ start, label, shaded }`: the period's start
173
+ * month/quarter ticks), each `{ start, label, showLabel, shaded }`: the period's start
174
174
  * instant (the first band's `start` may precede the domain — its label pins
175
175
  * at the left edge), the left-aligned label, and a stable zebra `shaded`
176
176
  * flag keyed to the band's calendar identity (pan/zoom-invariant). The
@@ -181,6 +181,16 @@ export interface TradingTimeScale {
181
181
  bands(count?: number): Array<{
182
182
  start: number;
183
183
  label: string;
184
+ /**
185
+ * Whether the renderer should DRAW `label`. `false` when this band's start
186
+ * was clamped onto a live instant a tick already labels — the text would
187
+ * collide, but the band itself must still be emitted, because each entry
188
+ * is a segment boundary (the next entry's `start` closes it) and carries
189
+ * that segment's `shaded` parity. Dropping the entry instead deletes the
190
+ * period from the row, letting the previous band stretch across it under
191
+ * the wrong identity and shading.
192
+ */
193
+ showLabel: boolean;
184
194
  shaded: boolean;
185
195
  }>;
186
196
  domain(): [number, number];
@@ -192,22 +192,68 @@ export function scaleTradingTime(provider) {
192
192
  scale.bands = (count = 10) => {
193
193
  if (!hasCalendar())
194
194
  return [];
195
- const bg = bandGrainFor(resolved(count).granularity);
195
+ const { ticks, granularity } = resolved(count);
196
+ const bg = bandGrainFor(granularity);
196
197
  if (bg === undefined)
197
198
  return []; // year grain — nothing coarser to band
198
199
  const fmt = base.tickFormat(count, bandFormatFor(bg));
199
- const out = [];
200
+ const tickSet = new Set(ticks);
201
+ // A band's raw calendar start is a date, not necessarily a LIVE instant —
202
+ // a month or day beginning on a collapsed weekend/holiday clamps onto the
203
+ // next live moment. `bandNext`/`clampUp` are both monotonic, so every raw
204
+ // start that clamps to the SAME live instant is a contiguous run (a
205
+ // holiday week's Sat/Sun/Mon all land on the same Tuesday) — collected
206
+ // here as `{ s, live }` candidates before any label is built, so a run
207
+ // can be resolved as one group rather than emitting (and then trying to
208
+ // retract) a label per member.
209
+ const candidates = [];
210
+ let s = bandStartOf(domain[0], bg);
200
211
  // First band starts at (or before) the domain start — the partial left
201
212
  // band whose label the renderer pins at x=0; step to each next period
202
213
  // start still inside the domain. Bounded loop as a runaway guard.
203
- let s = bandStartOf(domain[0], bg);
204
214
  for (let i = 0; i < 100_000 && s < domain[1]; i++) {
215
+ candidates.push({ s, live: provider.clampUp(s) });
216
+ s = bandNext(s, bg);
217
+ }
218
+ const out = [];
219
+ for (let i = 0; i < candidates.length;) {
220
+ const live = candidates[i].live;
221
+ let j = i;
222
+ while (j < candidates.length && candidates[j].live === live)
223
+ j += 1;
224
+ // One label per run: prefer the member that is ITSELF live (so a
225
+ // Tuesday reopening after a collapsed weekend reads "Tue", not the
226
+ // Saturday whose raw start merely happened to clamp onto it) — a run
227
+ // with no live member (the whole period is inside the gap) falls back
228
+ // to the first, since there is no better candidate. (Every member of
229
+ // the group shares `live` by construction — the test is `c.s ===
230
+ // c.live`, i.e. THIS candidate needed no clamping at all.)
231
+ const rep = candidates.slice(i, j).find((c) => c.s === c.live) ?? candidates[i];
232
+ // The band's raw start routinely coincides with a tick that is ALSO
233
+ // legitimately live (a month band and a day tick both anchored on the
234
+ // 1st is the ordinary stacked-axis look) — that is not the bug. Only a
235
+ // representative that itself needed clamping, and whose landing spot a
236
+ // tick already labels, duplicates one; skip it rather than draw a
237
+ // second, colliding label over the tick's own.
238
+ const collides = rep.s !== live && tickSet.has(live);
239
+ // Emit the band either way. A collision suppresses only its TEXT — the
240
+ // entry is also this period's segment boundary (the next entry's
241
+ // `start` closes it) and owns its zebra parity, so skipping it deletes
242
+ // the period from the row rather than de-duplicating a label: the
243
+ // preceding band then runs on to the next surviving start under its own
244
+ // shading and name. (Aug 1 2026 is a Saturday, so the August band
245
+ // clamps onto Mon Aug 3, which is already a day tick — dropping it left
246
+ // July painted across the whole of August.)
205
247
  out.push({
206
- start: s,
207
- label: fmt(new Date(s)),
208
- shaded: bandShaded(s, bg),
248
+ start: live,
249
+ // Formatted from the representative's own RAW start, not the
250
+ // clamped one — a genuinely-live rep reads its own date; a
251
+ // gap-only run's rep reads whichever raw date it fell back to.
252
+ label: fmt(new Date(rep.s)),
253
+ showLabel: !collides,
254
+ shaded: bandShaded(rep.s, bg),
209
255
  });
210
- s = bandNext(s, bg);
256
+ i = j;
211
257
  }
212
258
  return out;
213
259
  };