@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 +27 -14
- package/CHANGELOG.md +284 -1
- package/dist/BarChart.js +6 -6
- package/dist/Layers.js +10 -7
- package/dist/XAxis.js +1 -1
- package/dist/YAxis.d.ts +27 -1
- package/dist/YAxis.js +136 -11
- package/dist/bars.d.ts +21 -8
- package/dist/bars.js +38 -12
- package/dist/context.d.ts +13 -3
- package/dist/select.d.ts +1 -1
- package/dist/select.js +7 -2
- package/dist/tradingTimeScale.d.ts +11 -1
- package/dist/tradingTimeScale.js +53 -7
- package/dist/use-axis-gestures.d.ts +13 -13
- package/dist/use-axis-gestures.js +9 -11
- package/package.json +3 -3
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`
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
|
441
|
-
|
|
|
442
|
-
| `
|
|
443
|
-
| `
|
|
444
|
-
| `
|
|
445
|
-
| `
|
|
446
|
-
| `
|
|
447
|
-
| `
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/dist/tradingTimeScale.js
CHANGED
|
@@ -192,22 +192,68 @@ export function scaleTradingTime(provider) {
|
|
|
192
192
|
scale.bands = (count = 10) => {
|
|
193
193
|
if (!hasCalendar())
|
|
194
194
|
return [];
|
|
195
|
-
const
|
|
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
|
|
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:
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
* **
|
|
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
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
* **
|
|
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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
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.
|
|
43
|
-
"pond-ts": "^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": {
|