@pond-ts/charts 0.62.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.
package/dist/YAxis.js CHANGED
@@ -2,9 +2,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useContext, useEffect, useMemo } from 'react';
3
3
  import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
+ import { unpadDomain } from './domain.js';
5
6
  import { useSlotKey } from './use-slot-key.js';
6
7
  import { tickValues } from './yticks.js';
7
8
  import { axisMouseProps, axisPointerPx, } from './axis-events.js';
9
+ import { useAxisGestures } from './use-axis-gestures.js';
10
+ /**
11
+ * Clamp on a gutter drag's own zoom factor. Unlike the container's uniform
12
+ * transform (floored at `k ≥ 1` so a plot gesture can't zoom every axis out into
13
+ * blank canvas) an axis you deliberately grabbed may squash as well as stretch —
14
+ * `k < 1` widens the domain, which costs nothing. The bounds exist only so a
15
+ * flick of the wheel can't strand the axis at a factor no further gesture can
16
+ * recover from.
17
+ */
18
+ const MIN_AXIS_K = 0.02;
19
+ const MAX_AXIS_K = 50;
20
+ /** The un-grabbed transform — also what clears an axis's entry (see `ChartRow`). */
21
+ const IDENTITY_TRANSFORM = { k: 1, ty: 0 };
8
22
  const DEFAULT_WIDTH = 50;
9
23
  /** Fallback tick count before the row has published its resolved count (the
10
24
  * first render, pre-registration). The row's height-derived value takes over
@@ -17,8 +31,16 @@ const DEFAULT_TICK_COUNT = 5;
17
31
  * computes this axis's scale from the charts linked to it; the gutter then draws
18
32
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
19
33
  * (default: the first axis).
34
+ *
35
+ * **Gestures.** With `<ChartContainer axisPanZoom="y">` (or `"xy"`) the gutter is
36
+ * grabbable: drag or wheel it to scale **this axis only**
37
+ * — a sibling axis on the other side, and every other row, hold still — and
38
+ * double-click to release it back to its fit. That per-axis scaling is what the
39
+ * plot's vertical gesture deliberately cannot do; see
40
+ * {@link RowFrame.axisTransforms}. Report it to a scale UI with
41
+ * {@link YAxisProps.onBoundsChange}.
20
42
  */
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, }) {
43
+ 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, onBoundsChange, index = 0, }) {
22
44
  const container = useContext(ContainerContext);
23
45
  if (container === null) {
24
46
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -44,6 +66,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
44
66
  format,
45
67
  tickValues: ticks?.map((t) => t.at),
46
68
  tickCount,
69
+ color,
47
70
  index,
48
71
  }), [
49
72
  id,
@@ -59,6 +82,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
59
82
  format,
60
83
  ticks,
61
84
  tickCount,
85
+ color,
62
86
  index,
63
87
  ]);
64
88
  // A stable per-instance slot (see useSlotKey) keeps this axis in a fixed
@@ -66,13 +90,116 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
66
90
  // re-appending (which would move the first axis behind a later one and
67
91
  // silently rebind the row's default-axis charts).
68
92
  const slot = useSlotKey();
69
- const { registerAxis, unregisterAxis } = row;
93
+ const { registerAxis, unregisterAxis, applyAxisTransform } = row;
70
94
  // Unregister on unmount only (deps are stable, so cleanup never runs early).
71
- useEffect(() => () => unregisterAxis(slot), [unregisterAxis, slot]);
95
+ useEffect(() => () => {
96
+ unregisterAxis(slot);
97
+ // Drop any gutter zoom with the axis. The row keys transforms by axis id
98
+ // (as it keys scales), so an entry left behind would be inherited by a
99
+ // later axis that happens to reuse the id.
100
+ applyAxisTransform(id, IDENTITY_TRANSFORM);
101
+ }, [unregisterAxis, slot, applyAxisTransform, id]);
72
102
  // Register on mount + update in place on every spec change — no reorder.
73
103
  useEffect(() => {
74
104
  registerAxis(slot, spec);
75
105
  }, [registerAxis, slot, spec]);
106
+ // Drag / wheel to zoom **this** axis, double-click to release it back to the
107
+ // row's own fit. Enabled by the container's `panZoom` zoom-y degree of freedom.
108
+ // The gesture writes this axis's entry in `row.axisTransforms`, so only the
109
+ // gutter you grabbed rescales — the sibling axis, and every other row, hold
110
+ // still (see `RowFrame.axisTransforms` for why that needs its own transform).
111
+ const gestures = useAxisGestures({
112
+ axis: 'y',
113
+ // Gated on `<ChartContainer axisPanZoom>` (its `'y'` / `'xy'` values) — the
114
+ // axis opt-in, deliberately independent of the plot's `panZoom`. That is what
115
+ // lets the canonical setup work — an auto-fitting y on a chart whose *x* is
116
+ // panned and zoomed — without either inheriting gestures silently or opting
117
+ // the plot into vertical drags (a different feature: the uniform 2-D
118
+ // transform a scatter or heat map wants).
119
+ drag: container.axisPanZoomY ? 'zoom' : 'none',
120
+ wheel: container.axisPanZoomY,
121
+ onZoom: (factor, pivotPx) => {
122
+ // Read the scale at gesture time, not from the render that built this
123
+ // closure — a wheel notch mid-stream must compose onto what is drawn now.
124
+ if (onBoundsChange !== undefined) {
125
+ // **Controlled**: report the bounds the gesture reached and draw nothing
126
+ // ourselves — `min`/`max` coming back is what moves the axis.
127
+ //
128
+ // Computed by inverting through the scale in **pixel** space, not by
129
+ // affine arithmetic on its domain. That is what makes it correct on every
130
+ // scale kind: `log` and `symlog` are not affine in value space, so
131
+ // zooming their domain numerically drifts the grabbed pixel (visibly, on
132
+ // symlog, whose knee is re-derived from the domain each time) and can
133
+ // overflow to `[0, Infinity]` on a hard log zoom-out.
134
+ //
135
+ // Read from `baseYScales` — the axis's *resolved* scale, before the
136
+ // container's uniform `yTransform` and this axis's own transform. The
137
+ // consumer's `min`/`max` live in that space, so reporting a value read
138
+ // off the visible scale would have the transforms applied to it twice.
139
+ const base = row.baseYScales.get(id);
140
+ if (base === undefined)
141
+ return;
142
+ const [r0, r1] = base.range();
143
+ const lo = Math.min(r0, r1);
144
+ const hi = Math.max(r0, r1);
145
+ // Clamp into the scale's own range, not the gutter box: a
146
+ // `labelPlacement="top"` row reserves a header, so a press up there would
147
+ // otherwise pivot about a value the axis never draws.
148
+ const pivot = Math.max(lo, Math.min(hi, pivotPx));
149
+ // `factor` scales the visible span, so the pixel window scales by its
150
+ // reciprocal about the pivot.
151
+ const at = (px) => +base.invert(pivot + (px - pivot) * factor);
152
+ const next = [at(r0), at(r1)];
153
+ // Orientation is preserved rather than required: `resolveYDomain` keeps
154
+ // an explicit `[max, min]` as a deliberate axis flip, and rejecting
155
+ // descending results would have made adding this callback silently
156
+ // disable the gesture on a flipped axis.
157
+ if (!Number.isFinite(next[0]) || !Number.isFinite(next[1]))
158
+ return;
159
+ if (next[0] === next[1])
160
+ return;
161
+ // `pad` is applied last and to explicit bounds too, so the resolved
162
+ // domain already includes it; handing that back would re-pad it and
163
+ // inflate the axis by `1 + 2·pad` per notch (see `unpadDomain`).
164
+ onBoundsChange(unpadDomain(next, pad, scale));
165
+ return;
166
+ }
167
+ // **Uncontrolled**: hold the zoom ourselves as this axis's own pixel
168
+ // transform. Read at gesture time for the same reason the scale is: two
169
+ // wheel notches inside one frame both see the render-scope value, so the
170
+ // second would compose onto the first's *input* and the notch be lost.
171
+ const own = row.axisTransforms.get(id) ?? IDENTITY_TRANSFORM;
172
+ // Same range clamp as the controlled path: keep the pivot on the scale.
173
+ const visible = row.yScales.get(id);
174
+ const vr = (visible?.range() ?? [0, 0]);
175
+ const pivot = Math.max(Math.min(vr[0], vr[1]), Math.min(Math.max(vr[0], vr[1]), pivotPx));
176
+ // `factor` scales the domain span, so its reciprocal is the pixel-space
177
+ // zoom — the relationship the plot's wheel handler uses.
178
+ const z = 1 / factor;
179
+ const nk = Math.min(MAX_AXIS_K, Math.max(MIN_AXIS_K, own.k * z));
180
+ // Re-derive the zoom the clamp actually allowed, so a gesture held at a
181
+ // limit stops moving the pivot too (rather than sliding the axis).
182
+ const zEff = own.k === 0 ? 1 : nk / own.k;
183
+ applyAxisTransform(id, {
184
+ k: nk,
185
+ // Zoom about the grabbed pixel: p' = pivot + (p − pivot)·z, expanded
186
+ // through the existing transform p = ty + k·base. No pan clamp here —
187
+ // the plot's exists to stop zoomed content sliding off the canvas, and
188
+ // this transform is applied by narrowing the domain, so there is no
189
+ // canvas to leave.
190
+ ty: pivot * (1 - zEff) + own.ty * zEff,
191
+ });
192
+ },
193
+ // Back to auto: `null` tells a controlled consumer to drop its override (the
194
+ // same thing their "manual → auto" toggle does), and an uncontrolled axis
195
+ // drops its own transform.
196
+ onReset: () => {
197
+ if (onBoundsChange !== undefined)
198
+ onBoundsChange(null);
199
+ else
200
+ applyAxisTransform(id, IDENTITY_TRANSFORM);
201
+ },
202
+ });
76
203
  // `hide`: everything above still runs — the axis is registered, so its scale
77
204
  // exists and layers bind to it — and everything below (the gutter chrome)
78
205
  // does not. Placed after the last hook so the early return can't change hook
@@ -133,6 +260,9 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
133
260
  // and the event is dropped. A categorical row labels by slot, matching its
134
261
  // ticks; every other row reads this axis's own tick format.
135
262
  const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
263
+ // See the x strip's: a zoom drag's trailing click is not a click on a value.
264
+ if (event.type === 'click' && gestures.consumeDrag())
265
+ return null;
136
266
  if (!yScale)
137
267
  return null;
138
268
  // Clamp on the **scale's** range, not the box: a row with a
@@ -151,7 +281,14 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
151
281
  : fmt(value),
152
282
  };
153
283
  });
154
- return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse, style: {
284
+ return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ref: gestures.ref, ...gestures.props, ...mouse,
285
+ // See the x strip's: two `onDoubleClick`s meet here (the reset and the
286
+ // consumer's report) and a spread would silently drop one.
287
+ onDoubleClick: (e) => {
288
+ mouse.onDoubleClick?.(e);
289
+ gestures.props.onDoubleClick?.();
290
+ }, style: {
291
+ ...gestures.style,
155
292
  flex: `0 0 ${slotWidth}px`,
156
293
  display: 'flex',
157
294
  justifyContent: side === 'left' ? 'flex-end' : 'flex-start',
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { ContainerContext, RowContext, } from './context.js';
4
- import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
4
+ import { flagChipStyle, flagChipX, axisPillX, axisPillStyle, axisPillConnector, } from './chip.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
6
  /**
7
7
  * User-authored **annotations** — marks you place *on* a chart, in a register
@@ -569,12 +569,19 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
569
569
  }, children: text })), indicator &&
570
570
  (() => {
571
571
  const half = container.theme.font.size / 2 + 1;
572
- return (_jsx("div", { style: {
573
- ...axisPillStyle(container.theme, ann.color),
574
- top: `${Math.max(half, Math.min(row.height - half, y))}px`,
575
- transform: 'translateY(-50%)',
576
- ...axisPillX(row.axisSides.get(axisId) ?? 'left', w),
577
- }, children: fmt ? fmt(value) : String(value) }));
572
+ const side = row.axisSides.get(axisId) ?? 'left';
573
+ const offset = row.axisOffsets.get(axisId) ?? 0;
574
+ const top = `${Math.max(half, Math.min(row.height - half, y))}px`;
575
+ return (_jsxs(_Fragment, { children: [offset > 0 && (_jsx("div", { style: {
576
+ ...axisPillConnector(side, w, offset, ann.color),
577
+ top,
578
+ transform: 'translateY(-50%)',
579
+ } })), _jsx("div", { style: {
580
+ ...axisPillStyle(container.theme, ann.color),
581
+ top,
582
+ transform: 'translateY(-50%)',
583
+ ...axisPillX(side, w, offset),
584
+ }, children: fmt ? fmt(value) : String(value) })] }));
578
585
  })()] }));
579
586
  }
580
587
  /**
package/dist/chip.d.ts CHANGED
@@ -41,14 +41,57 @@ export declare function axisPillStyle(theme: ChartTheme, color: string): CSSProp
41
41
  */
42
42
  export declare function pointerStyle(side: 'left' | 'right', color: string): CSSProperties;
43
43
  /**
44
- * CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
45
- * edge at the plot boundary (`plotWidth`) and let it overflow outward across the
46
- * reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
47
- * sibling axis column (rendered later in the row) so it covers the tick behind
48
- * it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
49
- * cursor's per-series value pills, so both sit identically on the axis.
44
+ * CSS placing a value pill **on an axis gutter** at `side`: anchor its inner
45
+ * edge at that axis's inner edge — the plot boundary (`plotWidth`) plus the
46
+ * axis's own `offset` out into the gutter and let it overflow outward (the
47
+ * plot div doesn't clip), lifted with `zIndex` above the sibling axis columns
48
+ * (rendered later in the row) so it covers the tick behind it. The one placement
49
+ * every on-axis pill goes through the crosshair cursor's value pill, a
50
+ * `<Baseline indicator>`, and {@link YAxisIndicator} — so they cannot drift
51
+ * apart.
52
+ *
53
+ * `offset` is `0` for the innermost axis on a side (the single-axis case, and the
54
+ * behaviour before it existed) and the reserved widths of the axes nearer the
55
+ * plot for one further out — {@link RowFrame.axisOffsets}. Passing it is what
56
+ * puts the pill on the axis whose scale produced the number, rather than on
57
+ * whichever axis happens to sit against the plot. **`YAxisIndicator` does not
58
+ * pass it** and so still lands innermost: it takes an explicit `side` beside its
59
+ * `axis`, and what an offset should mean when those two disagree is unsettled
60
+ * (see `[PND-XHAIRAXIS]` in the charts plan).
61
+ *
62
+ * A pill is deliberately unclipped, so a long formatted value can overflow past
63
+ * the gutter it sits in — further out for an outer-axis pill, which has only its
64
+ * own column left before the container's edge. Sized-to-content and unclipped
65
+ * beats truncating a number, but a very wide readout on a narrow outer axis will
66
+ * spill outside the chart box.
67
+ */
68
+ export declare function axisPillX(side: 'left' | 'right', plotWidth: number, offset?: number): CSSProperties;
69
+ /**
70
+ * The **connector** for a pill placed further out than the innermost axis: a 1px
71
+ * bridge from the plot's `side` edge across `offset` px of gutter to the pill's
72
+ * inner edge, so the in-plot line and its pill read as one object rather than as
73
+ * a value floating in a gutter two columns away. The y-side twin of the
74
+ * crosshair's x-axis time connector, which exists for exactly this reason.
75
+ *
76
+ * The caller positions it vertically (`top` + a `translateY(-50%)`), at the
77
+ * **pill's** centre rather than the raw value's — the two agree except where the
78
+ * pill is clamped inside the row, and a connector attached to the pill is what
79
+ * sells them as one object.
80
+ *
81
+ * In the pill's own colour and above the axis column (`zIndex`, as the pill is),
82
+ * but at **half opacity** — unlike the x-axis time connector, which is solid.
83
+ * The difference is what each one crosses: the time connector runs over an empty
84
+ * strip, while this one runs over *another axis's tick labels* (measured: a
85
+ * connector at a value whose neighbouring axis has a tick at the same height
86
+ * overlaps that label's glyphs). Half opacity keeps the labels legible and reads
87
+ * the bridge as subordinate chrome — the weight the flag cursor's staffs already
88
+ * use for "this line only connects two things I have drawn".
89
+ *
90
+ * Only drawn when `offset > 0`: at offset `0` the pill already touches the plot
91
+ * edge where the line ends, so a connector would be zero-length ink over a tick
92
+ * label for nothing.
50
93
  */
51
- export declare function axisPillX(side: 'left' | 'right', plotWidth: number): CSSProperties;
94
+ export declare function axisPillConnector(side: 'left' | 'right', plotWidth: number, offset: number, color: string): CSSProperties;
52
95
  /**
53
96
  * Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
54
97
  * `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
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;
@@ -1356,15 +1386,24 @@ export type CursorSnapX = 'none' | 'sample' | 'sequence';
1356
1386
  /**
1357
1387
  * One resolved per-series measurement at the cursor — **finished numbers, not
1358
1388
  * 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.
1389
+ * scales against (id + side + gutter offset + colour, so a pill can sit *on
1390
+ * that* axis in *its* ink), and its value already formatted by that axis's
1391
+ * formatter. A cursor slot draws these; it never sees a scale, a format map, or
1392
+ * an axis-side map.
1362
1393
  */
1363
1394
  export interface ResolvedCursorSample {
1364
1395
  readonly px: number;
1365
1396
  readonly py: number;
1366
1397
  readonly axisId: string;
1367
1398
  readonly side: 'left' | 'right';
1399
+ /** Distance in px from the plot's `side` edge to this axis's inner edge —
1400
+ * `0` for the innermost axis, the reserved widths of the axes between it and
1401
+ * the plot otherwise ({@link RowFrame.axisOffsets}). What lets an axis-edge
1402
+ * pill land on the axis that measured the value rather than the innermost. */
1403
+ readonly axisOffset: number;
1404
+ /** This axis's own `<YAxis color>`, or `undefined` for the theme's axis ink
1405
+ * ({@link RowFrame.axisColors}) — so an axis-edge pill matches its axis. */
1406
+ readonly axisColor: string | undefined;
1368
1407
  readonly formatted: string;
1369
1408
  readonly color: string;
1370
1409
  readonly label: string;
@@ -1406,7 +1445,8 @@ export interface ResolvedCursorFrame {
1406
1445
  readonly flags: readonly ResolvedCursorFlag[];
1407
1446
  /**
1408
1447
  * 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.
1448
+ * formatted value, and that axis's placement (side + gutter offset + colour,
1449
+ * as on {@link ResolvedCursorSample}) — or `null` when this row isn't hovered.
1410
1450
  * The free (non-snapping) crosshair reads this; it is resolved here because a
1411
1451
  * slot has no `yScale.invert` to do it itself.
1412
1452
  */
@@ -1414,6 +1454,8 @@ export interface ResolvedCursorFrame {
1414
1454
  readonly py: number;
1415
1455
  readonly formatted: string;
1416
1456
  readonly side: 'left' | 'right';
1457
+ readonly axisOffset: number;
1458
+ readonly axisColor: string | undefined;
1417
1459
  } | null;
1418
1460
  /** The range cursor's **band** under the pointer (bucket-snapped via the
1419
1461
  * declared sequence, else the drag span), as clamped plot pixels; `null`
@@ -1661,6 +1703,11 @@ export interface AxisSpec {
1661
1703
  * target; `undefined` derives the count from the row height (see
1662
1704
  * {@link resolveYTickCount}). Ignored when {@link tickValues} is set. */
1663
1705
  readonly tickCount: number | undefined;
1706
+ /** This axis's own ink (`<YAxis color>`), or `undefined` for the theme's.
1707
+ * Registered — not merely rendered — because the axis-edge chrome the row
1708
+ * and the cursor draw (the crosshair's value pill) has to match the axis it
1709
+ * sits on, and only the registry knows every axis. */
1710
+ readonly color: string | undefined;
1664
1711
  /**
1665
1712
  * Declaration position among the row's children, injected by `ChartRow`. The
1666
1713
  * row sorts axes by this, so the **first declared** axis is the default
@@ -1689,6 +1736,52 @@ export interface RowFrame {
1689
1736
  */
1690
1737
  readonly topInset: number;
1691
1738
  readonly yScales: ReadonlyMap<string, YScale>;
1739
+ /**
1740
+ * Each axis's scale **before** any gesture transform — the domain it resolved
1741
+ * to from `min`/`max`/`pad`/auto-fit, which is the space a controlled
1742
+ * consumer's bounds live in.
1743
+ *
1744
+ * {@link yScales} carries the *visible* scales: the uniform
1745
+ * {@link ContainerFrame.yTransform} and the axis's own
1746
+ * {@link axisTransforms} entry have already narrowed them. A gutter gesture
1747
+ * that reported values read off those and had them fed back as `min`/`max`
1748
+ * would have the transforms applied a second time — the visible domain then
1749
+ * diverges from every value the consumer was told.
1750
+ */
1751
+ readonly baseYScales: ReadonlyMap<string, YScale>;
1752
+ /**
1753
+ * **Per-axis** pixel zoom, keyed by axis id — what a drag on that axis's
1754
+ * gutter produces, layered *under* the container's uniform
1755
+ * {@link ContainerFrame.yTransform}. Identity (`{ k: 1, ty: 0 }`) for any axis
1756
+ * nobody has grabbed, which is every axis until one is.
1757
+ *
1758
+ * The uniform transform exists precisely so a *plot* gesture never has to
1759
+ * answer "which of this row's axes does a vertical drag own?" — see
1760
+ * {@link ContainerFrame.yTransform}. Grabbing one gutter answers it by
1761
+ * construction, and this is where that answer lives. Both are applied the same
1762
+ * way (narrowing the domain to the window the transform makes visible), the
1763
+ * uniform one first, so an axis carrying both reads as an ordinary axis over
1764
+ * the doubly-narrowed window and nothing downstream knows either exists.
1765
+ *
1766
+ * Unlike the uniform transform this is **not** floored at `k ≥ 1`: that floor
1767
+ * stops a plot gesture zooming every axis out past its natural fit into blank
1768
+ * canvas, whereas squashing one axis you deliberately grabbed is the point of
1769
+ * the gesture (and costs nothing — a `k < 1` widens the domain rather than
1770
+ * exposing empty plot).
1771
+ */
1772
+ readonly axisTransforms: ReadonlyMap<string, {
1773
+ readonly k: number;
1774
+ readonly ty: number;
1775
+ }>;
1776
+ /**
1777
+ * Set one axis's {@link axisTransforms} entry — the y counterpart of
1778
+ * {@link ContainerFrame.applyRange}. Passing identity clears it (the
1779
+ * double-click reset).
1780
+ */
1781
+ applyAxisTransform(id: string, next: {
1782
+ k: number;
1783
+ ty: number;
1784
+ }): void;
1692
1785
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
1693
1786
  * against its scale) — used by both the tick labels and the cursor readout, so
1694
1787
  * a value reads identically in both. */
@@ -1705,6 +1798,30 @@ export interface RowFrame {
1705
1798
  /** The side each axis sits on, keyed by id — so an axis-edge overlay (the
1706
1799
  * crosshair value pills) hugs the correct gutter. */
1707
1800
  readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
1801
+ /**
1802
+ * How far out in its gutter each axis sits, keyed by id: the px distance from
1803
+ * the plot's edge to that axis's **inner** edge — `0` for the innermost axis
1804
+ * on a side, the sum of the reserved widths of the axes between it and the
1805
+ * plot for the ones beyond it (the slots of {@link ContainerFrame.leftSlots} /
1806
+ * `rightSlots` it sits behind).
1807
+ *
1808
+ * The companion to {@link axisSides}: side alone puts an axis-edge pill on the
1809
+ * *innermost* axis of that side, which is the wrong axis whenever a side
1810
+ * carries more than one — the pill then reads as a value on a scale that never
1811
+ * measured it. Together they place it on the axis that did.
1812
+ *
1813
+ * Keyed by id, so a **mirrored** id (one scale registered on both sides, or a
1814
+ * duplicate) resolves to the **last declared** instance — the same winner
1815
+ * {@link axisSides} picks, deliberately, so that side and offset always
1816
+ * describe one axis. Picking them by different rules would pair one
1817
+ * instance's gutter with another's column, which is exactly the mis-placement
1818
+ * this map exists to remove.
1819
+ */
1820
+ readonly axisOffsets: ReadonlyMap<string, number>;
1821
+ /** Each axis's own ink (`<YAxis color>`), keyed by id; an axis that sets none
1822
+ * is absent. The other half of matching an axis-edge pill to its axis (see
1823
+ * {@link axisOffsets}) — the pill takes this colour, else the theme's. */
1824
+ readonly axisColors: ReadonlyMap<string, string>;
1708
1825
  /** This row's cursor-mode override, or `undefined` to inherit the container's
1709
1826
  * default ({@link ContainerFrame.cursor}). */
1710
1827
  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
  ? {