@pond-ts/charts 0.61.0 → 0.63.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.
@@ -1,8 +1,44 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type CursorMode } from './context.js';
3
3
  export interface ChartRowProps {
4
- /** Row height in CSS pixels. */
5
- height: number;
4
+ /**
5
+ * Row height in CSS pixels — the **fixed** sizing mode. Omit it (or pass
6
+ * {@link flex}) to let the row share the container's remaining height
7
+ * instead; a bare `<ChartRow>` means `flex={1}`.
8
+ */
9
+ height?: number;
10
+ /**
11
+ * Share of the container's **remaining** height ([PND-HEIGHT]) — the
12
+ * CSS-flex sizing mode, and what an omitted `height` defaults to (`1`).
13
+ *
14
+ * The remainder is what CSS flex layout says it is: the container's height
15
+ * minus its axis strip, minus every fixed-`height` row, minus any non-row
16
+ * children you placed between rows (a draggable splitter), minus `rowGap`s.
17
+ * That is deliberate — the row's box is `flex: <n> 1 0`, so **the browser
18
+ * does the subtraction** and there is no strip-height constant for a caller
19
+ * to know, guess, or drift on (the reporting consumer had `20` and `24` in
20
+ * one codebase for a strip that is actually 22 — *when it is not showing a
21
+ * calendar band row or marker pills, which change it*). The row then reads
22
+ * back the height the layout gave it and builds its y-scales from that.
23
+ *
24
+ * Mixing modes is the point, not an edge case: a price row over a volume
25
+ * row is `<ChartRow flex={3}>` over `<ChartRow flex={1}>`; the splitter
26
+ * shape is one `flex` row that absorbs slack over one fixed row the drag
27
+ * resizes.
28
+ *
29
+ * **Needs a container that manages height** — `<ChartContainer
30
+ * height={number | 'auto'}>`. Inside a container with no height, a flex
31
+ * row's box has nothing to flex into, collapses to zero, and stays gated
32
+ * out; dev builds warn.
33
+ *
34
+ * A flex row's first useful paint waits for its first measurement — by
35
+ * **timing**, not a gate: the first render does execute children at height
36
+ * 0 (a 0-height canvas draws nothing), and the layout effect's synchronous
37
+ * setState delivers the real height before the browser paints. Like the
38
+ * container's `width="auto"`, it keeps its last non-zero height while
39
+ * hidden, so a `display: none` tab switch does not discard its scales.
40
+ */
41
+ flex?: number;
6
42
  /**
7
43
  * Cursor presentation for this row, overriding the container's default
8
44
  * ({@link ChartContainerProps.cursor}). Omit to inherit. See {@link CursorMode}.
@@ -32,5 +68,5 @@ export interface ChartRowProps {
32
68
  * Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
33
69
  * before `<Layers/>` and `<YAxis side="right"/>` after.
34
70
  */
35
- export declare function ChartRow({ height, cursor, children }: ChartRowProps): import("react/jsx-runtime").JSX.Element;
71
+ export declare function ChartRow({ height: heightProp, flex, cursor, children, }: ChartRowProps): import("react/jsx-runtime").JSX.Element;
36
72
  //# sourceMappingURL=ChartRow.d.ts.map
package/dist/ChartRow.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
2
+ import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
4
4
  import { isDev } from './dev.js';
5
5
  import { useIndexedChildren } from './child-index.js';
@@ -90,6 +90,11 @@ function axisSpecEqual(a, b) {
90
90
  a.labelPlacement === b.labelPlacement &&
91
91
  a.index === b.index &&
92
92
  a.tickCount === b.tickCount &&
93
+ // The axis-edge chrome drawn *by the row* wears this (the crosshair's value
94
+ // pill), so a swallowed colour change would leave the pill on the old ink
95
+ // while the axis's own labels repainted — the same silent-staleness the
96
+ // `linearWindow` note above warns about.
97
+ a.color === b.color &&
93
98
  Object.is(a.format, b.format) &&
94
99
  numberArraysEqual(a.tickValues, b.tickValues));
95
100
  }
@@ -134,11 +139,73 @@ const TOP_LABEL_HEADER = 16;
134
139
  * Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
135
140
  * before `<Layers/>` and `<YAxis side="right"/>` after.
136
141
  */
137
- export function ChartRow({ height, cursor, children }) {
142
+ export function ChartRow({ height: heightProp, flex, cursor, children, }) {
138
143
  const container = useContext(ContainerContext);
139
144
  if (container === null) {
140
145
  throw new Error('<ChartRow> must be rendered inside a <ChartContainer>');
141
146
  }
147
+ // ── Sizing mode ([PND-HEIGHT]) ─────────────────────────────────────────────
148
+ // Fixed (`height` in px) or flex (`flex`, the default when neither is
149
+ // given). A flex row's box is sized by CSS (`flex: <n> 1 0` inside the
150
+ // container's column), and the row reads back what layout gave it — the
151
+ // browser subtracts the axis strip, fixed siblings, splitters and row gaps,
152
+ // so no caller (and no code here) ever knows what those cost.
153
+ const isFlex = heightProp === undefined;
154
+ const flexGrow = flex ?? 1;
155
+ const warnedSizingRef = useRef(false);
156
+ useEffect(() => {
157
+ if (!isDev || warnedSizingRef.current)
158
+ return;
159
+ if (heightProp !== undefined && flex !== undefined) {
160
+ // Both given is a contradiction, not a tiebreak: warn and honour
161
+ // `height` (the long-standing prop) so the chart renders
162
+ // deterministically.
163
+ warnedSizingRef.current = true;
164
+ console.warn(`[pond-charts] <ChartRow> got both height={${heightProp}} and ` +
165
+ `flex={${flex}} — they are alternative sizing modes. Using ` +
166
+ `height; drop one.`);
167
+ }
168
+ }, [heightProp, flex]);
169
+ const boxRef = useRef(null);
170
+ const [measured, setMeasured] = useState(0);
171
+ useLayoutEffect(() => {
172
+ if (!isFlex)
173
+ return;
174
+ const el = boxRef.current;
175
+ if (el === null)
176
+ return;
177
+ const measure = () => setMeasured((prev) => {
178
+ const next = Math.round(el.getBoundingClientRect().height);
179
+ // Latch the last non-zero height — a hidden row (display: none
180
+ // ancestor) is not a resized one, and writing 0 through would tear
181
+ // down its scales. Same rule as the container's width="auto".
182
+ return next > 0 ? next : prev;
183
+ });
184
+ measure();
185
+ if (typeof ResizeObserver === 'undefined')
186
+ return;
187
+ const ro = new ResizeObserver(measure);
188
+ ro.observe(el);
189
+ return () => ro.disconnect();
190
+ }, [isFlex]);
191
+ // A flex row inside a container that doesn't manage height has nothing to
192
+ // flex into: its box is 0 and stays 0, silently. That is a wiring error the
193
+ // consumer should hear about now, not a slow start to wait through.
194
+ const warnedNoHeightRef = useRef(false);
195
+ useEffect(() => {
196
+ if (!isDev ||
197
+ !isFlex ||
198
+ container.managesHeight ||
199
+ warnedNoHeightRef.current) {
200
+ return;
201
+ }
202
+ warnedNoHeightRef.current = true;
203
+ console.warn(`[pond-charts] <ChartRow flex> needs a container that manages height ` +
204
+ `— give <ChartContainer> a height ({number | 'auto'}). Inside a ` +
205
+ `container with no height this row's box collapses to 0 and never ` +
206
+ `paints.`);
207
+ }, [isFlex, container.managesHeight]);
208
+ const height = isFlex ? measured : heightProp;
142
209
  // Register on mount so the container can mark the first (topmost) row by
143
210
  // mount order — the shared cursor-time chip renders there only.
144
211
  const rowKey = useSlotKey();
@@ -165,6 +232,33 @@ export function ChartRow({ height, cursor, children }) {
165
232
  // min/max or series change silently rebind axes / reorder the z-stack.)
166
233
  const [axes, setAxes] = useState(() => new Map());
167
234
  const [layers, setLayers] = useState(() => new Map());
235
+ // Per-axis pixel zoom — a drag on one gutter (see `RowFrame.axisTransforms`).
236
+ // Empty until an axis is actually grabbed, so a chart with no axis gestures
237
+ // carries no extra state and the identity branch below skips the work.
238
+ // Filled by the scale memo below (see `baseYScales` on the frame).
239
+ const baseRef = useRef(new Map());
240
+ const [axisTransforms, setAxisTransforms] = useState(() => new Map());
241
+ const applyAxisTransform = useCallback((id, next) => {
242
+ setAxisTransforms((prev) => {
243
+ const cur = prev.get(id);
244
+ if (cur !== undefined && cur.k === next.k && cur.ty === next.ty) {
245
+ return prev; // no-op: don't re-render (a wheel notch at a clamp)
246
+ }
247
+ const map = new Map(prev);
248
+ // Identity is the absence of a transform, not an entry recording one —
249
+ // so the reset genuinely returns the axis to the un-grabbed state and
250
+ // the scale memo's fast path applies again.
251
+ if (next.k === 1 && next.ty === 0) {
252
+ if (cur === undefined)
253
+ return prev;
254
+ map.delete(id);
255
+ }
256
+ else {
257
+ map.set(id, next);
258
+ }
259
+ return map;
260
+ });
261
+ }, []);
168
262
  // Registration is idempotent under value-equality: a `<YAxis>` re-fires its
169
263
  // register effect whenever its `spec` memo yields a fresh object — which an
170
264
  // inline `ticks={[]}` / `format` or a re-rendered parent does every render. If
@@ -234,6 +328,7 @@ export function ChartRow({ height, cursor, children }) {
234
328
  format: undefined,
235
329
  tickValues: undefined,
236
330
  tickCount: undefined,
331
+ color: undefined,
237
332
  index: 0,
238
333
  },
239
334
  ], [realAxes]);
@@ -278,6 +373,7 @@ export function ChartRow({ height, cursor, children }) {
278
373
  const { k: yk, ty: yty } = container.yTransform;
279
374
  const yScales = useMemo(() => {
280
375
  const map = new Map();
376
+ const bases = new Map();
281
377
  for (const ax of effectiveAxes) {
282
378
  const extents = needsExtents(ax)
283
379
  ? layerList
@@ -315,14 +411,43 @@ export function ChartRow({ height, cursor, children }) {
315
411
  // each other in the first cut. Narrowing the domain means ticks, padding
316
412
  // and every downstream reader see an ordinary axis over the visible
317
413
  // window, and none of them need to know a transform exists.
318
- if (yk !== 1 || yty !== 0) {
319
- const at = (px) => +s.invert((px - yty) / yk);
414
+ //
415
+ // The **per-axis** transform (an axis-gutter drag) is applied the same
416
+ // way, immediately after — so a doubly-transformed axis is still just an
417
+ // ordinary axis over the doubly-narrowed window. Sequential rather than
418
+ // pre-composed on purpose: each step inverts through the scale it is
419
+ // actually narrowing, which is what keeps it correct on a log / symlog
420
+ // axis, where pixel→value is not affine and two composed `k`s would not
421
+ // land where two applications do.
422
+ const narrow = (k, ty) => {
423
+ const at = (px) => +s.invert((px - ty) / k);
320
424
  s.domain([at(height), at(topHeader)]);
425
+ };
426
+ // Snapshot before either transform: `baseYScales` is the domain the axis
427
+ // RESOLVED to (bounds + pad + nice), which is the space a controlled
428
+ // consumer's `min`/`max` live in. Reading the transformed scale instead and
429
+ // handing those values back re-applies the transform on top of them.
430
+ bases.set(ax.id, s.copy());
431
+ if (yk !== 1 || yty !== 0)
432
+ narrow(yk, yty);
433
+ const own = axisTransforms.get(ax.id);
434
+ if (own !== undefined && (own.k !== 1 || own.ty !== 0)) {
435
+ narrow(own.k, own.ty);
321
436
  }
322
437
  map.set(ax.id, s);
323
438
  }
439
+ baseRef.current = bases;
324
440
  return map;
325
- }, [effectiveAxes, layerList, height, defaultAxisId, topHeader, yk, yty]);
441
+ }, [
442
+ effectiveAxes,
443
+ layerList,
444
+ height,
445
+ defaultAxisId,
446
+ topHeader,
447
+ yk,
448
+ yty,
449
+ axisTransforms,
450
+ ]);
326
451
  // Dev-mode diagnostics for a `scale="log"` axis (see `logAxisWarning`). Three
327
452
  // things about *where* this sits are load-bearing, each of them a bug the
328
453
  // first version shipped:
@@ -451,6 +576,53 @@ export function ChartRow({ height, cursor, children }) {
451
576
  map.set(ax.id, ax.side);
452
577
  return map;
453
578
  }, [effectiveAxes]);
579
+ // How far out in its gutter each axis sits: the px from the plot's edge to the
580
+ // axis's inner edge, walking each side plot-outward and accumulating the
581
+ // *reserved* slot widths (what the axis boxes actually render at, so the
582
+ // offset lands on the axis and not between two of them). Side alone would put
583
+ // every pill on the innermost axis — the wrong scale as soon as a side carries
584
+ // two (see RowFrame.axisOffsets). Right axes are authored inner→outer, left
585
+ // axes outer→inner, so the left list walks in reverse.
586
+ //
587
+ // Resolved per **instance** first, then collapsed to ids by the same
588
+ // last-declared-wins rule `axisSides` uses — so for a mirrored id (one scale
589
+ // registered on both sides, or a duplicate) the side and the offset always
590
+ // come from the *same* axis. A different rule per map (say, keeping the
591
+ // smallest offset) can pair one instance's side with another's offset, which
592
+ // is the very "pill on an axis that didn't measure it" this fixes.
593
+ const axisOffsets = useMemo(() => {
594
+ const byInstance = new Map();
595
+ const walk = (side) => {
596
+ const inward = realEntries.filter(([, spec]) => spec.side === side);
597
+ if (side === 'left')
598
+ inward.reverse();
599
+ let offset = 0;
600
+ for (const [key, spec] of inward) {
601
+ byInstance.set(key, offset);
602
+ offset += axisSlots.get(key) ?? spec.width;
603
+ }
604
+ };
605
+ walk('right');
606
+ walk('left');
607
+ // realEntries is index-sorted, the order effectiveAxes (and so axisSides)
608
+ // walks — hence the same winner on a repeated id.
609
+ const map = new Map();
610
+ for (const [key, spec] of realEntries) {
611
+ map.set(spec.id, byInstance.get(key) ?? 0);
612
+ }
613
+ return map;
614
+ }, [realEntries, axisSlots]);
615
+ // Each axis's own ink (`<YAxis color>`) — the axis-edge pill that lands on an
616
+ // axis wears its colour, so with several axes the number says which scale it
617
+ // is on. Axes that set no colour are absent (the pill falls back to theme).
618
+ const axisColors = useMemo(() => {
619
+ const map = new Map();
620
+ for (const ax of effectiveAxes) {
621
+ if (ax.color !== undefined)
622
+ map.set(ax.id, ax.color);
623
+ }
624
+ return map;
625
+ }, [effectiveAxes]);
454
626
  const frame = useMemo(() => ({
455
627
  height,
456
628
  topInset: topHeader,
@@ -458,10 +630,15 @@ export function ChartRow({ height, cursor, children }) {
458
630
  isFirstRow,
459
631
  rowKey,
460
632
  yScales,
633
+ baseYScales: baseRef.current,
634
+ axisTransforms,
635
+ applyAxisTransform,
461
636
  formats,
462
637
  tickValues,
463
638
  tickCounts,
464
639
  axisSides,
640
+ axisOffsets,
641
+ axisColors,
465
642
  defaultAxisId,
466
643
  axisSlots,
467
644
  registerAxis,
@@ -476,10 +653,14 @@ export function ChartRow({ height, cursor, children }) {
476
653
  isFirstRow,
477
654
  rowKey,
478
655
  yScales,
656
+ axisTransforms,
657
+ applyAxisTransform,
479
658
  formats,
480
659
  tickValues,
481
660
  tickCounts,
482
661
  axisSides,
662
+ axisOffsets,
663
+ axisColors,
483
664
  defaultAxisId,
484
665
  axisSlots,
485
666
  registerAxis,
@@ -556,11 +737,17 @@ export function ChartRow({ height, cursor, children }) {
556
737
  "wrap the row's <Layers>, leaving each <YAxis> a direct child of the " +
557
738
  '<ChartRow>.');
558
739
  }, [axisInsideWrapper]);
559
- return (_jsxs(RowContext.Provider, { value: frame, children: [cursor !== undefined && (_jsx(LegacyCursor, { mode: cursor, showTime: container.cursorTime, snap: container.crosshairSnap })), _jsxs("div", { style: {
740
+ return (_jsxs(RowContext.Provider, { value: frame, children: [cursor !== undefined && (_jsx(LegacyCursor, { mode: cursor, showTime: container.cursorTime, snap: container.crosshairSnap })), _jsxs("div", { ref: boxRef, style: {
560
741
  display: 'flex',
561
742
  flexDirection: 'row',
562
743
  width: `${container.width}px`,
563
- height: `${height}px`,
744
+ // Fixed rows keep their pixels; a flex row is sized by the
745
+ // container's column layout and reads the result back
746
+ // ([PND-HEIGHT]). `minHeight: 0` is what lets it actually shrink —
747
+ // a flex child's default min-height is its content.
748
+ ...(isFlex
749
+ ? { flex: `${flexGrow} 1 0%`, minHeight: 0 }
750
+ : { height: `${height}px` }),
564
751
  }, children: [leftPad > 0 && _jsx("div", { style: { flex: `0 0 ${leftPad}px` } }), leftAxisEls, plotEls, rightAxisEls, rightPad > 0 && _jsx("div", { style: { flex: `0 0 ${rightPad}px` } })] })] }));
565
752
  }
566
753
  //# sourceMappingURL=ChartRow.js.map
package/dist/Layers.js CHANGED
@@ -207,7 +207,7 @@ export function Layers({ children }) {
207
207
  }), [row.registerLayer, row.unregisterLayer]);
208
208
  const background = container.theme.background;
209
209
  const { grid: gridColor, gridDash } = container.theme.axis;
210
- const { layers, yScales, formats, defaultAxisId, tickValues, tickCounts, axisSides, } = row;
210
+ const { layers, yScales, formats, defaultAxisId, tickValues, tickCounts, axisSides, axisOffsets, axisColors, } = row;
211
211
  // x geometry is shared and lives on the container (uniform across rows), and
212
212
  // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
213
213
  // labels, which pass the same `xTickCount` to the same scale.
@@ -516,14 +516,20 @@ export function Layers({ children }) {
516
516
  // The chip uses this layer's axis formatter, so a readout value reads
517
517
  // exactly as the axis labels it.
518
518
  const fmt = formats.get(axisId) ?? String;
519
- // Which gutter the crosshair value pill hugs (the axis's own side).
519
+ // Where the crosshair value pill lands, and in what ink: this axis's own
520
+ // side, its offset out into that gutter (so the pill sits on the axis that
521
+ // measured the value, not the innermost one), and its `<YAxis color>`.
520
522
  const side = axisSides.get(axisId) ?? 'left';
523
+ const axisOffset = axisOffsets.get(axisId) ?? 0;
524
+ const axisColor = axisColors.get(axisId);
521
525
  for (const s of entry.layer.sampleAt(cursorTime)) {
522
526
  out.push({
523
527
  px: xScale(s.x),
524
528
  py: yScale(s.value),
525
529
  axisId,
526
530
  side,
531
+ axisOffset,
532
+ axisColor,
527
533
  formatted: fmt(s.value),
528
534
  color: s.color,
529
535
  label: s.label,
@@ -538,6 +544,8 @@ export function Layers({ children }) {
538
544
  yScales,
539
545
  formats,
540
546
  axisSides,
547
+ axisOffsets,
548
+ axisColors,
541
549
  xScale,
542
550
  defaultAxisId,
543
551
  ]);
@@ -1539,6 +1547,10 @@ export function Layers({ children }) {
1539
1547
  py: cursor.cursorY,
1540
1548
  formatted: fmt(ys.invert(cursor.cursorY)),
1541
1549
  side: axisSides.get(defaultAxisId) ?? 'left',
1550
+ // The free reticle reads the row's *default* axis, so its pill belongs on
1551
+ // that axis — at its offset, in its ink — like a snapped sample's.
1552
+ axisOffset: axisOffsets.get(defaultAxisId) ?? 0,
1553
+ axisColor: axisColors.get(defaultAxisId),
1542
1554
  };
1543
1555
  }, [
1544
1556
  wantsPointer,
@@ -1548,6 +1560,8 @@ export function Layers({ children }) {
1548
1560
  yScales,
1549
1561
  formats,
1550
1562
  axisSides,
1563
+ axisOffsets,
1564
+ axisColors,
1551
1565
  defaultAxisId,
1552
1566
  ]);
1553
1567
  // The in-plot cursor time, readout-formatted — the `showTime` presets' chip
package/dist/XAxis.d.ts CHANGED
@@ -133,6 +133,12 @@ export interface XAxisProps {
133
133
  * numbers, with no axis-type prop here; the kind follows the data.
134
134
  *
135
135
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
136
+ *
137
+ * **Gestures.** With `<ChartContainer axisPanZoom="x">` (or `"xy"`) the strip is a
138
+ * second handle on the canvas gesture: it **pans on drag** and **zooms on
139
+ * wheel**, with double-click returning to the declared `range`. Same maths as the
140
+ * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
141
+ * A category axis has no continuous domain and stays inert.
136
142
  */
137
143
  export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
138
144
  export {};
package/dist/XAxis.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useContext } from 'react';
2
+ import { Fragment, useContext, useRef } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { derivedTicks } from './derivedTicks.js';
5
5
  import { ContainerContext, CursorContext, } from './context.js';
@@ -8,6 +8,8 @@ import { xAxisCursorEntries } from './cursors.js';
8
8
  import { axisPillStyle } from './chip.js';
9
9
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
10
10
  import { axisMouseProps, axisPointerPx, } from './axis-events.js';
11
+ import { useAxisGestures } from './use-axis-gestures.js';
12
+ import { panRange, panRangeTrading, zoomRange, zoomRangeTrading, } from './viewport.js';
11
13
  /** Tick strip height (mark + value label) in CSS px. */
12
14
  const TICK_STRIP = 22;
13
15
  /** Extra height reserved for an axis `label` line. */
@@ -157,6 +159,12 @@ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily)
157
159
  * numbers, with no axis-type prop here; the kind follows the data.
158
160
  *
159
161
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
162
+ *
163
+ * **Gestures.** With `<ChartContainer axisPanZoom="x">` (or `"xy"`) the strip is a
164
+ * second handle on the canvas gesture: it **pans on drag** and **zooms on
165
+ * wheel**, with double-click returning to the declared `range`. Same maths as the
166
+ * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
167
+ * A category axis has no continuous domain and stays inert.
160
168
  */
161
169
  export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
162
170
  const container = useContext(ContainerContext);
@@ -417,14 +425,84 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
417
425
  // scale — no gutter arithmetic. The label reads the same channel a cursor
418
426
  // pill does: the band scale's category name on a category axis (a d3 number
419
427
  // format can't name one), this axis's readout format everywhere else.
428
+ // The view the current pan started from — see `onDragStart`.
429
+ const panStartRef = useRef(null);
430
+ // Drag pans and wheel zooms the shared x view, double-click returns to the
431
+ // declared range. Enabled by the container's own `panZoom` zoom-x degree of
432
+ // freedom — a chart that never opted in captures nothing here.
433
+ //
434
+ // A **category** axis has no continuous domain to zoom, exactly as for the
435
+ // plot's gesture (`Layers`' wheel makes the same exclusion), so the strip
436
+ // stays inert there rather than snapping between slots.
437
+ const gestures = useAxisGestures({
438
+ axis: 'x',
439
+ // Gated on `<ChartContainer axisPanZoom>` — the axis opt-in — and NOT on the
440
+ // plot's `panZoom`: inheriting that would hand every already-interactive
441
+ // chart gestures its author never asked for. Once opted in the strip is the
442
+ // canvas gesture (drag pans, wheel zooms). A category axis has no continuous
443
+ // domain for either.
444
+ drag: container.axisPanZoomX && xKind !== 'category' ? 'pan' : 'none',
445
+ wheel: container.axisPanZoomX && xKind !== 'category',
446
+ // Snapshot the view at press: the pan is re-derived from it on every move
447
+ // (the plot's own approach), so a long drag can't accumulate rounding, and
448
+ // `roundRange`'s ms snap can't ratchet the span.
449
+ onDragStart: () => {
450
+ panStartRef.current = [container.timeRange[0], container.timeRange[1]];
451
+ },
452
+ onPan: (totalDeltaPx) => {
453
+ const start = panStartRef.current;
454
+ if (start === null)
455
+ return;
456
+ // Dragging right moves the view EARLIER — the content follows the pointer,
457
+ // the sign the plot's drag uses.
458
+ if (container.discontinuities) {
459
+ // Trading-time axis: pan by an equal amount of *trading* time so the
460
+ // drag feels uniform across collapsed gaps (a raw-ms shift jumps).
461
+ const fraction = plotWidth > 0 ? -totalDeltaPx / plotWidth : 0;
462
+ container.applyRange(panRangeTrading(start, fraction, container.discontinuities));
463
+ return;
464
+ }
465
+ const span = start[1] - start[0];
466
+ const dt = plotWidth > 0 ? -totalDeltaPx * (span / plotWidth) : 0;
467
+ container.applyRange(panRange(start, dt, { log: container.xIsLog, snap: xKind === 'time' }));
468
+ },
469
+ onZoom: (factor, pivotPx) => {
470
+ const pivot = +xScale.invert(pivotPx);
471
+ // The same two-branch zoom the plot uses, so an axis drag and a plot
472
+ // wheel move the view by identical maths — including `minDuration` as the
473
+ // zoom-in floor and, on a trading axis, a floor in *trading* ms.
474
+ // `applyRange` then applies `bounds` for both.
475
+ container.applyRange(container.discontinuities
476
+ ? zoomRangeTrading(container.timeRange, pivot, factor, container.discontinuities, container.minDuration)
477
+ : zoomRange(container.timeRange, pivot, factor, container.minDuration, {
478
+ log: container.xIsLog,
479
+ snap: xKind === 'time',
480
+ }));
481
+ },
482
+ onReset: () => container.applyRange(container.seedRange),
483
+ });
420
484
  const mouse = axisMouseProps(onMouseEvent, 'x', undefined, (event) => {
485
+ // A zoom drag ends with a trailing `click` on the strip, which would report
486
+ // as "clicked the axis at the value I released on" — a value the user never
487
+ // aimed at. Swallow exactly that one report; every other event still flows.
488
+ if (event.type === 'click' && gestures.consumeDrag())
489
+ return null;
421
490
  const value = +xScale.invert(axisPointerPx(event, 'x', [0, plotWidth]));
422
491
  return {
423
492
  value,
424
493
  label: xKind === 'category' ? fmt(value) : readoutFmt(value),
425
494
  };
426
495
  });
427
- return (_jsxs("div", { "data-axis": "x", ...mouse, style: {
496
+ return (_jsxs("div", { "data-axis": "x", ref: gestures.ref, ...gestures.props, ...mouse,
497
+ // Both spreads carry an `onDoubleClick` — the gesture's reset and the
498
+ // consumer's report — and a spread silently keeps the last one. Compose
499
+ // them, reporting *before* the reset so the payload describes the view the
500
+ // user actually double-clicked in.
501
+ onDoubleClick: (e) => {
502
+ mouse.onDoubleClick?.(e);
503
+ gestures.props.onDoubleClick?.();
504
+ }, style: {
505
+ ...gestures.style,
428
506
  position: 'relative',
429
507
  marginLeft: `${leftGutter}px`,
430
508
  width: `${plotWidth}px`,
package/dist/YAxis.d.ts CHANGED
@@ -195,7 +195,14 @@ export interface YAxisProps {
195
195
  * overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
196
196
  * convention of colouring each y axis to match its series (`color`
197
197
  * matching the layer's) — busy, but standard. Omit for the theme's axis
198
- * colours. Presentation-only: it never re-registers the axis.
198
+ * colours.
199
+ *
200
+ * **Also worn by the axis-edge chrome that lands on this axis** — a
201
+ * `<CrosshairCursor>`'s value pill takes it when the reticle reads a series
202
+ * scaled here, so with several axes the pill says *which* scale the number is
203
+ * on (the ChartIQ price-tag convention). That is why it rides on the
204
+ * registered spec: the pill is drawn by the row's cursor overlay, not by this
205
+ * component, so a colour it never registered could not reach it.
199
206
  */
200
207
  color?: string;
201
208
  /**
@@ -209,8 +216,57 @@ export interface YAxisProps {
209
216
  * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
210
217
  * attached when the prop is omitted, so the move events cost nothing unless
211
218
  * you ask for them. A `hide`den axis draws no gutter and so fires nothing.
219
+ *
220
+ * A gutter that also zooms (see the component docs) still reports every event
221
+ * here, minus the trailing `click` a zoom drag would otherwise synthesize.
212
222
  */
213
223
  onMouseEvent?: AxisMouseHandler;
224
+ /**
225
+ * A gutter gesture scaled this axis — **the "auto vs manual" hand-off.**
226
+ * Fires with the `[min, max]` **bounds** the gesture arrived at, and with
227
+ * `null` when the axis is released back to auto-fit (double-click).
228
+ *
229
+ * Named for bounds rather than the domain because that is what it reports: with
230
+ * a `pad` set, the visible domain is these bounds *plus* the padding, and it is
231
+ * the bounds you hand back as `min`/`max`.
232
+ *
233
+ * The common shape this exists for: an auto-fitting y axis on a chart whose x
234
+ * is panned and zoomed. The moment the user scrolls or drags the y gutter they
235
+ * have overridden the fit, and a UI usually wants to *say* so — show the
236
+ * resulting min/max, mark the scale "manual", and offer a toggle back to auto
237
+ * (which is the same thing double-clicking the gutter does).
238
+ *
239
+ * ```tsx
240
+ * const [scale, setScale] = useState<[number, number] | null>(null); // null = auto
241
+ * <YAxis
242
+ * id="price"
243
+ * {...(scale ? { min: scale[0], max: scale[1] } : {})}
244
+ * onBoundsChange={setScale}
245
+ * />
246
+ * ```
247
+ *
248
+ * **Providing it makes the axis controlled**, exactly as `onTimeRangeChange`
249
+ * does for the x view: the gesture then only *reports*, and what the axis draws
250
+ * is whatever `min`/`max` you feed back. Omit it and the axis holds the zoom
251
+ * itself (an internal per-axis transform) — which is the standalone behaviour,
252
+ * and why a chart with no scale UI needs no wiring at all.
253
+ *
254
+ * The reported pair is in data units, ready to hand straight back as
255
+ * `min`/`max`.
256
+ *
257
+ * **`scale="symlog"` is approximate on this path, by construction.**
258
+ * {@link linearWindow} is a fraction of the *domain*, so bounds fed back
259
+ * re-derive the knee and reshape the curve — the grabbed pixel cannot be held
260
+ * on a curve that moves with the bounds. (It is the same fact that makes
261
+ * `linearWindow` deliberately *not* recompute under a 2-D gesture.) The zoom is
262
+ * still monotone and well-behaved; if you need the pixel held exactly on a
263
+ * symlog axis, leave this callback off and let the axis hold the zoom itself,
264
+ * where the knee stays anchored to the resolved domain. With an active plot-level y zoom (`panZoom="panZoomY"`/`"panZoomXY"`)
265
+ * the two **compose**: the bounds are the axis's own, and the plot transform
266
+ * still narrows what is drawn on top of them. On a `log` axis it stays positive (the zoom is done in log
267
+ * space), so it is always a domain the axis can actually draw.
268
+ */
269
+ onBoundsChange?: (bounds: readonly [number, number] | null) => void;
214
270
  /**
215
271
  * @internal Declaration position among the row's children, injected by
216
272
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -224,6 +280,14 @@ export interface YAxisProps {
224
280
  * computes this axis's scale from the charts linked to it; the gutter then draws
225
281
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
226
282
  * (default: the first axis).
283
+ *
284
+ * **Gestures.** With `<ChartContainer axisPanZoom="y">` (or `"xy"`) the gutter is
285
+ * grabbable: drag or wheel it to scale **this axis only**
286
+ * — a sibling axis on the other side, and every other row, hold still — and
287
+ * double-click to release it back to its fit. That per-axis scaling is what the
288
+ * plot's vertical gesture deliberately cannot do; see
289
+ * {@link RowFrame.axisTransforms}. Report it to a scale UI with
290
+ * {@link YAxisProps.onBoundsChange}.
227
291
  */
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;
292
+ export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, onBoundsChange, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
229
293
  //# sourceMappingURL=YAxis.d.ts.map