@pond-ts/charts 0.63.0 → 0.65.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/API.md CHANGED
@@ -164,7 +164,7 @@ Deliberately small — the ordering-based slice of the algebra, no calendar ops
164
164
  | Operation schemas | `RollingSchema`, `RollingAlignment`, `AlignSchema`, `DiffSchema`, `SmoothSchema`, `SmoothMethod`, `FillStrategy`, `FillMapping` | `packages/core/src/schema/index.ts` |
165
165
  | Column/data kinds | `Column`, `KeyColumn`, `ColumnKind`, `ScalarKind`, `ScalarValue`, `ColumnValue`, `ArrayValue`, `ValidityBitmap` | `packages/core/src/columnar/` |
166
166
  | JSON wire format | `JsonRowFormat`, `JsonRowForSchema`, `JsonObjectRowForSchema`, `JsonValueForKind`, `JsonTimestampInput`, `JsonTimeRangeInput`, `JsonIntervalInput` | `packages/core/src/schema/index.ts` |
167
- | Temporal utility | `TemporalLike`, `DurationInput`, `CalendarUnit`, `TimeZoneOptions`, `KeyLike`, `BatchSampleStrategy` | `packages/core/src/core/`, `packages/core/src/sequence/` |
167
+ | Temporal utility | `TemporalLike`, `DurationInput`, `CalendarUnit`, `TimeZoneOptions`, `KeyLike`, `BatchSampleStrategy`, `SequenceSample`, `SequenceCoverage` | `packages/core/src/core/`, `packages/core/src/sequence/` |
168
168
 
169
169
  The `pond-ts/types` subpath re-exports the schema-as-contract types with zero
170
170
  runtime (`packages/core/src/schema/public.ts`).
@@ -432,19 +432,32 @@ Series shapes (same file): `ChartSeries`, `BandSeries`, `BoxSeries`,
432
432
 
433
433
  All are pure `(series, options) → TimeSeries` appending output columns;
434
434
  `column` defaults to `'close'`; periods are bar counts; warm-up is
435
- length-preserving (`undefined` head rows).
436
-
437
- | Study | Output column(s) | Options gist | Source |
438
- | --------------------------- | ----------------------------------- | --------------------------------------------------- | -------------------------------------------------- |
439
- | `sma` | `sma` | `{ period, column?, output? }` | `packages/financial/src/studies/moving-average.ts` |
440
- | `ema` | `ema` | `{ period, column?, output? }` (α = 2/(period+1)) | `packages/financial/src/studies/moving-average.ts` |
441
- | `bollinger` | `bbMiddle`, `bbUpper`, `bbLower` | `{ period, stdDev?, column?, prefix? }` | `packages/financial/src/studies/bollinger.ts` |
442
- | `envelope` | `envMiddle`, `envUpper`, `envLower` | `{ period, percent?, maType?, column?, prefix? }` | `packages/financial/src/studies/envelope.ts` |
443
- | `rollingStdev` | `stdev` | `{ period, column?, output? }` (population, ddof=0) | `packages/financial/src/studies/rolling-stat.ts` |
444
- | `rollingMin` / `rollingMax` | `min` / `max` | `{ period, column?, output? }` (Donchian edges) | `packages/financial/src/studies/rolling-stat.ts` |
445
- | `rollingPercentile` | `p{q}` (e.g. `p90`) | `{ period, q, column?, output? }` | `packages/financial/src/studies/rolling-stat.ts` |
446
- | `zScore` | `zscore` | `{ period, column?, output? }` | `packages/financial/src/studies/z-score.ts` |
447
- | `percentChange` | `pctChange` | `{ periods?, column?, output? }` | `packages/financial/src/studies/percent-change.ts` |
435
+ length-preserving (`undefined` head rows). A **multi-input** study (`atr`)
436
+ names each input instead of taking one `column`, each defaulting to its
437
+ `DEFAULT_OHLCV` name the same "never hard-code a column" rule applied
438
+ per input.
439
+
440
+ | Study | Output column(s) | Options gist | Source |
441
+ | --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------- |
442
+ | `sma` | `sma` | `{ period, column?, output? }` | `packages/financial/src/studies/moving-average.ts` |
443
+ | `ema` | `ema` | `{ period, column?, output? }` (α = 2/(period+1)) | `packages/financial/src/studies/moving-average.ts` |
444
+ | `bollinger` | `bbMiddle`, `bbUpper`, `bbLower` | `{ period, stdDev?, column?, prefix? }` | `packages/financial/src/studies/bollinger.ts` |
445
+ | `envelope` | `envMiddle`, `envUpper`, `envLower` | `{ period, percent?, maType?, column?, prefix? }` | `packages/financial/src/studies/envelope.ts` |
446
+ | `rollingStdev` | `stdev` | `{ period, column?, output? }` (population, ddof=0) | `packages/financial/src/studies/rolling-stat.ts` |
447
+ | `rollingMin` / `rollingMax` | `min` / `max` | `{ period, column?, output? }` (one edge; `donchian` gives the channel) | `packages/financial/src/studies/rolling-stat.ts` |
448
+ | `rollingPercentile` | `p{q}` (e.g. `p90`) | `{ period, q, column?, output? }` | `packages/financial/src/studies/rolling-stat.ts` |
449
+ | `zScore` | `zscore` | `{ period, column?, output? }` | `packages/financial/src/studies/z-score.ts` |
450
+ | `percentChange` | `pctChange` | `{ periods?, column?, output? }` (= ROC; TA-Lib-verified) | `packages/financial/src/studies/percent-change.ts` |
451
+ | `rsi` | `rsi` | `{ period?, column?, output? }` (Wilder, default 14) | `packages/financial/src/studies/rsi.ts` |
452
+ | `macd` | `macdLine`, `macdSignal`, `macdHist` | `{ fastPeriod?, slowPeriod?, signalPeriod?, column?, prefix? }` (12/26/9) | `packages/financial/src/studies/macd.ts` |
453
+ | `atr` | `atr` | `{ period?, high?, low?, close?, output? }` (Wilder, default 14) | `packages/financial/src/studies/atr.ts` |
454
+ | `momentum` | `momentum` | `{ period?, column?, output? }` (`v − v[−period]`, default 10) | `packages/financial/src/studies/momentum.ts` |
455
+ | `historicalVolatility` | `hv` | `{ period?, annualize?, column?, output? }` (σ of log returns, ×√252) | `packages/financial/src/studies/volatility.ts` |
456
+ | `stochastic` | `stochK`, `stochD` | `{ kPeriod?, slowing?, dPeriod?, high?, low?, close?, prefix? }` (14/3/3; `slowing: 1` = fast) | `packages/financial/src/studies/stochastic.ts` |
457
+ | `williamsR` | `williamsR` | `{ period?, high?, low?, close?, output? }` (default 14, bounded −100..0) | `packages/financial/src/studies/williams-r.ts` |
458
+ | `donchian` | `dcUpper`, `dcLower`, `dcMiddle` | `{ period?, high?, low?, prefix? }` (default 20) | `packages/financial/src/studies/donchian.ts` |
459
+ | `obv` | `obv` | `{ close?, volume?, output? }` (no period; TA-Lib seed `volume[0]`) | `packages/financial/src/studies/obv.ts` |
460
+ | `vwap` | `vwap` | `{ period, high?, low?, close?, volume?, output? }` (rolling, typical px) | `packages/financial/src/studies/vwap.ts` |
448
461
 
449
462
  Adding a study? Follow `packages/financial/src/studies/README.md` (uniform
450
463
  shape + pandas oracle case + fluent method are all REQUIRED).
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.63.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.65.0...HEAD
12
+ [0.65.0]: https://github.com/pond-ts/pond/compare/v0.64.0...v0.65.0
13
+ [0.64.0]: https://github.com/pond-ts/pond/compare/v0.63.0...v0.64.0
12
14
  [0.63.0]: https://github.com/pond-ts/pond/compare/v0.62.0...v0.63.0
13
15
  [0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
14
16
  [0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
@@ -66,6 +68,287 @@ include new features and type-level changes; patch bumps are strictly additive.
66
68
 
67
69
  ## [Unreleased]
68
70
 
71
+ ## [0.65.0] — 2026-09-06
72
+
73
+ ### Added
74
+
75
+ - `@pond-ts/financial`: **`stochastic(...)`**, **`williamsR(...)`** and
76
+ **`donchian(...)`** — the three range-position studies, built on one new
77
+ kernel (`highestLowestValues`: the trailing highest high / lowest low in a
78
+ single `rolling` scan) plus fluent `.stochastic()` / `.williamsR()` /
79
+ `.donchian()`. All name their bar inputs per column (`high` / `low` /
80
+ `close`, defaulting to `DEFAULT_OHLCV`), the `atr` shape.
81
+
82
+ **`stochastic`** — `{ kPeriod = 14, slowing = 3, dPeriod = 3, prefix =
83
+ 'stoch' }` appending `stochK` / `stochD`: the slow stochastic, with
84
+ `slowing: 1` giving the fast one (TA-Lib's `STOCHF`) from the same
85
+ function. Values match TA-Lib's `STOCH`/`STOCHF` to `5.7e-14` on every bar
86
+ TA-Lib emits, `%D` with an identical warm-up. Two deliberate deltas,
87
+ both documented on the study: **`%K` starts when it can** (bar 15 at the
88
+ defaults) where TA-Lib masks it back to `%D`'s first bar (17), discarding
89
+ `dPeriod − 1` real values — the `macd` precedent; and **a flat window
90
+ (`HH === LL`) is `undefined`** where TA-Lib reports `0`, which is also its
91
+ value for "close at the very bottom of a real range" — the `rsi` precedent.
92
+ The smoothing uses a new raw-array kernel, `rollingMeanValues`, that waits
93
+ for `slowing` _values_ rather than rows: a scratch-column SMA would have
94
+ put a "3-bar" `%K` on bar 13 with one value in it.
95
+
96
+ **`williamsR`** — `{ period = 14, output = 'williamsR' }`, bounded
97
+ `−100..0`. Matches TA-Lib's `WILLR` bar-for-bar to `1.4e-14` with an
98
+ identical warm-up; the only delta is the same flat-window `undefined`.
99
+ It is fast `%K − 100`, computed on the same kernel, and a test pins that
100
+ identity against `stochastic({ slowing: 1 })`.
101
+
102
+ **`donchian`** — `{ period = 20, prefix = 'dc' }` appending `dcUpper` /
103
+ `dcLower` / `dcMiddle`. `upper` is exactly `rollingMax(high)` and `lower`
104
+ exactly `rollingMin(low)` (pinned), in one scan plus the midpoint; pandas
105
+ oracle only (TA-Lib has no Donchian). Scales linearly with price, like
106
+ `atr`; a missing `high`/`low` is skipped (core's reducer policy), not
107
+ propagated.
108
+
109
+ - `@pond-ts/financial`: **`obv(...)`** and **`vwap(...)`** — the first two
110
+ **volume** studies, plus fluent `.obv()` / `.vwap()`. Both name their inputs
111
+ per column (`close` / `volume`, and `high` / `low` for VWAP), each
112
+ defaulting to its `DEFAULT_OHLCV` name, the rule `atr` established.
113
+
114
+ **`obv`** is Granville's On-Balance Volume — the running total of each bar's
115
+ volume signed by its close change — and is the first study with **no
116
+ `period`**: it is read for its shape, not its level, so there is nothing to
117
+ size a window over. It is **TA-Lib's OBV exactly**, including the seed
118
+ (`OBV[0] = volume[0]`) and an unchanged close adding nothing; the oracle
119
+ asserts equality, with an identical (empty) warm-up mask. On **interior
120
+ gaps** it deliberately departs from TA-Lib: a running sum has no local
121
+ answer for a missing term, so a missing close or volume leaves the level
122
+ `undefined` from that bar on (the same asymmetry the Wilder recursion has —
123
+ a leading gap shifts the seed, an interior one propagates). TA-Lib has no
124
+ gap semantics; measured on a seven-bar series it is 400 out of level at
125
+ the gap bar and 100 out from then on, presented as a value.
126
+
127
+ **`vwap`** is a **rolling** Volume-Weighted Average Price:
128
+ `Σ typicalPrice · volume / Σ volume` over `period` bars, typical price
129
+ `(high + low + close) / 3`. There is no TA-Lib VWAP and no single
130
+ definition, so this is a documented design choice: the rolling form fits
131
+ the package's count-window shape, and the intraday desk's _anchored_ VWAP
132
+ is not a special case of it (a count window emits only once it spans
133
+ `period` rows) — that one needs a session reset and is deferred to the
134
+ session-anchored phase. `period` is **required** (no conventional length to
135
+ default to). A window with no volume is `undefined`; a bar missing any
136
+ input is dropped from **both** sums so the ratio is never biased. Verified
137
+ against a pandas replication on a fixture whose volume spikes make a
138
+ dropped weighting visible (≥ 1.24 price units from the plain mean).
139
+
140
+ The oracle fixture gains a `volumes` input, and four kernels land under
141
+ them for the studies that follow: `cumulativeValues` (A/D line, PVT),
142
+ `signedVolumeValues`, `typicalPriceValues` (CCI, MFI, Keltner) and
143
+ `rollingWeightedMeanValues` (VWMA).
144
+
145
+ - `@pond-ts/financial`: **`atr(...)`** — Wilder's Average True Range, the third
146
+ [PND-STUDY] named indicator, plus a fluent `.atr()`. `{ period = 14,
147
+ high = 'high', low = 'low', close = 'close', output = 'atr' }`. Verified
148
+ against **TA-Lib** bar-for-bar in the oracle — exact agreement at both
149
+ periods tested, with identical warm-up.
150
+
151
+ **The first multi-input study.** Where every study so far took a single
152
+ `column`, ATR names each of its three inputs, each defaulting to its
153
+ `DEFAULT_OHLCV` name — the same "never hard-code a column" rule applied per
154
+ input rather than a new mechanism. Because the inputs are columns of one
155
+ series they are aligned by construction, so the mismatched-length error
156
+ array-based libraries must check for at every call is unrepresentable here.
157
+
158
+ Warm-up is `period` rows, not `period − 1`: true range needs a previous
159
+ close, so bar 0 has none.
160
+
161
+ - `@pond-ts/financial`: **`macd(...)`** — Moving Average Convergence/Divergence,
162
+ the second [PND-STUDY] named indicator. `{ fastPeriod = 12, slowPeriod = 26,
163
+ signalPeriod = 9, column = 'close', prefix = 'macd' }`, appending
164
+ `macdLine` / `macdSignal` / `macdHist`, plus a fluent `.macd()`.
165
+
166
+ Two deliberate deltas from TA-Lib, both documented on the study:
167
+ **each column warms up when it can** (the line at bar 25, signal and
168
+ histogram at 33) where TA-Lib masks all three back to 33, keeping eight real
169
+ values it discards; and the EMAs are **pond's own** (`α = 2/(span+1)` seeded
170
+ on the first sample — what `ema()` already ships) rather than TA-Lib's
171
+ SMA-seeded ones. The second is a _seed_ difference, so it decays: measured
172
+ at 3.8% of the line's magnitude at the first shared bar, 0.089% by bar 79.
173
+ Seeding TA-Lib's way would make `macd()` disagree with
174
+ `ema(fast) − ema(slow)` inside this package, which is the worse surprise —
175
+ and is pinned by a test.
176
+
177
+ - `@pond-ts/financial`: **`rsi(...)`** — Wilder's Relative Strength Index, the
178
+ first of the [PND-STUDY] named indicators. `{ period = 14, column = 'close',
179
+ output = 'rsi' }`, length-preserving warm-up (`period` rows, not `period − 1`
180
+ — RSI averages _differences_, so it needs one extra bar), and a fluent
181
+ `.rsi()`. Verified against **TA-Lib** bar-for-bar in the oracle fixture,
182
+ agreeing to `1.4e-14`.
183
+
184
+ Worth knowing which RSI this is: the gain/loss averages are seeded on the
185
+ arithmetic mean of the first `period` differences, then carried by Wilder's
186
+ recursion — TA-Lib's definition and Wilder's original. Implementations that
187
+ instead smooth with a plain first-sample-seeded EMA of the same `α = 1/period`
188
+ produce a **materially different** series: on this package's own oracle input
189
+ the two sit up to **7.03 RSI points** apart, and are still `0.15` apart 65
190
+ bars later. That is enough to move a reading across the conventional 70/30
191
+ thresholds, so it is a definition choice rather than a rounding detail.
192
+
193
+ - `@pond-ts/financial`: **`momentum(...)`** — the absolute `period`-bar
194
+ difference, `value − value[i − period]`. `{ period = 10, column = 'close',
195
+ output = 'momentum' }`, warm-up `period` rows, plus a fluent `.momentum()`.
196
+ Verified against **TA-Lib's `MOM`** bar-for-bar in the oracle: exact
197
+ agreement with identical warm-up masks. The additive companion to
198
+ `percentChange`'s ratio; it is in the price's units and scales with them
199
+ (pinned by a property test).
200
+
201
+ - `@pond-ts/financial`: **`historicalVolatility(...)`** — the standard
202
+ deviation of log returns over `period` bars, annualised.
203
+ `{ period = 20, annualize = 252, column = 'close', output = 'hv' }`, plus a
204
+ fluent `.historicalVolatility()`. Warm-up is `period` rows, not
205
+ `period − 1` — `period` returns need `period + 1` prices, the same
206
+ off-by-one RSI and ATR have.
207
+
208
+ TA-Lib has no HV to arbitrate the conventions, so they are pinned on the
209
+ study and in the pandas oracle (`np.log(s).diff().rolling(n).std(ddof=0)
210
+ · √annualize`): **population σ** (`ddof = 0`, the package convention shared
211
+ with Bollinger / `rollingStdev` / `zScore`; a sample σ is
212
+ `√(n/(n−1))` larger — 2.6% at 20); **log returns**, not simple; annualised
213
+ by `√annualize` with `252` the default for daily bars and an **option**
214
+ rather than a hidden constant (`1` for per-bar σ, `252·390` for one-minute
215
+ bars); and a **decimal** (`0.18` = 18%), the form volatility is consumed
216
+ in. Scale-invariant, pinned by a property test. A non-positive price has
217
+ no log return: both returns touching it are missing (explicitly, since
218
+ `ln(−4/−5)` is a finite non-answer). A leading gap shifts the start so the
219
+ first window still covers `period` real returns, matching pandas'
220
+ `min_periods` and the Wilder-family studies — the σ is computed by calling
221
+ the range-exact kernel on the returns array directly, because a scratch
222
+ column through `rollingValues` counts rows and would emit one bar early
223
+ over one return too few.
224
+
225
+ - `@pond-ts/financial`: **`percentChange` is ROC** — TA-Lib's `ROC` is the same
226
+ `(price / prevPrice − 1) × 100`, and the oracle now cross-checks the study
227
+ against it: exact agreement (`0.0`) with identical warm-up masks at both
228
+ periods. Documented on the study and in API.md; there is deliberately no
229
+ separate `roc` study.
230
+
231
+ ## [0.64.0] — 2026-08-28
232
+
233
+ ### Added
234
+
235
+ - `pond-ts`: **`Sequence.bounded(range, { coverage })`** — chooses whether a
236
+ range selects buckets by their sample point (`'sample'`, the default and the
237
+ existing behaviour) or by their extent (`'overlap'`, every bucket the range
238
+ touches, including the one containing `range.begin()`). `aggregate` realizes
239
+ its grid with `'overlap'`; alignment keeps `'sample'`. As a side benefit
240
+ `bounded({ start: t, end: t }, { coverage: 'overlap' })` is a flooring
241
+ primitive — it returns exactly the bucket containing `t` — so consumers no
242
+ longer have to re-derive pond's calendar anchoring by hand to floor a
243
+ timestamp. New exported type `SequenceCoverage`.
244
+
245
+ - `@pond-ts/charts`: **`<YAxis zeroAnchored>`** — pins a y-axis's zoom to the
246
+ value-`0` gridline instead of the pointer, and drops drag-to-pan entirely.
247
+ For a bar chart, whose baseline must never move: a bar rests on `0` (at the
248
+ plot floor for all-positive data, or somewhere mid-plot for data that
249
+ straddles positive and negative), and an ordinary pointer-pivoted gesture
250
+ would slide that baseline around the plot as you scroll. With
251
+ `zeroAnchored`, every wheel notch scales the axis around wherever `0`
252
+ currently renders, so the baseline holds however far in or out you zoom.
253
+ One qualification: the pivot is clamped into the axis's pixel range, so once
254
+ `0` has been scrolled off the plot the zoom pivots about the nearer edge and
255
+ the baseline does creep — benign for bars, where that clamp coincides with
256
+ the declared floor, but not an unconditional guarantee. Still gated by the
257
+ container's own `axisPanZoom` opt-in — this changes what the gesture does,
258
+ not whether it's on.
259
+
260
+ ### Changed
261
+
262
+ - `pond-ts`: **`aggregate` now emits the bucket containing the first event**,
263
+ where before it started at the first grid boundary _at or after_ it — so
264
+ every event between the two aggregated into nothing, silently. 60 daily bars
265
+ rolled to a calendar month came back holding 38 of them: no error, no
266
+ warning, no `undefined`, just a well-formed series with a third of the input
267
+ gone. It contradicted `aggregate`'s own documented membership rule ("point
268
+ events contribute to the bucket containing their timestamp") and was
269
+ asymmetric — the trailing partial bucket _was_ emitted. **This changes
270
+ results for any caller whose first event did not land exactly on a grid
271
+ boundary**: expect an extra leading bucket, and sums/counts that now account
272
+ for the previously-dropped events. Callers who worked around it by passing a
273
+ pre-floored explicit `range` are unaffected — the floor is now what the
274
+ default does. **Note what `range` does and does not bound:** it selects the
275
+ grid, not the event scan, so the leading bucket is filled from every event it
276
+ contains — including events _before_ `range.begin()` when the series has
277
+ them. On a window narrower than the series (a chart viewport, say) the edge
278
+ bucket is therefore a complete bucket rather than a clipped one, which is
279
+ what keeps bar heights stable under a pan; if you need the window to bound
280
+ the events too, narrow the series rather than the grid. `align(...)` and
281
+ `materialize(...)` are deliberately unchanged: their grids are _sampled_,
282
+ and the sample point becomes the output key. A pre-realized
283
+ `BoundedSequence` argument is an explicit bucket list, used as given and
284
+ unaffected by any of this. Reported by Tidal (#672).
285
+
286
+ - `@pond-ts/charts`: **y gutter drag now pans instead of zooming** — matching
287
+ the x strip's own gesture (drag pans, wheel zooms) instead of the
288
+ drag-to-zoom behavior it shipped with in `axisPanZoom="y"`/`"xy"`. Wheel
289
+ still zooms the grabbed axis about the pointer, and double-click still
290
+ releases it back to its fit; only the drag's own effect changed.
291
+
292
+ ### Fixed
293
+
294
+ - `@pond-ts/charts`: **a trading-time date band clamped onto a labelled tick
295
+ keeps its segment boundary.** The F-charts-21 collision fix dropped the whole
296
+ `{ start, label, shaded }` entry, but `<XAxis>` spans each band
297
+ `[its start, the next band's start)` and takes its zebra parity from
298
+ `shaded` — so removing an entry deleted the _period_, not a duplicate label,
299
+ and the preceding band ran on to the next surviving boundary under its own
300
+ name and shading. (Aug 1 2026 is a Saturday, so the August band clamps onto
301
+ Mon Aug 3, already a day tick — August vanished and July painted across it.)
302
+ `bands()` entries now carry **`showLabel`**, and only the text is suppressed.
303
+ Found by Codex reviewing the v0.64.0 range.
304
+ - `@pond-ts/charts`: **a controlled y-gutter wheel zoom no longer pivots about
305
+ the wrong point under a plot-level y zoom.** The `zeroAnchored` pivot was
306
+ corrected above, but the ordinary (non-anchored) controlled wheel still fed
307
+ the pointer's _screen_ pixel into the pre-transform scale's pixel space, so
308
+ the value under the pointer slid out from under it — ~0.14 units per notch at
309
+ `k = 1.198`. Third and last face of one root cause: a screen-space pixel used
310
+ where `base`'s pixel space is meant.
311
+ - `@pond-ts/charts`: **a controlled y-gutter pan no longer overshoots the
312
+ cursor under a plot-level y zoom.** With `onBoundsChange` set, the drag
313
+ translated the axis's pre-transform scale by a _screen_ pixel delta. Those
314
+ two pixel spaces differ by the container's `yTransform`, so with the plot's
315
+ own y pan/zoom engaged (`panZoom="panZoomY"` / `"panZoomXY"`) the axis
316
+ panned `k`× too far and the grabbed value outran the pointer. Both
317
+ transforms are applied in pixel space, so the base↔screen relation is
318
+ exactly affine and the correction is a division, on `log` and `symlog` as
319
+ well. (The division is exact on all three; end-to-end tracking on `symlog`
320
+ still drifts, because its knee is re-derived from the panned domain — a
321
+ pre-existing property the sibling zoom test already documents.) Invisible at
322
+ `k === 1`.
323
+ - `@pond-ts/charts`: **`<YAxis zeroAnchored>` no longer lets the baseline drift
324
+ in the controlled path.** With `onBoundsChange` set, the zoom read `0`'s
325
+ pixel off the axis's _live_ scale but inverted it through `baseYScales`.
326
+ Those two pixel spaces differ by the container's own `yTransform`, so with
327
+ the plot's y pan/zoom active (`panZoom="panZoomY"` / `"panZoomXY"`) the
328
+ baseline crept a few pixels per wheel notch — monotonically, in exactly the
329
+ gesture whose purpose is to hold it still. It was invisible without a
330
+ plot-level y zoom, where the two spaces coincide. Found by a post-merge
331
+ adversarial review of #676.
332
+ - `@pond-ts/charts`: **a `BarChart`'s baseline no longer relocates under a y
333
+ gutter pan/zoom.** `resolveBarBaseline` decided whether bars rest on zero or
334
+ on the axis floor by reading the axis's _live, transformed_ domain — the
335
+ same read a pan/zoom keeps moving — so once a gesture scrolled zero out of
336
+ view, every bar's rendered top silently snapped to the moving floor instead
337
+ of its actual value. It now reads the axis's declared (pre-transform)
338
+ domain for that decision, so a pan/zoom can move the viewport without ever
339
+ relocating a bar's own baseline.
340
+ - `@pond-ts/charts`: **[F-charts-21] a trading-time axis's stacked date-band
341
+ row no longer draws a second, colliding label over a collapsed-gap seam.**
342
+ `scale.bands()` picked its anchors from the raw calendar (a month or day
343
+ start) without checking whether that instant was live, so a period
344
+ beginning on a collapsed weekend or holiday clamped its label onto the same
345
+ seam a tick (or another band) already occupied — rendering as two
346
+ overlapping labels (`"Aug"` over `"Aug 3"`, or worse at tight zoom, fully
347
+ interleaved). Bands whose raw start needed clamping now dedupe against a
348
+ run of other clamped starts landing on the same live instant (picking the
349
+ genuinely-live member as the representative when one exists) and against
350
+ an already-labelled tick, so a seam carries exactly one label.
351
+
69
352
  ## [0.63.0] — 2026-08-18
70
353
 
71
354
  ### Added
package/dist/BarChart.js CHANGED
@@ -448,8 +448,8 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
448
448
  ...(id === undefined
449
449
  ? {}
450
450
  : {
451
- hitTest: (px, py, xScale, yScale, mode) => {
452
- const baseline = resolveBarBaseline(yScale);
451
+ hitTest: (px, py, xScale, yScale, mode, baseYScale) => {
452
+ const baseline = resolveBarBaseline(yScale, baseYScale);
453
453
  // No `gapPx` — the hit region is the bar's whole slot (its
454
454
  // interval width, full plot height), not the inset rect the
455
455
  // gap draws. See barSlotRect.
@@ -525,7 +525,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
525
525
  },
526
526
  }),
527
527
  }),
528
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleDrawStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
528
+ draw: (ctx, xScale, yScale, baseYScale) => drawBars(ctx, bs, xScale, yScale, singleDrawStyle, resolveBarBaseline(yScale, baseYScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
529
529
  },
530
530
  axisId: axis,
531
531
  index,
@@ -562,7 +562,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
562
562
  ...(id === undefined
563
563
  ? {}
564
564
  : {
565
- hitTest: (px, py, xScale, yScale) => {
565
+ hitTest: (px, py, xScale, yScale, _mode, baseYScale) => {
566
566
  // The cap reaches the hit rect ONLY for a real stack, where the
567
567
  // rect is what resolves *which segment* was hit and so must be
568
568
  // the drawn one. A single-series chart has one segment per slot
@@ -576,7 +576,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
576
576
  // the fix is to make the guarantee orientation-independent rather
577
577
  // than to narrow the claim, since the reason for the split is
578
578
  // segment disambiguation and that is a property of the *stack*.
579
- const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth, ss.groups.length > 1 ? maxWidthPx : undefined);
579
+ const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth, ss.groups.length > 1 ? maxWidthPx : undefined, baseYScale);
580
580
  if (hit === null)
581
581
  return null;
582
582
  const [bi, g, begin, name, value] = hit;
@@ -657,7 +657,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
657
657
  });
658
658
  },
659
659
  }),
660
- draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
660
+ draw: (ctx, xScale, yScale, baseYScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans, baseYScale),
661
661
  },
662
662
  axisId: axis,
663
663
  index,
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, axisOffsets, axisColors, } = row;
210
+ const { layers, yScales, baseYScales, 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.
@@ -346,21 +346,23 @@ export function Layers({ children }) {
346
346
  const report = container.reportDrawStats;
347
347
  if (report === undefined) {
348
348
  for (const entry of layers) {
349
- const yScale = yScales.get(entry.axisId ?? defaultAxisId);
349
+ const axisId = entry.axisId ?? defaultAxisId;
350
+ const yScale = yScales.get(axisId);
350
351
  if (yScale === undefined)
351
352
  continue;
352
- entry.layer.draw(ctx, xScale, yScale);
353
+ entry.layer.draw(ctx, xScale, yScale, baseYScales.get(axisId));
353
354
  }
354
355
  }
355
356
  else {
356
357
  const infos = [];
357
358
  let totalDrawMs = 0;
358
359
  for (const entry of layers) {
359
- const yScale = yScales.get(entry.axisId ?? defaultAxisId);
360
+ const axisId = entry.axisId ?? defaultAxisId;
361
+ const yScale = yScales.get(axisId);
360
362
  if (yScale === undefined)
361
363
  continue;
362
364
  const t0 = performance.now();
363
- const stats = entry.layer.draw(ctx, xScale, yScale);
365
+ const stats = entry.layer.draw(ctx, xScale, yScale, baseYScales.get(axisId));
364
366
  const drawMs = performance.now() - t0;
365
367
  totalDrawMs += drawMs;
366
368
  infos.push({
@@ -377,6 +379,7 @@ export function Layers({ children }) {
377
379
  }, [
378
380
  layers,
379
381
  yScales,
382
+ baseYScales,
380
383
  xScale,
381
384
  xTickCount,
382
385
  defaultAxisId,
@@ -1132,7 +1135,7 @@ export function Layers({ children }) {
1132
1135
  // canvas repaints only on a mark transition — not every move (the move just
1133
1136
  // slides the SVG cursor). A row with no selectable layer (line/area/band)
1134
1137
  // resolves to null → a no-op. Uses the raw pointer, not the snapped x.
1135
- const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
1138
+ const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'hover', (axisId) => r.baseYScales.get(axisId ?? r.defaultAxisId));
1136
1139
  // The resting BLOCK preview (a mounted <MultiSelector>): hover lights
1137
1140
  // every mark in the snap block under the pointer — exactly the set a
1138
1141
  // drag begun and released here would select, from exactly the sweep's
@@ -1392,7 +1395,7 @@ export function Layers({ children }) {
1392
1395
  // 'select', not 'hover': a click must be able to resolve to NO mark —
1393
1396
  // that null is the deselect signal (the empty commit) — so layers whose
1394
1397
  // hover target is generous (a bar's full-height slot) narrow it here.
1395
- const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'select');
1398
+ const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'select', (axisId) => r.baseYScales.get(axisId ?? r.defaultAxisId));
1396
1399
  const modifiers = {
1397
1400
  additive: e.metaKey || e.ctrlKey,
1398
1401
  ctrlKey: e.ctrlKey,
package/dist/XAxis.js CHANGED
@@ -592,7 +592,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
592
592
  fontWeight: 600,
593
593
  whiteSpace: 'nowrap',
594
594
  overflow: 'hidden',
595
- }, children: b.label }, b.start));
595
+ }, children: b.showLabel ? b.label : '' }, b.start));
596
596
  }) })), label !== undefined && (_jsx("div", { style: {
597
597
  position: 'absolute',
598
598
  left: 0,
package/dist/YAxis.d.ts CHANGED
@@ -267,6 +267,32 @@ export interface YAxisProps {
267
267
  * space), so it is always a domain the axis can actually draw.
268
268
  */
269
269
  onBoundsChange?: (bounds: readonly [number, number] | null) => void;
270
+ /**
271
+ * Pin the zoom to the value-**0** gridline instead of the pointer, and drop
272
+ * drag-to-pan entirely — for a bar chart, whose baseline must never move.
273
+ * A bar rests on `0` (or, straddling positive and negative, has `0`
274
+ * *somewhere inside* the visible range) — panning or zooming about an
275
+ * arbitrary pointer position would slide that baseline around the plot,
276
+ * which reads as the data moving under gestures that never touched it (see
277
+ * `resolveBarBaseline`, and [PND-XBASE] for the still-open x-axis
278
+ * counterpart of this exact failure mode). `zeroAnchored` sidesteps it:
279
+ * every wheel notch scales the axis around wherever `0` currently renders,
280
+ * so the baseline holds however far in or out you scroll — whether it sits
281
+ * at the plot floor (all-positive bars) or in the middle (bars that
282
+ * straddle zero).
283
+ *
284
+ * One qualification: the pivot is clamped into the axis's own pixel range,
285
+ * so if `0` has been scrolled off the plot entirely the zoom pivots about
286
+ * the nearer edge instead and the baseline does creep. For a bar chart that
287
+ * is benign — the clamp lands on the same floor `resolveBarBaseline`
288
+ * already resolves against — but it is a creep, not a guarantee.
289
+ *
290
+ * Still gated by the container's own `axisPanZoom` opt-in (`'y'` / `'xy'`)
291
+ * — this only changes *what* the gesture does once enabled, not whether
292
+ * it's enabled. Double-click still resets to the declared/auto-fit view.
293
+ * Default `false`.
294
+ */
295
+ zeroAnchored?: boolean;
270
296
  /**
271
297
  * @internal Declaration position among the row's children, injected by
272
298
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -289,5 +315,5 @@ export interface YAxisProps {
289
315
  * {@link RowFrame.axisTransforms}. Report it to a scale UI with
290
316
  * {@link YAxisProps.onBoundsChange}.
291
317
  */
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;
318
+ export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, onBoundsChange, zeroAnchored, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
293
319
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -1,6 +1,6 @@
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
5
  import { unpadDomain } from './domain.js';
6
6
  import { useSlotKey } from './use-slot-key.js';
@@ -40,7 +40,7 @@ const DEFAULT_TICK_COUNT = 5;
40
40
  * {@link RowFrame.axisTransforms}. Report it to a scale UI with
41
41
  * {@link YAxisProps.onBoundsChange}.
42
42
  */
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, }) {
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, }) {
44
44
  const container = useContext(ContainerContext);
45
45
  if (container === null) {
46
46
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -103,11 +103,23 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
103
103
  useEffect(() => {
104
104
  registerAxis(slot, spec);
105
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).
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).
111
123
  const gestures = useAxisGestures({
112
124
  axis: 'y',
113
125
  // Gated on `<ChartContainer axisPanZoom>` (its `'y'` / `'xy'` values) — the
@@ -116,9 +128,95 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
116
128
  // panned and zoomed — without either inheriting gestures silently or opting
117
129
  // the plot into vertical drags (a different feature: the uniform 2-D
118
130
  // transform a scatter or heat map wants).
119
- drag: container.axisPanZoomY ? 'zoom' : 'none',
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',
120
137
  wheel: container.axisPanZoomY,
121
- onZoom: (factor, pivotPx) => {
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;
122
220
  // Read the scale at gesture time, not from the render that built this
123
221
  // closure — a wheel notch mid-stream must compose onto what is drawn now.
124
222
  if (onBoundsChange !== undefined) {
@@ -139,13 +237,40 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
139
237
  const base = row.baseYScales.get(id);
140
238
  if (base === undefined)
141
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);
142
267
  const [r0, r1] = base.range();
143
268
  const lo = Math.min(r0, r1);
144
269
  const hi = Math.max(r0, r1);
145
270
  // Clamp into the scale's own range, not the gutter box: a
146
271
  // `labelPlacement="top"` row reserves a header, so a press up there would
147
272
  // otherwise pivot about a value the axis never draws.
148
- const pivot = Math.max(lo, Math.min(hi, pivotPx));
273
+ const pivot = Math.max(lo, Math.min(hi, controlledPivotPx));
149
274
  // `factor` scales the visible span, so the pixel window scales by its
150
275
  // reciprocal about the pivot.
151
276
  const at = (px) => +base.invert(pivot + (px - pivot) * factor);
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/context.d.ts CHANGED
@@ -822,7 +822,7 @@ export interface RowLayer {
822
822
  * chart. Layers whose hit target is already the drawn mark (stacks,
823
823
  * scatter, boxes, heat cells) ignore `mode`.
824
824
  */
825
- hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select'): SelectInfo | null;
825
+ hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select', baseYScale?: (value: number) => number): SelectInfo | null;
826
826
  /**
827
827
  * Begin a **sweep session** over this layer's marks — `<MultiSelector>`'s
828
828
  * range query (interaction RFC A7.6/A7.7), the range analog of
@@ -896,8 +896,18 @@ export interface RowLayer {
896
896
  * {@link LayerDrawStats} (source/drawn counts + whether decimation engaged) so
897
897
  * the container can surface them via {@link ContainerProps.onDrawStats}; a
898
898
  * layer that returns `void` still contributes its measured `drawMs`.
899
- */
900
- draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number): LayerDrawStats | void;
899
+ *
900
+ * `baseYScale` is this axis's *declared* scale resolved from `min`/`max`/
901
+ * auto-fit, before either the container's or this axis's own pan/zoom pixel
902
+ * transform narrows it (the same scale `row.baseYScales` exposes). A layer
903
+ * whose geometry depends on where the domain sits relative to a fixed value
904
+ * (a bar or stack resting on zero — see `resolveBarBaseline`) reads *that*
905
+ * domain for the decision, not `yScale`'s: the live scale is a *viewport*
906
+ * onto the axis, and a pan sliding that viewport away from zero must not
907
+ * relocate the bars' own baseline out from under them. Only bars currently
908
+ * use it; every other layer type ignores the extra argument.
909
+ */
910
+ draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number, baseYScale?: (value: number) => number): LayerDrawStats | void;
901
911
  }
902
912
  /**
903
913
  * A layer's per-drag **sweep session** ({@link RowLayer.beginSweep} — RFC
package/dist/select.d.ts CHANGED
@@ -17,5 +17,5 @@ import type { LayerEntry, SelectInfo } from './context.js';
17
17
  * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
18
18
  * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
19
19
  */
20
- export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select'): SelectInfo | null;
20
+ export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select', baseYScaleFor?: (axisId: string | undefined) => ((value: number) => number) | undefined): SelectInfo | null;
21
21
  //# sourceMappingURL=select.d.ts.map
package/dist/select.js CHANGED
@@ -16,13 +16,18 @@
16
16
  * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
17
17
  * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
18
18
  */
19
- export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover') {
19
+ export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover',
20
+ // The axis's declared (pre-pan/zoom) scale — see `RowLayer.hitTest`. Kept
21
+ // alongside `yScaleFor` rather than folded into it: most callers have no
22
+ // base-scale lookup at hand (a bar-free row, a test harness), and every
23
+ // layer but bars ignores the extra argument regardless.
24
+ baseYScaleFor) {
20
25
  for (let i = entries.length - 1; i >= 0; i -= 1) {
21
26
  const entry = entries[i];
22
27
  const yScale = yScaleFor(entry.axisId);
23
28
  if (yScale === undefined)
24
29
  continue;
25
- const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode);
30
+ const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode, baseYScaleFor?.(entry.axisId));
26
31
  if (hit)
27
32
  return hit;
28
33
  }
@@ -170,7 +170,7 @@ export interface TradingTimeScale {
170
170
  * The **date bands** — the segmented second row of the stacked style. One
171
171
  * entry per next-coarser calendar period touching the domain (day bands
172
172
  * under intraday ticks, month bands under day ticks, year bands under
173
- * month/quarter ticks), each `{ start, label, shaded }`: the period's start
173
+ * month/quarter ticks), each `{ start, label, showLabel, shaded }`: the period's start
174
174
  * instant (the first band's `start` may precede the domain — its label pins
175
175
  * at the left edge), the left-aligned label, and a stable zebra `shaded`
176
176
  * flag keyed to the band's calendar identity (pan/zoom-invariant). The
@@ -181,6 +181,16 @@ export interface TradingTimeScale {
181
181
  bands(count?: number): Array<{
182
182
  start: number;
183
183
  label: string;
184
+ /**
185
+ * Whether the renderer should DRAW `label`. `false` when this band's start
186
+ * was clamped onto a live instant a tick already labels — the text would
187
+ * collide, but the band itself must still be emitted, because each entry
188
+ * is a segment boundary (the next entry's `start` closes it) and carries
189
+ * that segment's `shaded` parity. Dropping the entry instead deletes the
190
+ * period from the row, letting the previous band stretch across it under
191
+ * the wrong identity and shading.
192
+ */
193
+ showLabel: boolean;
184
194
  shaded: boolean;
185
195
  }>;
186
196
  domain(): [number, number];
@@ -192,22 +192,68 @@ export function scaleTradingTime(provider) {
192
192
  scale.bands = (count = 10) => {
193
193
  if (!hasCalendar())
194
194
  return [];
195
- const bg = bandGrainFor(resolved(count).granularity);
195
+ const { ticks, granularity } = resolved(count);
196
+ const bg = bandGrainFor(granularity);
196
197
  if (bg === undefined)
197
198
  return []; // year grain — nothing coarser to band
198
199
  const fmt = base.tickFormat(count, bandFormatFor(bg));
199
- const out = [];
200
+ const tickSet = new Set(ticks);
201
+ // A band's raw calendar start is a date, not necessarily a LIVE instant —
202
+ // a month or day beginning on a collapsed weekend/holiday clamps onto the
203
+ // next live moment. `bandNext`/`clampUp` are both monotonic, so every raw
204
+ // start that clamps to the SAME live instant is a contiguous run (a
205
+ // holiday week's Sat/Sun/Mon all land on the same Tuesday) — collected
206
+ // here as `{ s, live }` candidates before any label is built, so a run
207
+ // can be resolved as one group rather than emitting (and then trying to
208
+ // retract) a label per member.
209
+ const candidates = [];
210
+ let s = bandStartOf(domain[0], bg);
200
211
  // First band starts at (or before) the domain start — the partial left
201
212
  // band whose label the renderer pins at x=0; step to each next period
202
213
  // start still inside the domain. Bounded loop as a runaway guard.
203
- let s = bandStartOf(domain[0], bg);
204
214
  for (let i = 0; i < 100_000 && s < domain[1]; i++) {
215
+ candidates.push({ s, live: provider.clampUp(s) });
216
+ s = bandNext(s, bg);
217
+ }
218
+ const out = [];
219
+ for (let i = 0; i < candidates.length;) {
220
+ const live = candidates[i].live;
221
+ let j = i;
222
+ while (j < candidates.length && candidates[j].live === live)
223
+ j += 1;
224
+ // One label per run: prefer the member that is ITSELF live (so a
225
+ // Tuesday reopening after a collapsed weekend reads "Tue", not the
226
+ // Saturday whose raw start merely happened to clamp onto it) — a run
227
+ // with no live member (the whole period is inside the gap) falls back
228
+ // to the first, since there is no better candidate. (Every member of
229
+ // the group shares `live` by construction — the test is `c.s ===
230
+ // c.live`, i.e. THIS candidate needed no clamping at all.)
231
+ const rep = candidates.slice(i, j).find((c) => c.s === c.live) ?? candidates[i];
232
+ // The band's raw start routinely coincides with a tick that is ALSO
233
+ // legitimately live (a month band and a day tick both anchored on the
234
+ // 1st is the ordinary stacked-axis look) — that is not the bug. Only a
235
+ // representative that itself needed clamping, and whose landing spot a
236
+ // tick already labels, duplicates one; skip it rather than draw a
237
+ // second, colliding label over the tick's own.
238
+ const collides = rep.s !== live && tickSet.has(live);
239
+ // Emit the band either way. A collision suppresses only its TEXT — the
240
+ // entry is also this period's segment boundary (the next entry's
241
+ // `start` closes it) and owns its zebra parity, so skipping it deletes
242
+ // the period from the row rather than de-duplicating a label: the
243
+ // preceding band then runs on to the next surviving start under its own
244
+ // shading and name. (Aug 1 2026 is a Saturday, so the August band
245
+ // clamps onto Mon Aug 3, which is already a day tick — dropping it left
246
+ // July painted across the whole of August.)
205
247
  out.push({
206
- start: s,
207
- label: fmt(new Date(s)),
208
- shaded: bandShaded(s, bg),
248
+ start: live,
249
+ // Formatted from the representative's own RAW start, not the
250
+ // clamped one — a genuinely-live rep reads its own date; a
251
+ // gap-only run's rep reads whichever raw date it fell back to.
252
+ label: fmt(new Date(rep.s)),
253
+ showLabel: !collides,
254
+ shaded: bandShaded(rep.s, bg),
209
255
  });
210
- s = bandNext(s, bg);
256
+ i = j;
211
257
  }
212
258
  return out;
213
259
  };
@@ -14,8 +14,10 @@ export interface AxisGestureSpec {
14
14
  * - `'zoom'` — scales the axis about the grabbed pixel, reporting incremental
15
15
  * span multipliers.
16
16
  *
17
- * The x strip pans (the canvas gesture, one mental model for both surfaces);
18
- * a y gutter zooms, which is the gesture the plot cannot offer per axis.
17
+ * Both the x strip and the y gutter pan on drag the canvas gesture, one
18
+ * mental model for every surface. `'zoom'` remains available for a strip
19
+ * that wants drag-to-zoom instead, but nothing in this repo currently wires
20
+ * it up: wheel is the zoom gesture everywhere now.
19
21
  */
20
22
  drag: 'none' | 'pan' | 'zoom';
21
23
  /** Whether the **wheel** zooms this axis — again as it does over the plot. */
@@ -61,19 +63,17 @@ export interface AxisGestures {
61
63
  /**
62
64
  * Drag, wheel and double-click gestures for an axis strip.
63
65
  *
64
- * **The x strip behaves exactly as the canvas does** — drag pans, wheel zooms
66
+ * **Every strip behaves exactly as the canvas does** — drag pans, wheel zooms
65
67
  * about the pointer — so a chart has one gesture vocabulary rather than one per
66
- * surface, and the strip is simply a second place to reach the same view.
68
+ * surface, and a strip is simply a second place to reach the same view. A y
69
+ * gutter's pan moves only *that* axis (the plot's own vertical drag, where it
70
+ * exists, scales every axis in the row by one factor — the aspect lock; a
71
+ * gutter names a single axis instead).
67
72
  *
68
- * **A y gutter zooms on drag**, because that is the gesture the plot cannot
69
- * offer: the plot's vertical drag scales every axis in the row by one factor
70
- * (the aspect lock), while grabbing a gutter names a single axis. Up expands it,
71
- * down compresses it, about the grabbed pixel.
72
- *
73
- * The two shapes are why the drag reports differently per mode: a pan is
74
- * **anchored** (total delta from the press, re-derived from a snapshot — how the
75
- * plot's own pan avoids accumulating rounding), a zoom is **incremental** (a
76
- * span multiplier per step, composed onto the current view).
73
+ * The two `drag` shapes report differently: a pan is **anchored** (total delta
74
+ * from the press, re-derived from a snapshot how the plot's own pan avoids
75
+ * accumulating rounding), a zoom is **incremental** (a span multiplier per
76
+ * step, composed onto the current view).
77
77
  */
78
78
  export declare function useAxisGestures(spec: AxisGestureSpec): AxisGestures;
79
79
  //# sourceMappingURL=use-axis-gestures.d.ts.map
@@ -26,19 +26,17 @@ const WHEEL_CURSOR_MS = 400;
26
26
  /**
27
27
  * Drag, wheel and double-click gestures for an axis strip.
28
28
  *
29
- * **The x strip behaves exactly as the canvas does** — drag pans, wheel zooms
29
+ * **Every strip behaves exactly as the canvas does** — drag pans, wheel zooms
30
30
  * about the pointer — so a chart has one gesture vocabulary rather than one per
31
- * surface, and the strip is simply a second place to reach the same view.
31
+ * surface, and a strip is simply a second place to reach the same view. A y
32
+ * gutter's pan moves only *that* axis (the plot's own vertical drag, where it
33
+ * exists, scales every axis in the row by one factor — the aspect lock; a
34
+ * gutter names a single axis instead).
32
35
  *
33
- * **A y gutter zooms on drag**, because that is the gesture the plot cannot
34
- * offer: the plot's vertical drag scales every axis in the row by one factor
35
- * (the aspect lock), while grabbing a gutter names a single axis. Up expands it,
36
- * down compresses it, about the grabbed pixel.
37
- *
38
- * The two shapes are why the drag reports differently per mode: a pan is
39
- * **anchored** (total delta from the press, re-derived from a snapshot — how the
40
- * plot's own pan avoids accumulating rounding), a zoom is **incremental** (a
41
- * span multiplier per step, composed onto the current view).
36
+ * The two `drag` shapes report differently: a pan is **anchored** (total delta
37
+ * from the press, re-derived from a snapshot how the plot's own pan avoids
38
+ * accumulating rounding), a zoom is **incremental** (a span multiplier per
39
+ * step, composed onto the current view).
42
40
  */
43
41
  export function useAxisGestures(spec) {
44
42
  // The live spec, read by the handlers — so a listener attached once still sees
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -39,8 +39,8 @@
39
39
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@pond-ts/react": "^0.63.0",
43
- "pond-ts": "^0.63.0",
42
+ "@pond-ts/react": "^0.65.0",
43
+ "pond-ts": "^0.65.0",
44
44
  "react": "^18.0.0 || ^19.0.0"
45
45
  },
46
46
  "devDependencies": {