@pond-ts/charts 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/YAxis.js CHANGED
@@ -1,10 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useContext, useEffect, useMemo } from 'react';
3
- import { ContainerContext, RowContext } from './context.js';
2
+ import { useContext, useEffect, useMemo, useRef } from 'react';
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, zeroAnchored = false, 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,241 @@ 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
+ // The transform this axis held at the last press — an UNCONTROLLED pan is
107
+ // anchored on it (see `onPan`), the same way the x strip anchors on
108
+ // `container.timeRange`.
109
+ const panStartRef = useRef(null);
110
+ // The base scale at the last press, for a CONTROLLED pan. Snapshotted for the
111
+ // same reason: a controlled consumer writes `min`/`max` back after every
112
+ // move, so `row.baseYScales` on the *next* move already reflects this drag's
113
+ // own effect — reading it fresh would compose the total delta onto a base
114
+ // that has already moved, doubling every step's shift.
115
+ const panBaseRef = useRef(null);
116
+ // Drag pans, wheel zooms **this** axis, double-click releases it back to the
117
+ // row's own fit — the same gesture vocabulary the x strip uses, so a gutter
118
+ // and the canvas agree on what a drag does. Enabled by the container's
119
+ // `panZoom` zoom-y degree of freedom. The gesture writes this axis's entry in
120
+ // `row.axisTransforms`, so only the gutter you grabbed moves — the sibling
121
+ // axis, and every other row, hold still (see `RowFrame.axisTransforms` for
122
+ // why that needs its own transform).
123
+ const gestures = useAxisGestures({
124
+ axis: 'y',
125
+ // Gated on `<ChartContainer axisPanZoom>` (its `'y'` / `'xy'` values) — the
126
+ // axis opt-in, deliberately independent of the plot's `panZoom`. That is what
127
+ // lets the canonical setup work — an auto-fitting y on a chart whose *x* is
128
+ // panned and zoomed — without either inheriting gestures silently or opting
129
+ // the plot into vertical drags (a different feature: the uniform 2-D
130
+ // transform a scatter or heat map wants).
131
+ //
132
+ // `zeroAnchored` drops the drag entirely: a pan slides the whole pixel
133
+ // window, which is exactly the baseline movement it exists to forbid.
134
+ // Wheel stays live — `onZoom` below overrides the pivot to `0`'s own
135
+ // pixel, so a notch narrows the axis around the baseline instead.
136
+ drag: !zeroAnchored && container.axisPanZoomY ? 'pan' : 'none',
137
+ wheel: container.axisPanZoomY,
138
+ // Snapshot the transform (uncontrolled) or the base scale (controlled) at
139
+ // press: the pan is re-derived from one of these on every move (the x
140
+ // strip's own approach), so a long drag can't accumulate rounding — and,
141
+ // for the controlled path, can't compose the total delta onto a base that
142
+ // this same drag's own previous move already shifted.
143
+ onDragStart: () => {
144
+ panStartRef.current = row.axisTransforms.get(id) ?? IDENTITY_TRANSFORM;
145
+ panBaseRef.current = row.baseYScales.get(id) ?? null;
146
+ },
147
+ onPan: (totalDeltaPx) => {
148
+ if (onBoundsChange !== undefined) {
149
+ // **Controlled**: shift the pixel window by the drag, same pixel-space
150
+ // inversion `onZoom` uses so the maths stays correct on `log` and
151
+ // `symlog` — a pan is just a zoom with every pixel shifted by the same
152
+ // amount rather than scaled about a pivot. Read from the *press-time*
153
+ // base, not the live one: `min`/`max` come back through this axis's own
154
+ // props after every move, so the live base already carries however far
155
+ // this drag has gone so far, and re-deriving the total delta against it
156
+ // would double-apply everything already reported.
157
+ const base = panBaseRef.current;
158
+ if (base === null)
159
+ return;
160
+ const [r0, r1] = base.range();
161
+ // `totalDeltaPx` is a SCREEN delta, but `base` is the pre-transform
162
+ // scale, and the two pixel spaces differ by the container's `k` (and
163
+ // this axis's own, if it carries one). Translating `base` by a screen
164
+ // delta therefore pans by `k`× too much whenever the plot's own y zoom
165
+ // is engaged — the grabbed value outruns the cursor. Both transforms
166
+ // are applied in *pixel* space (`narrow` inverts `(px - ty) / k`), so
167
+ // the base↔screen relation is exactly affine for every scale kind and
168
+ // the correction is a plain division — no per-scale special-casing.
169
+ // The division is exact on `log` and `symlog` too; end-to-end
170
+ // tracking on `symlog` still drifts, because `symlogConstant` is
171
+ // derived from the domain and so the knee moves as the domain pans.
172
+ // That is pre-existing and independent of this correction — the
173
+ // sibling zoom test already documents it as "knee drift".
174
+ //
175
+ // `ownK` is 1 in the ordinary controlled path: `applyAxisTransform`
176
+ // is only ever called from the UNCONTROLLED branches, so a controlled
177
+ // axis carries no transform of its own. It is composed here for the
178
+ // one window where that is false — a consumer that pans uncontrolled
179
+ // and then supplies `onBoundsChange`, leaving a stale axis transform
180
+ // until the next reset. No test covers that window.
181
+ //
182
+ // Sibling of the pivot-space bugs in `onZoom`; invisible at
183
+ // `k === 1`, which is every test that does not first zoom the plot.
184
+ const ownK = panStartRef.current?.k ?? 1;
185
+ const pixelK = container.yTransform.k * ownK;
186
+ const deltaBasePx = Number.isFinite(pixelK) && pixelK !== 0
187
+ ? totalDeltaPx / pixelK
188
+ : totalDeltaPx;
189
+ const at = (px) => +base.invert(px - deltaBasePx);
190
+ const next = [at(r0), at(r1)];
191
+ if (!Number.isFinite(next[0]) || !Number.isFinite(next[1]))
192
+ return;
193
+ if (next[0] === next[1])
194
+ return;
195
+ onBoundsChange(unpadDomain(next, pad, scale));
196
+ return;
197
+ }
198
+ // **Uncontrolled**: shift this axis's own pixel transform. Anchored on
199
+ // the press's `panStartRef`, not the live `row.axisTransforms` value —
200
+ // reading that fresh on every move would double-apply everything moved
201
+ // so far this drag.
202
+ const start = panStartRef.current ?? IDENTITY_TRANSFORM;
203
+ applyAxisTransform(id, { k: start.k, ty: start.ty + totalDeltaPx });
204
+ },
205
+ onZoom: (factor, wheelPivotPx) => {
206
+ // `zeroAnchored` substitutes the wheel's own pointer pixel for wherever
207
+ // `0` currently renders — read off the LIVE scale (post pan/zoom, both
208
+ // the container's and this axis's own), which is the pixel a viewer
209
+ // actually sees the baseline sitting at right now. Falls back to the
210
+ // pointer if `0` has no position at all (a log axis, which never
211
+ // admits it — `scaleLog()(0)` is `NaN`) rather than silently doing
212
+ // nothing.
213
+ // Used by the UNCONTROLLED branch below, which composes its transform in
214
+ // live pixel space. The controlled branch re-reads `0` off `baseYScales`
215
+ // instead — see there.
216
+ const zeroPx = row.yScales.get(id)?.(0);
217
+ const pivotPx = zeroAnchored && zeroPx !== undefined && Number.isFinite(zeroPx)
218
+ ? zeroPx
219
+ : wheelPivotPx;
220
+ // Read the scale at gesture time, not from the render that built this
221
+ // closure — a wheel notch mid-stream must compose onto what is drawn now.
222
+ if (onBoundsChange !== undefined) {
223
+ // **Controlled**: report the bounds the gesture reached and draw nothing
224
+ // ourselves — `min`/`max` coming back is what moves the axis.
225
+ //
226
+ // Computed by inverting through the scale in **pixel** space, not by
227
+ // affine arithmetic on its domain. That is what makes it correct on every
228
+ // scale kind: `log` and `symlog` are not affine in value space, so
229
+ // zooming their domain numerically drifts the grabbed pixel (visibly, on
230
+ // symlog, whose knee is re-derived from the domain each time) and can
231
+ // overflow to `[0, Infinity]` on a hard log zoom-out.
232
+ //
233
+ // Read from `baseYScales` — the axis's *resolved* scale, before the
234
+ // container's uniform `yTransform` and this axis's own transform. The
235
+ // consumer's `min`/`max` live in that space, so reporting a value read
236
+ // off the visible scale would have the transforms applied to it twice.
237
+ const base = row.baseYScales.get(id);
238
+ if (base === undefined)
239
+ return;
240
+ // `pivotPx` above was read off the LIVE scale — the pixel a viewer
241
+ // sees, which is right for the uncontrolled branch because it composes
242
+ // a transform in that same space. Here the inversion happens in
243
+ // `base`'s pixel space, and the two differ by the container's own
244
+ // `yTransform`, so `0` has to be re-read there. Mixing them lets the
245
+ // baseline drift a few px per notch whenever the plot's y pan/zoom is
246
+ // active (`panZoom="panZoomY"` / `"panZoomXY"`) — precisely the motion
247
+ // `zeroAnchored` exists to forbid, and invisible without a plot-level
248
+ // y zoom, where the two spaces coincide.
249
+ const baseZeroPx = zeroAnchored ? base(0) : undefined;
250
+ // The fallback — and the ordinary, non-`zeroAnchored` case — is the
251
+ // pointer's own pixel, which is a SCREEN pixel and needs the same
252
+ // change of space. (#678 corrected only the `zeroAnchored` pivot,
253
+ // leaving the plain controlled wheel pivoting about the wrong point
254
+ // under a plot y zoom; the pan in this same file had the mirror
255
+ // defect on its delta. Three faces of one root cause: a screen-space
256
+ // pixel used where `base`'s pixel space is meant.) `ty` matters here
257
+ // because a pivot is an absolute position, not a delta.
258
+ const ownK = panStartRef.current?.k ?? 1;
259
+ const pixelK = container.yTransform.k * ownK;
260
+ const pixelTy = container.yTransform.ty;
261
+ const toBasePx = (screenPx) => Number.isFinite(pixelK) && pixelK !== 0
262
+ ? (screenPx - pixelTy) / pixelK
263
+ : screenPx;
264
+ const controlledPivotPx = baseZeroPx !== undefined && Number.isFinite(baseZeroPx)
265
+ ? baseZeroPx
266
+ : toBasePx(wheelPivotPx);
267
+ const [r0, r1] = base.range();
268
+ const lo = Math.min(r0, r1);
269
+ const hi = Math.max(r0, r1);
270
+ // Clamp into the scale's own range, not the gutter box: a
271
+ // `labelPlacement="top"` row reserves a header, so a press up there would
272
+ // otherwise pivot about a value the axis never draws.
273
+ const pivot = Math.max(lo, Math.min(hi, controlledPivotPx));
274
+ // `factor` scales the visible span, so the pixel window scales by its
275
+ // reciprocal about the pivot.
276
+ const at = (px) => +base.invert(pivot + (px - pivot) * factor);
277
+ const next = [at(r0), at(r1)];
278
+ // Orientation is preserved rather than required: `resolveYDomain` keeps
279
+ // an explicit `[max, min]` as a deliberate axis flip, and rejecting
280
+ // descending results would have made adding this callback silently
281
+ // disable the gesture on a flipped axis.
282
+ if (!Number.isFinite(next[0]) || !Number.isFinite(next[1]))
283
+ return;
284
+ if (next[0] === next[1])
285
+ return;
286
+ // `pad` is applied last and to explicit bounds too, so the resolved
287
+ // domain already includes it; handing that back would re-pad it and
288
+ // inflate the axis by `1 + 2·pad` per notch (see `unpadDomain`).
289
+ onBoundsChange(unpadDomain(next, pad, scale));
290
+ return;
291
+ }
292
+ // **Uncontrolled**: hold the zoom ourselves as this axis's own pixel
293
+ // transform. Read at gesture time for the same reason the scale is: two
294
+ // wheel notches inside one frame both see the render-scope value, so the
295
+ // second would compose onto the first's *input* and the notch be lost.
296
+ const own = row.axisTransforms.get(id) ?? IDENTITY_TRANSFORM;
297
+ // Same range clamp as the controlled path: keep the pivot on the scale.
298
+ const visible = row.yScales.get(id);
299
+ const vr = (visible?.range() ?? [0, 0]);
300
+ const pivot = Math.max(Math.min(vr[0], vr[1]), Math.min(Math.max(vr[0], vr[1]), pivotPx));
301
+ // `factor` scales the domain span, so its reciprocal is the pixel-space
302
+ // zoom — the relationship the plot's wheel handler uses.
303
+ const z = 1 / factor;
304
+ const nk = Math.min(MAX_AXIS_K, Math.max(MIN_AXIS_K, own.k * z));
305
+ // Re-derive the zoom the clamp actually allowed, so a gesture held at a
306
+ // limit stops moving the pivot too (rather than sliding the axis).
307
+ const zEff = own.k === 0 ? 1 : nk / own.k;
308
+ applyAxisTransform(id, {
309
+ k: nk,
310
+ // Zoom about the grabbed pixel: p' = pivot + (p − pivot)·z, expanded
311
+ // through the existing transform p = ty + k·base. No pan clamp here —
312
+ // the plot's exists to stop zoomed content sliding off the canvas, and
313
+ // this transform is applied by narrowing the domain, so there is no
314
+ // canvas to leave.
315
+ ty: pivot * (1 - zEff) + own.ty * zEff,
316
+ });
317
+ },
318
+ // Back to auto: `null` tells a controlled consumer to drop its override (the
319
+ // same thing their "manual → auto" toggle does), and an uncontrolled axis
320
+ // drops its own transform.
321
+ onReset: () => {
322
+ if (onBoundsChange !== undefined)
323
+ onBoundsChange(null);
324
+ else
325
+ applyAxisTransform(id, IDENTITY_TRANSFORM);
326
+ },
327
+ });
76
328
  // `hide`: everything above still runs — the axis is registered, so its scale
77
329
  // exists and layers bind to it — and everything below (the gutter chrome)
78
330
  // does not. Placed after the last hook so the early return can't change hook
@@ -133,6 +385,9 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
133
385
  // and the event is dropped. A categorical row labels by slot, matching its
134
386
  // ticks; every other row reads this axis's own tick format.
135
387
  const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
388
+ // See the x strip's: a zoom drag's trailing click is not a click on a value.
389
+ if (event.type === 'click' && gestures.consumeDrag())
390
+ return null;
136
391
  if (!yScale)
137
392
  return null;
138
393
  // Clamp on the **scale's** range, not the box: a row with a
@@ -151,7 +406,14 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
151
406
  : fmt(value),
152
407
  };
153
408
  });
154
- return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse, style: {
409
+ return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ref: gestures.ref, ...gestures.props, ...mouse,
410
+ // See the x strip's: two `onDoubleClick`s meet here (the reset and the
411
+ // consumer's report) and a spread would silently drop one.
412
+ onDoubleClick: (e) => {
413
+ mouse.onDoubleClick?.(e);
414
+ gestures.props.onDoubleClick?.();
415
+ }, style: {
416
+ ...gestures.style,
155
417
  flex: `0 0 ${slotWidth}px`,
156
418
  display: 'flex',
157
419
  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/bars.d.ts CHANGED
@@ -38,12 +38,25 @@ export declare function barExtent(cs: BarSeries): [number, number] | null;
38
38
  * - When the domain sits entirely below `0`: the bars hang from the **axis top**
39
39
  * (`0` clamped down into the domain) — the symmetric case.
40
40
  *
41
- * I.e. `0` clamped into `[floor, top]`. The domain bounds come from the plain
42
- * `(value) => pixel` scale the row hands `draw`/`hitTest`; the runtime object is
43
- * a d3 `ScaleLinear` carrying `.domain()`, read through a localized shape rather
44
- * than widening the contract to d3-scale (same approach as `AreaChart`).
41
+ * I.e. `0` clamped into `[floor, top]`. The domain bounds come from `baseScale`
42
+ * when the caller has one the axis's *declared* domain, resolved from
43
+ * `min`/`max`/auto-fit before any pan/zoom pixel transform narrows it else
44
+ * from `yScale` itself. The runtime object is a d3 `ScaleLinear` carrying
45
+ * `.domain()`, read through a localized shape rather than widening the
46
+ * contract to d3-scale (same approach as `AreaChart`).
47
+ *
48
+ * **Reading `yScale` alone is the bug this parameter exists to avoid.** `yScale`
49
+ * is the *viewport* onto the axis — a gutter drag or wheel notch slides it
50
+ * without moving any bar's actual value. But a bare `yScale.domain()` read
51
+ * can't tell "the axis was deliberately configured to exclude zero" apart
52
+ * from "the current pan happens to have scrolled zero out of view" — so once
53
+ * a pan pushed the floor above zero, every bar's rendered top silently became
54
+ * `yScale(baseline)` instead of `yScale(value)`, reading as a bar's value
55
+ * changing under a gesture that never touched the data. `baseScale` is the
56
+ * declared domain, immune to that transform, so a transient pan can never
57
+ * relocate the baseline — only an author's own `<YAxis min>` can.
45
58
  */
46
- export declare function resolveBarBaseline(yScale: Scale): number;
59
+ export declare function resolveBarBaseline(yScale: Scale, baseScale?: Scale): number;
47
60
  /**
48
61
  * The pixel rect of bar `i` — `[x0, x1, yTop, yBottom]`, with `x0 <= x1` and
49
62
  * `yTop <= yBottom` — or `null` for a gap (non-finite value). The x-span comes
@@ -442,7 +455,7 @@ export declare function stackBinExtent(ss: StackedBarSeries): [number, number] |
442
455
  * `0` into the domain, so this returns exactly `0` and the geometry is
443
456
  * unchanged.
444
457
  */
445
- export declare function stackBase(orientation: Orientation, xScale: Scale, yScale: Scale): number;
458
+ export declare function stackBase(orientation: Orientation, xScale: Scale, yScale: Scale, baseYScale?: Scale): number;
446
459
  /**
447
460
  * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
448
461
  * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
@@ -475,7 +488,7 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
475
488
  *
476
489
  * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
477
490
  */
478
- export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: readonly StackMark[], hover: readonly StackMark[], banding?: BandLadder, spans?: readonly SpanSelection[]): void;
491
+ export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: readonly StackMark[], hover: readonly StackMark[], banding?: BandLadder, spans?: readonly SpanSelection[], baseYScale?: Scale): void;
479
492
  /**
480
493
  * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
481
494
  * segment whose rect contains the point, or `null`. The geometry is
@@ -491,5 +504,5 @@ export declare function stackAt(ss: StackedBarSeries, px: number, py: number, or
491
504
  /** Must match the draw's cap ([PND-BARWIDTH]) — this function's whole
492
505
  * contract is that its rect is the drawn rect, so a cap applied to one and
493
506
  * not the other silently drifts the hit target off the ink. */
494
- maxSpanPx?: number): [bin: number, group: number, begin: number, name: string, value: number] | null;
507
+ maxSpanPx?: number, baseYScale?: Scale): [bin: number, group: number, begin: number, name: string, value: number] | null;
495
508
  //# sourceMappingURL=bars.d.ts.map
package/dist/bars.js CHANGED
@@ -49,13 +49,26 @@ export function barExtent(cs) {
49
49
  * - When the domain sits entirely below `0`: the bars hang from the **axis top**
50
50
  * (`0` clamped down into the domain) — the symmetric case.
51
51
  *
52
- * I.e. `0` clamped into `[floor, top]`. The domain bounds come from the plain
53
- * `(value) => pixel` scale the row hands `draw`/`hitTest`; the runtime object is
54
- * a d3 `ScaleLinear` carrying `.domain()`, read through a localized shape rather
55
- * than widening the contract to d3-scale (same approach as `AreaChart`).
52
+ * I.e. `0` clamped into `[floor, top]`. The domain bounds come from `baseScale`
53
+ * when the caller has one the axis's *declared* domain, resolved from
54
+ * `min`/`max`/auto-fit before any pan/zoom pixel transform narrows it else
55
+ * from `yScale` itself. The runtime object is a d3 `ScaleLinear` carrying
56
+ * `.domain()`, read through a localized shape rather than widening the
57
+ * contract to d3-scale (same approach as `AreaChart`).
58
+ *
59
+ * **Reading `yScale` alone is the bug this parameter exists to avoid.** `yScale`
60
+ * is the *viewport* onto the axis — a gutter drag or wheel notch slides it
61
+ * without moving any bar's actual value. But a bare `yScale.domain()` read
62
+ * can't tell "the axis was deliberately configured to exclude zero" apart
63
+ * from "the current pan happens to have scrolled zero out of view" — so once
64
+ * a pan pushed the floor above zero, every bar's rendered top silently became
65
+ * `yScale(baseline)` instead of `yScale(value)`, reading as a bar's value
66
+ * changing under a gesture that never touched the data. `baseScale` is the
67
+ * declared domain, immune to that transform, so a transient pan can never
68
+ * relocate the baseline — only an author's own `<YAxis min>` can.
56
69
  */
57
- export function resolveBarBaseline(yScale) {
58
- const d = yScale.domain?.();
70
+ export function resolveBarBaseline(yScale, baseScale) {
71
+ const d = (baseScale ?? yScale).domain?.();
59
72
  if (!d || d.length === 0)
60
73
  return 0;
61
74
  const floor = Math.min(d[0], d[d.length - 1]);
@@ -759,8 +772,14 @@ export function stackBinExtent(ss) {
759
772
  * `0` into the domain, so this returns exactly `0` and the geometry is
760
773
  * unchanged.
761
774
  */
762
- export function stackBase(orientation, xScale, yScale) {
763
- return resolveBarBaseline(orientation === 'vertical' ? yScale : xScale);
775
+ export function stackBase(orientation, xScale, yScale,
776
+ // The y axis's declared (pre-pan/zoom) scale — see `resolveBarBaseline`.
777
+ // Vertical only: a horizontal stack's baseline lives on `xScale`, and x has
778
+ // no equivalent "declared, pre-transform" scale to read yet ([PND-XBASE]).
779
+ baseYScale) {
780
+ return orientation === 'vertical'
781
+ ? resolveBarBaseline(yScale, baseYScale)
782
+ : resolveBarBaseline(xScale);
764
783
  }
765
784
  /**
766
785
  * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
@@ -833,9 +852,12 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
833
852
  // value against `y` when present, and its group against `rows` when present
834
853
  // (the group is this layer's label channel, so `rows` stays testable per
835
854
  // segment where `drawBars`' constant label lets the component resolve it).
836
- spans = NO_SPANS) {
855
+ spans = NO_SPANS,
856
+ // The y axis's declared (pre-pan/zoom) scale — see `resolveBarBaseline` /
857
+ // `stackBase`.
858
+ baseYScale) {
837
859
  const G = ss.groups.length;
838
- const base = stackBase(orientation, xScale, yScale);
860
+ const base = stackBase(orientation, xScale, yScale, baseYScale);
839
861
  // Threshold banding applies to a **plain** bar only. `G === 1` is exactly the
840
862
  // categorical / horizontal single-value bar (`categoryStack` builds a
841
863
  // one-group series); a genuine multi-group stack has no defined banding —
@@ -1015,9 +1037,13 @@ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanP
1015
1037
  /** Must match the draw's cap ([PND-BARWIDTH]) — this function's whole
1016
1038
  * contract is that its rect is the drawn rect, so a cap applied to one and
1017
1039
  * not the other silently drifts the hit target off the ink. */
1018
- maxSpanPx) {
1040
+ maxSpanPx,
1041
+ // The y axis's declared (pre-pan/zoom) scale — see `resolveBarBaseline` /
1042
+ // `stackBase`. Kept in sync with `drawStacks`' own so a hit rect never
1043
+ // drifts from the drawn one under a live gutter pan.
1044
+ baseYScale) {
1019
1045
  const G = ss.groups.length;
1020
- const base = stackBase(orientation, xScale, yScale);
1046
+ const base = stackBase(orientation, xScale, yScale, baseYScale);
1021
1047
  for (let b = 0; b < ss.length; b += 1) {
1022
1048
  // The same two accumulators `drawStacks` keeps — they must agree exactly or
1023
1049
  // the hit rect drifts from the drawn one.
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