@pond-ts/charts 0.59.0 → 0.61.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/Layers.js CHANGED
@@ -9,7 +9,8 @@ import { resolveSelection } from './select.js';
9
9
  import { isDev } from './dev.js';
10
10
  import { useIndexedChildren } from './child-index.js';
11
11
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
12
- import { yTickValues } from './yticks.js';
12
+ // Aliased: `tickValues` is already a local map of per-axis explicit ticks.
13
+ import { tickValues as axisTickValues } from './yticks.js';
13
14
  import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
14
15
  /** Fallback **y**-gridline tick count, used only before the row publishes its
15
16
  * resolved `tickCounts` (pre-registration). Normally the gridlines read the
@@ -240,7 +241,7 @@ export function Layers({ children }) {
240
241
  // a gridline sits under every `<YAxis>` label and no more.
241
242
  const yCount = tickCounts.get(defaultAxisId) ?? GRID_TICKS;
242
243
  const yTicks = gridY && !(yIsCategory && explicitY === undefined)
243
- ? (explicitY ?? yTickValues(gridY, yCount)).map((t) => gridY(t))
244
+ ? (explicitY ?? axisTickValues(gridY, yCount)).map((t) => gridY(t))
244
245
  : [];
245
246
  // On a calendar axis the verticals are the FULL grain populations —
246
247
  // every day in the month, every month in the year, every aligned
@@ -1078,7 +1079,13 @@ export function Layers({ children }) {
1078
1079
  else {
1079
1080
  const span = drag.startRange[1] - drag.startRange[0];
1080
1081
  const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
1081
- c.applyRange(panRange(drag.startRange, dt));
1082
+ // A log x pans by ratio, not by offset — see `ViewportOptions`.
1083
+ // `snap` follows: whole-millisecond snapping is a time-axis rule and
1084
+ // wrong for any value axis, log or not.
1085
+ c.applyRange(panRange(drag.startRange, dt, {
1086
+ log: c.xIsLog,
1087
+ snap: c.xKind === 'time',
1088
+ }));
1082
1089
  }
1083
1090
  return; // tracker suppressed during a pan
1084
1091
  }
@@ -1467,7 +1474,10 @@ export function Layers({ children }) {
1467
1474
  // the minimum visible *trading* time (ms of open-market time)
1468
1475
  // rather than wall-clock ms — the sensible meaning for this axis.
1469
1476
  zoomRangeTrading(c.timeRange, pivot, f, c.discontinuities, c.minDuration)
1470
- : zoomRange(c.timeRange, pivot, f, c.minDuration);
1477
+ : zoomRange(c.timeRange, pivot, f, c.minDuration, {
1478
+ log: c.xIsLog,
1479
+ snap: c.xKind === 'time',
1480
+ });
1471
1481
  // ── The aspect lock has to be NEGOTIATED, not asserted ────────────────
1472
1482
  // Both axes zooming by "the same factor" only holds the ratio while both
1473
1483
  // can actually take that factor. Each has its own limit — y cannot zoom
package/dist/XAxis.d.ts CHANGED
@@ -1,5 +1,30 @@
1
1
  import { type AxisTransform } from './derivedTicks.js';
2
2
  import { type AxisFormat } from './format.js';
3
+ import { type AxisMouseHandler } from './axis-events.js';
4
+ /** One placed tick — its plot-pixel x, the text to draw, and (stacked band
5
+ * style) whether it sits on a band turn and renders emphasized. */
6
+ interface PlacedTick {
7
+ readonly x: number;
8
+ readonly label: string;
9
+ /** This tick opens a coarser calendar period (a day/month/year turn), so it
10
+ * renders emphasized (bold): a band turn in stacked, an inline promotion in
11
+ * flat — the same boundaries in both styles. */
12
+ readonly bold?: boolean;
13
+ }
14
+ /**
15
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible
16
+ * — triggered by **measured geometry**, not category count ([PND-CATFIT]):
17
+ * keep every `stride`-th label, and middle-ellipsize a kept one that still
18
+ * overruns its room. `stride` is the fewest slots the widest label needs once
19
+ * it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
20
+ * every full label and long ones trade thinning against truncation instead of
21
+ * overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
22
+ * `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
23
+ * a later option.
24
+ *
25
+ * Exported for tests only — not re-exported from the package index.
26
+ */
27
+ export declare function thinCategoryLabels(ticks: readonly PlacedTick[], slot: number, plotWidth: number, fontSize: number, fontFamily: string): PlacedTick[];
3
28
  export interface XAxisProps {
4
29
  /**
5
30
  * Tick / cursor value formatting — a d3 format/time specifier string or a
@@ -59,8 +84,10 @@ export interface XAxisProps {
59
84
  * - `'auto'` — centred, but the first label left-anchors and the last
60
85
  * right-anchors so the edge labels stay inside the plot (the old default).
61
86
  * - `'right'` — the label sits to the **right** of an extended tick that
62
- * drops from the axis line (label beside the tick, not under it) — useful
63
- * for dense or wide labels that would collide when centred.
87
+ * drops from the axis line (label beside the tick, not under it) — a
88
+ * *style* choice (the TradingView look). It re-anchors without measuring,
89
+ * so it is **not** a remedy for colliding labels; on a category axis the
90
+ * measured fit (thin + middle-ellipsize) is what prevents collisions.
64
91
  */
65
92
  align?: 'auto' | 'center' | 'right';
66
93
  /**
@@ -80,6 +107,22 @@ export interface XAxisProps {
80
107
  * on each turn is emphasized and joins its divider as one boundary line.
81
108
  */
82
109
  dateStyle?: 'flat' | 'stacked';
110
+ /**
111
+ * Mouse events on the axis strip, with the **axis value under the pointer**
112
+ * ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click on a time axis reports the instant it
113
+ * landed on, a click on a category axis reports the category. The lever for
114
+ * axis-driven UI: pick a date by clicking its tick, open a menu on the strip
115
+ * (`event.type === 'contextmenu'`), drill into a category.
116
+ *
117
+ * **One handler takes every mouse event** — click, double-click, context
118
+ * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
119
+ * attached when the prop is omitted, so the move events cost nothing unless
120
+ * you ask for them.
121
+ *
122
+ * The x strip has no `id` (only a `<YAxis>` does); to distinguish stacked
123
+ * axes, close over it: `onMouseEvent={(e) => onAxis('delta', e)}`.
124
+ */
125
+ onMouseEvent?: AxisMouseHandler;
83
126
  }
84
127
  /**
85
128
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
@@ -91,5 +134,6 @@ export interface XAxisProps {
91
134
  *
92
135
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
93
136
  */
94
- export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
137
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
138
+ export {};
95
139
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -3,9 +3,11 @@ import { Fragment, useContext } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { derivedTicks } from './derivedTicks.js';
5
5
  import { ContainerContext, CursorContext, } from './context.js';
6
+ import { tickValues } from './yticks.js';
6
7
  import { xAxisCursorEntries } from './cursors.js';
7
8
  import { axisPillStyle } from './chip.js';
8
9
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
10
+ import { axisMouseProps, axisPointerPx, } from './axis-events.js';
9
11
  /** Tick strip height (mark + value label) in CSS px. */
10
12
  const TICK_STRIP = 22;
11
13
  /** Extra height reserved for an axis `label` line. */
@@ -17,32 +19,132 @@ const BAND_STRIP = 20;
17
19
  * ladder's per-tick budget (a hair tighter: derived labels are short). */
18
20
  const TRANSFORM_TICK_PX = 48;
19
21
  /**
20
- * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
21
- * every `stride`-th label (so a kept label has room), and ellipsize one that still
22
- * overruns its space. `stride` grows with the longest label vs the per-category
23
- * slot width, so a few short categories keep every full label and many long ones
24
- * decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
25
- * for placement; the exact metric is the browser's. Rotation is a later option.
22
+ * Measure a category label's rendered width in the axis font. A shared
23
+ * offscreen canvas gives the browser's own metric the thing the old
24
+ * per-character estimate could only approximate, and approximated low on
25
+ * exactly the labels category axes carry (all-caps keys with digits and
26
+ * hyphens), which let "fits by the estimate" labels overprint on screen
27
+ * ([PND-CATFIT]). Falls back to the estimate where no canvas backend exists
28
+ * (SSR, test DOMs). Results are cached per font+text; a webfont that loads
29
+ * after first measure keeps its fallback-font metric until the cache turns
30
+ * over, which the fit's inter-label gap absorbs.
26
31
  */
27
- function thinCategoryLabels(ticks, plotWidth, fontSize) {
32
+ const measureCache = new Map();
33
+ // Module state: the first render's canvas context is kept for the process
34
+ // lifetime. In tests this captures whatever canvas stub is installed at first
35
+ // measure — harmless while stubs measure 0 (the estimate fallback takes over
36
+ // per call), but a future *nonzero* canvas mock would need a reset hook here.
37
+ let measureCtx;
38
+ function labelWidth(text, font, fontSize) {
39
+ const key = `${font}|${text}`;
40
+ const hit = measureCache.get(key);
41
+ if (hit !== undefined)
42
+ return hit;
43
+ if (measureCtx === undefined) {
44
+ try {
45
+ measureCtx =
46
+ typeof document === 'undefined'
47
+ ? null
48
+ : (document.createElement('canvas').getContext('2d') ?? null);
49
+ }
50
+ catch {
51
+ measureCtx = null; // a DOM shim whose getContext throws → estimate path
52
+ }
53
+ }
54
+ let w = 0;
55
+ if (measureCtx !== null) {
56
+ measureCtx.font = font;
57
+ w = measureCtx.measureText(text).width;
58
+ }
59
+ // No backend, or a mock that measures everything at 0 → per-glyph estimate.
60
+ if (!(w > 0))
61
+ w = text.length * fontSize * 0.62;
62
+ if (measureCache.size > 4096)
63
+ measureCache.clear();
64
+ measureCache.set(key, w);
65
+ return w;
66
+ }
67
+ /** Minimum clear space between two neighbouring drawn labels, px. */
68
+ const LABEL_GAP = 4;
69
+ /**
70
+ * A kept label may ellipsize down to this fraction of its full width before
71
+ * the fit prefers dropping labels (growing `stride`) instead — below it, the
72
+ * text no longer identifies its category.
73
+ */
74
+ const TRUNC_KEEP = 0.6;
75
+ /**
76
+ * Ellipsize `text` from the **middle** to fit `room` px: category keys often
77
+ * share a prefix and differ in the tail (or the reverse), so keeping both ends
78
+ * preserves whichever part distinguishes — end-truncation makes shared-prefix
79
+ * keys visually identical. Head-heavy split (60/40). Binary search on the kept
80
+ * **code-point** count (a UTF-16 `slice` could split a surrogate pair and
81
+ * emit mojibake); the result is only accepted when it *measures* within
82
+ * `room`, so the returned label can never overrun its space.
83
+ */
84
+ function ellipsizeMiddle(text, room, font, fontSize) {
85
+ const cp = Array.from(text); // code points, not UTF-16 units
86
+ let lo = 1;
87
+ let hi = cp.length - 1;
88
+ let best = '…';
89
+ while (lo <= hi) {
90
+ const k = (lo + hi) >> 1;
91
+ const head = Math.ceil(k * 0.6);
92
+ const tail = k - head;
93
+ const s = cp.slice(0, head).join('') +
94
+ '…' +
95
+ (tail > 0 ? cp.slice(cp.length - tail).join('') : '');
96
+ if (labelWidth(s, font, fontSize) <= room) {
97
+ best = s;
98
+ lo = k + 1;
99
+ }
100
+ else {
101
+ hi = k - 1;
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+ /**
107
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible
108
+ * — triggered by **measured geometry**, not category count ([PND-CATFIT]):
109
+ * keep every `stride`-th label, and middle-ellipsize a kept one that still
110
+ * overruns its room. `stride` is the fewest slots the widest label needs once
111
+ * it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
112
+ * every full label and long ones trade thinning against truncation instead of
113
+ * overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
114
+ * `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
115
+ * a later option.
116
+ *
117
+ * Exported for tests only — not re-exported from the package index.
118
+ */
119
+ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily) {
28
120
  const n = ticks.length;
29
- const slot = plotWidth / n; // per-category width in px
30
- // Before first layout `plotWidth` is 0 `slot` is 0 and the stride/room math
31
- // below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
32
- // the ticks through untouched until a real width arrives.
33
- if (!(slot > 0))
34
- return [...ticks];
35
- const charW = fontSize * 0.62; // ~average glyph advance
36
- const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
37
- const stride = Math.max(1, Math.ceil((longest * charW) / slot));
38
- const room = Math.max(1, Math.floor((slot * stride) / charW));
121
+ // Degenerate / pre-layout width: nothing can be legible, so draw NO labels.
122
+ // The old passthrough here was the collapsed-panel smear: these are
123
+ // absolutely-positioned `nowrap` divs, so at width 0 every label rendered
124
+ // full-length at x 0, overflowing the strip and overprinting.
125
+ if (!(slot > 0) || !(plotWidth > 0))
126
+ return [];
127
+ const font = `${fontSize}px ${fontFamily}`;
128
+ const widths = ticks.map((t) => labelWidth(t.label, font, fontSize));
129
+ const maxW = widths.reduce((m, w) => Math.max(m, w), 0);
130
+ // The width a kept label must be allowed: its full measure when that's
131
+ // modest, else the legibility floor — TRUNC_KEEP of it, but never less than
132
+ // ~two glyphs of text.
133
+ const required = Math.min(maxW, Math.max(maxW * TRUNC_KEEP, fontSize * 2));
134
+ const stride = Math.max(1, Math.ceil((required + LABEL_GAP) / slot));
135
+ const room = Math.min(slot * stride - LABEL_GAP, plotWidth - LABEL_GAP);
136
+ // Not even ~two glyphs fit (a collapsed strip) — an empty axis over a smear.
137
+ if (!(room >= fontSize * 2))
138
+ return [];
39
139
  const out = [];
40
140
  for (let i = 0; i < n; i += stride) {
41
- const s = ticks[i].label;
42
- out.push({
43
- x: ticks[i].x,
44
- label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
45
- });
141
+ const t = ticks[i];
142
+ const label = widths[i] <= room
143
+ ? t.label
144
+ : ellipsizeMiddle(t.label, room, font, fontSize);
145
+ // A bare ellipsis identifies nothing — leave that tick unlabeled.
146
+ if (label !== '…')
147
+ out.push({ x: t.x, label });
46
148
  }
47
149
  return out;
48
150
  }
@@ -56,7 +158,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
56
158
  *
57
159
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
58
160
  */
59
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', } = {}) {
161
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
60
162
  const container = useContext(ContainerContext);
61
163
  if (container === null) {
62
164
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -261,24 +363,33 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
261
363
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
262
364
  : derived !== null
263
365
  ? honestDerived()
264
- : xScale.ticks(xTickCount).map((d) => ({
265
- x: xScale(d),
266
- label: tickFmt(+d),
267
- // A **period turn** renders emphasized (bold), consistently across
268
- // styles: in stacked, a tick on a band divider (matched by pixel);
269
- // in flat, a tick whose label was *promoted* to a coarser period
270
- // (its flat label differs from the terse base) — the same boundaries,
271
- // so `Feb` / `2026` read as strong in flat just as the band turns do.
272
- bold: stacked
273
- ? dividerXs.has(Math.round(xScale(d)))
274
- : flatFmt !== undefined &&
275
- baseFmt !== undefined &&
276
- flatFmt(+d) !== baseFmt(+d),
277
- }));
366
+ : // `tickValues`, not `xScale.ticks` — d3's raw `scaleLog.ticks()` is
367
+ // nearly a step function (see `yticks.ts`), so a log x needs the same
368
+ // decade ladder the y axis already builds. For a time or linear scale
369
+ // it defers to `scale.ticks(count)`, so this is a no-op there.
370
+ tickValues(xScale, xTickCount).map((d) => ({
371
+ x: xScale(d),
372
+ label: tickFmt(+d),
373
+ // A **period turn** renders emphasized (bold), consistently across
374
+ // styles: in stacked, a tick on a band divider (matched by pixel);
375
+ // in flat, a tick whose label was *promoted* to a coarser period
376
+ // (its flat label differs from the terse base) — the same boundaries,
377
+ // so `Feb` / `2026` read as strong in flat just as the band turns do.
378
+ bold: stacked
379
+ ? dividerXs.has(Math.round(xScale(d)))
380
+ : flatFmt !== undefined &&
381
+ baseFmt !== undefined &&
382
+ flatFmt(+d) !== baseFmt(+d),
383
+ }));
278
384
  // A category axis ticks once per category; thin + truncate its labels when they
279
- // crowd (an explicit `customTicks` axis keeps its labels verbatim).
280
- const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
281
- ? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
385
+ // crowd (an explicit `customTicks` axis keeps its labels verbatim). The slot is
386
+ // the scale's own band pitch under `maxBandWidth` packing it is narrower than
387
+ // `plotWidth / n`, and the fit must measure against the pitch labels actually
388
+ // sit on.
389
+ const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 0
390
+ ? thinCategoryLabels(rawTicks, 'bandwidth' in xScale
391
+ ? xScale.bandwidth()
392
+ : plotWidth / rawTicks.length, plotWidth, theme.font.size, theme.font.family)
282
393
  : rawTicks;
283
394
  const onTop = side === 'top';
284
395
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
@@ -300,7 +411,20 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
300
411
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
301
412
  (hasBands ? BAND_STRIP : 0) +
302
413
  maxPillLane * PILL_LANE_H;
303
- return (_jsxs("div", { style: {
414
+ // The axis coordinate under the pointer, for `onMouseEvent`. The strip is
415
+ // laid out flush with the plot (the left gutter is its margin, its width is
416
+ // `plotWidth`), so a strip-local pixel inverts straight through the shared x
417
+ // scale — no gutter arithmetic. The label reads the same channel a cursor
418
+ // pill does: the band scale's category name on a category axis (a d3 number
419
+ // format can't name one), this axis's readout format everywhere else.
420
+ const mouse = axisMouseProps(onMouseEvent, 'x', undefined, (event) => {
421
+ const value = +xScale.invert(axisPointerPx(event, 'x', [0, plotWidth]));
422
+ return {
423
+ value,
424
+ label: xKind === 'category' ? fmt(value) : readoutFmt(value),
425
+ };
426
+ });
427
+ return (_jsxs("div", { "data-axis": "x", ...mouse, style: {
304
428
  position: 'relative',
305
429
  marginLeft: `${leftGutter}px`,
306
430
  width: `${plotWidth}px`,
package/dist/YAxis.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type AxisFormat } from './format.js';
2
+ import { type AxisMouseHandler } from './axis-events.js';
2
3
  export interface YAxisProps {
3
4
  /** Identifier a chart links to via its `axis` prop (and the first declared is
4
5
  * the row's default). */
@@ -197,6 +198,19 @@ export interface YAxisProps {
197
198
  * colours. Presentation-only: it never re-registers the axis.
198
199
  */
199
200
  color?: string;
201
+ /**
202
+ * Mouse events on this axis's gutter, with the **axis value under the
203
+ * pointer** ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click reports the value it landed
204
+ * on, and this axis's `id`, so one handler can serve several axes. The lever
205
+ * for axis-driven UI: set a threshold by clicking the gutter, open a scale
206
+ * menu (`event.type === 'contextmenu'`), drill into a categorical row.
207
+ *
208
+ * **One handler takes every mouse event** — click, double-click, context
209
+ * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
210
+ * attached when the prop is omitted, so the move events cost nothing unless
211
+ * you ask for them. A `hide`den axis draws no gutter and so fires nothing.
212
+ */
213
+ onMouseEvent?: AxisMouseHandler;
200
214
  /**
201
215
  * @internal Declaration position among the row's children, injected by
202
216
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -211,5 +225,5 @@ export interface YAxisProps {
211
225
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
212
226
  * (default: the first axis).
213
227
  */
214
- export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
228
+ export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
215
229
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -3,7 +3,8 @@ import { useContext, useEffect, useMemo } from 'react';
3
3
  import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
- import { yTickValues } from './yticks.js';
6
+ import { tickValues } from './yticks.js';
7
+ import { axisMouseProps, axisPointerPx, } from './axis-events.js';
7
8
  const DEFAULT_WIDTH = 50;
8
9
  /** Fallback tick count before the row has published its resolved count (the
9
10
  * first render, pre-registration). The row's height-derived value takes over
@@ -17,7 +18,7 @@ const DEFAULT_TICK_COUNT = 5;
17
18
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
18
19
  * (default: the first axis).
19
20
  */
20
- export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
21
+ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, onMouseEvent, index = 0, }) {
21
22
  const container = useContext(ContainerContext);
22
23
  if (container === null) {
23
24
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -114,7 +115,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
114
115
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
115
116
  : layerCategories !== null
116
117
  ? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
117
- : (yScale ? yTickValues(yScale, count) : []).map((t) => ({
118
+ : (yScale ? tickValues(yScale, count) : []).map((t) => ({
118
119
  value: t,
119
120
  label: fmt(t),
120
121
  }));
@@ -124,7 +125,33 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
124
125
  // axes line up column-by-column. Keyed by this instance's slot key (not `id`,
125
126
  // which may repeat across a mirror). Falls back to own width until reserved.
126
127
  const slotWidth = row.axisSlots.get(slot) ?? width;
127
- return (_jsx("div", { style: {
128
+ // The axis value under the pointer, for `onMouseEvent` — read on the **slot**
129
+ // box (below), so the whole reserved gutter answers, not just this axis's own
130
+ // narrower content. The box is exactly the row's height and shares its top
131
+ // edge with the plot, so a box-local pixel inverts straight through this
132
+ // axis's scale. Before the row has published one there is no value to report
133
+ // and the event is dropped. A categorical row labels by slot, matching its
134
+ // ticks; every other row reads this axis's own tick format.
135
+ const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
136
+ if (!yScale)
137
+ return null;
138
+ // Clamp on the **scale's** range, not the box: a row with a
139
+ // `labelPlacement="top"` axis reserves a header, so the range is
140
+ // `[height, topHeader]` while the box still starts at 0 (`ChartRow`).
141
+ const [r0, r1] = yScale.range();
142
+ const value = yScale.invert(axisPointerPx(event, 'y', [r0, r1]));
143
+ return {
144
+ value,
145
+ label: layerCategories !== null
146
+ ? // A slot index, clamped to a real category: the domain's top edge
147
+ // inverts to exactly `n` (and rounding can nudge the bottom below
148
+ // 0), which no category occupies — the nearest one is the honest
149
+ // answer, matching the band scale's own `invert`.
150
+ (layerCategories[Math.min(layerCategories.length - 1, Math.max(0, Math.floor(value)))] ?? '')
151
+ : fmt(value),
152
+ };
153
+ });
154
+ return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse, style: {
128
155
  flex: `0 0 ${slotWidth}px`,
129
156
  display: 'flex',
130
157
  justifyContent: side === 'left' ? 'flex-end' : 'flex-start',
package/dist/area.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { ChartSeries } from './data.js';
3
3
  import { type Scale, type TraceState } from './line.js';
4
+ import type { BandLadder } from './bars.js';
4
5
  import type { AreaStyle } from './theme.js';
5
6
  import type { LayerDrawStats } from './context.js';
6
7
  import { type GapMode } from './gaps.js';
@@ -69,7 +70,48 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
69
70
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
70
71
  * are collected by one O(N) walk ({@link collectGapEdges}).
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
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption, banding?: BandLadder): LayerDrawStats;
74
+ /**
75
+ * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
76
+ * vertical **hard-stop gradient in pixel space**, a colour switch at every
77
+ * threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
78
+ * magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
79
+ * as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
80
+ * same currency `drawBars` takes.
81
+ *
82
+ * A gradient rather than one clipped redraw per band, and that is the
83
+ * load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
84
+ * themselves at every boundary with an antialiased seam, where a gradient
85
+ * draws the identical single path once and costs O(K) colour stops. It also
86
+ * bands the **outline for free** — `strokeStyle` takes the same gradient, so
87
+ * the value line switches hue exactly at a crossing, which no per-band clip
88
+ * can do without shearing the stroke.
89
+ *
90
+ * The ladder is walked on the **magnitude** and mirrored below zero, exactly
91
+ * as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
92
+ * zero side) from band `k + 1` (the away side). Whether "away from zero" is up
93
+ * or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
94
+ * positive and finite by construction), so a flipped axis bands correctly. A
95
+ * boundary with **no position** on the scale contributes no crossing — on a
96
+ * log axis the negative mirrors (and zero) simply don't exist, which is the
97
+ * right reading. A crossing **off the plot** clamps to the gradient's ends
98
+ * (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
99
+ * zoomed-in view honest: with every visible pixel inside one band, the clamp
100
+ * degenerates the other stops and the whole plot paints that band's colour.
101
+ *
102
+ * Falls back to the top band's flat colour when there is nothing to anchor on
103
+ * (no plot height, or no boundary with a position at all) — reachable only
104
+ * with a degenerate scale stub, since every real axis positions a positive
105
+ * finite value; any flat colour is equally (in)correct there, and the top
106
+ * band's is at least stable.
107
+ *
108
+ * Like the bar ladder, breakpoints are **absolute data values**, so the
109
+ * baseline plays no part here: an area resting on a non-zero floor still bands
110
+ * at the same heights as its neighbours — measuring from the resolved baseline
111
+ * instead would silently shift every breakpoint by the floor, the quiet
112
+ * wrongness [PND-BANDBAR2] exists to remove.
113
+ */
114
+ export declare function buildBandGradient(ctx: CanvasRenderingContext2D, yScale: Scale, plotHeight: number, banding: BandLadder): CanvasGradient | string;
73
115
  /**
74
116
  * **Is the pointer inside this area?** The filled-region counterpart of
75
117
  * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as