@pond-ts/charts 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +1 -1
- package/CHANGELOG.md +223 -1
- package/dist/BarChart.js +6 -6
- package/dist/ChartContainer.d.ts +72 -0
- package/dist/ChartContainer.js +151 -24
- package/dist/ChartRow.d.ts +39 -3
- package/dist/ChartRow.js +194 -7
- package/dist/Layers.js +25 -8
- package/dist/XAxis.d.ts +6 -0
- package/dist/XAxis.js +81 -3
- package/dist/YAxis.d.ts +92 -2
- package/dist/YAxis.js +268 -6
- package/dist/annotations.js +14 -7
- package/dist/bars.d.ts +21 -8
- package/dist/bars.js +38 -12
- package/dist/chip.d.ts +50 -7
- package/dist/chip.js +66 -9
- package/dist/context.d.ts +134 -7
- package/dist/cursors.js +33 -8
- package/dist/domain.d.ts +18 -0
- package/dist/domain.js +33 -0
- 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 +79 -0
- package/dist/use-axis-gestures.js +257 -0
- package/package.json +3 -3
package/dist/YAxis.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useContext, useEffect, useMemo } from 'react';
|
|
3
|
-
import { ContainerContext, RowContext } from './context.js';
|
|
2
|
+
import { useContext, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { ContainerContext, RowContext, } from './context.js';
|
|
4
4
|
import { resolveAxisFormat } from './format.js';
|
|
5
|
+
import { unpadDomain } from './domain.js';
|
|
5
6
|
import { useSlotKey } from './use-slot-key.js';
|
|
6
7
|
import { tickValues } from './yticks.js';
|
|
7
8
|
import { axisMouseProps, axisPointerPx, } from './axis-events.js';
|
|
9
|
+
import { useAxisGestures } from './use-axis-gestures.js';
|
|
10
|
+
/**
|
|
11
|
+
* Clamp on a gutter drag's own zoom factor. Unlike the container's uniform
|
|
12
|
+
* transform (floored at `k ≥ 1` so a plot gesture can't zoom every axis out into
|
|
13
|
+
* blank canvas) an axis you deliberately grabbed may squash as well as stretch —
|
|
14
|
+
* `k < 1` widens the domain, which costs nothing. The bounds exist only so a
|
|
15
|
+
* flick of the wheel can't strand the axis at a factor no further gesture can
|
|
16
|
+
* recover from.
|
|
17
|
+
*/
|
|
18
|
+
const MIN_AXIS_K = 0.02;
|
|
19
|
+
const MAX_AXIS_K = 50;
|
|
20
|
+
/** The un-grabbed transform — also what clears an axis's entry (see `ChartRow`). */
|
|
21
|
+
const IDENTITY_TRANSFORM = { k: 1, ty: 0 };
|
|
8
22
|
const DEFAULT_WIDTH = 50;
|
|
9
23
|
/** Fallback tick count before the row has published its resolved count (the
|
|
10
24
|
* first render, pre-registration). The row's height-derived value takes over
|
|
@@ -17,8 +31,16 @@ const DEFAULT_TICK_COUNT = 5;
|
|
|
17
31
|
* computes this axis's scale from the charts linked to it; the gutter then draws
|
|
18
32
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
19
33
|
* (default: the first axis).
|
|
34
|
+
*
|
|
35
|
+
* **Gestures.** With `<ChartContainer axisPanZoom="y">` (or `"xy"`) the gutter is
|
|
36
|
+
* grabbable: drag or wheel it to scale **this axis only**
|
|
37
|
+
* — a sibling axis on the other side, and every other row, hold still — and
|
|
38
|
+
* double-click to release it back to its fit. That per-axis scaling is what the
|
|
39
|
+
* plot's vertical gesture deliberately cannot do; see
|
|
40
|
+
* {@link RowFrame.axisTransforms}. Report it to a scale UI with
|
|
41
|
+
* {@link YAxisProps.onBoundsChange}.
|
|
20
42
|
*/
|
|
21
|
-
export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, onMouseEvent, index = 0, }) {
|
|
43
|
+
export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, onMouseEvent, onBoundsChange, zeroAnchored = false, index = 0, }) {
|
|
22
44
|
const container = useContext(ContainerContext);
|
|
23
45
|
if (container === null) {
|
|
24
46
|
throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -44,6 +66,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
44
66
|
format,
|
|
45
67
|
tickValues: ticks?.map((t) => t.at),
|
|
46
68
|
tickCount,
|
|
69
|
+
color,
|
|
47
70
|
index,
|
|
48
71
|
}), [
|
|
49
72
|
id,
|
|
@@ -59,6 +82,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
59
82
|
format,
|
|
60
83
|
ticks,
|
|
61
84
|
tickCount,
|
|
85
|
+
color,
|
|
62
86
|
index,
|
|
63
87
|
]);
|
|
64
88
|
// A stable per-instance slot (see useSlotKey) keeps this axis in a fixed
|
|
@@ -66,13 +90,241 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
66
90
|
// re-appending (which would move the first axis behind a later one and
|
|
67
91
|
// silently rebind the row's default-axis charts).
|
|
68
92
|
const slot = useSlotKey();
|
|
69
|
-
const { registerAxis, unregisterAxis } = row;
|
|
93
|
+
const { registerAxis, unregisterAxis, applyAxisTransform } = row;
|
|
70
94
|
// Unregister on unmount only (deps are stable, so cleanup never runs early).
|
|
71
|
-
useEffect(() => () =>
|
|
95
|
+
useEffect(() => () => {
|
|
96
|
+
unregisterAxis(slot);
|
|
97
|
+
// Drop any gutter zoom with the axis. The row keys transforms by axis id
|
|
98
|
+
// (as it keys scales), so an entry left behind would be inherited by a
|
|
99
|
+
// later axis that happens to reuse the id.
|
|
100
|
+
applyAxisTransform(id, IDENTITY_TRANSFORM);
|
|
101
|
+
}, [unregisterAxis, slot, applyAxisTransform, id]);
|
|
72
102
|
// Register on mount + update in place on every spec change — no reorder.
|
|
73
103
|
useEffect(() => {
|
|
74
104
|
registerAxis(slot, spec);
|
|
75
105
|
}, [registerAxis, slot, spec]);
|
|
106
|
+
// The transform this axis held at the last press — an UNCONTROLLED pan is
|
|
107
|
+
// anchored on it (see `onPan`), the same way the x strip anchors on
|
|
108
|
+
// `container.timeRange`.
|
|
109
|
+
const panStartRef = useRef(null);
|
|
110
|
+
// The base scale at the last press, for a CONTROLLED pan. Snapshotted for the
|
|
111
|
+
// same reason: a controlled consumer writes `min`/`max` back after every
|
|
112
|
+
// move, so `row.baseYScales` on the *next* move already reflects this drag's
|
|
113
|
+
// own effect — reading it fresh would compose the total delta onto a base
|
|
114
|
+
// that has already moved, doubling every step's shift.
|
|
115
|
+
const panBaseRef = useRef(null);
|
|
116
|
+
// Drag pans, wheel zooms **this** axis, double-click releases it back to the
|
|
117
|
+
// row's own fit — the same gesture vocabulary the x strip uses, so a gutter
|
|
118
|
+
// and the canvas agree on what a drag does. Enabled by the container's
|
|
119
|
+
// `panZoom` zoom-y degree of freedom. The gesture writes this axis's entry in
|
|
120
|
+
// `row.axisTransforms`, so only the gutter you grabbed moves — the sibling
|
|
121
|
+
// axis, and every other row, hold still (see `RowFrame.axisTransforms` for
|
|
122
|
+
// why that needs its own transform).
|
|
123
|
+
const gestures = useAxisGestures({
|
|
124
|
+
axis: 'y',
|
|
125
|
+
// Gated on `<ChartContainer axisPanZoom>` (its `'y'` / `'xy'` values) — the
|
|
126
|
+
// axis opt-in, deliberately independent of the plot's `panZoom`. That is what
|
|
127
|
+
// lets the canonical setup work — an auto-fitting y on a chart whose *x* is
|
|
128
|
+
// panned and zoomed — without either inheriting gestures silently or opting
|
|
129
|
+
// the plot into vertical drags (a different feature: the uniform 2-D
|
|
130
|
+
// transform a scatter or heat map wants).
|
|
131
|
+
//
|
|
132
|
+
// `zeroAnchored` drops the drag entirely: a pan slides the whole pixel
|
|
133
|
+
// window, which is exactly the baseline movement it exists to forbid.
|
|
134
|
+
// Wheel stays live — `onZoom` below overrides the pivot to `0`'s own
|
|
135
|
+
// pixel, so a notch narrows the axis around the baseline instead.
|
|
136
|
+
drag: !zeroAnchored && container.axisPanZoomY ? 'pan' : 'none',
|
|
137
|
+
wheel: container.axisPanZoomY,
|
|
138
|
+
// Snapshot the transform (uncontrolled) or the base scale (controlled) at
|
|
139
|
+
// press: the pan is re-derived from one of these on every move (the x
|
|
140
|
+
// strip's own approach), so a long drag can't accumulate rounding — and,
|
|
141
|
+
// for the controlled path, can't compose the total delta onto a base that
|
|
142
|
+
// this same drag's own previous move already shifted.
|
|
143
|
+
onDragStart: () => {
|
|
144
|
+
panStartRef.current = row.axisTransforms.get(id) ?? IDENTITY_TRANSFORM;
|
|
145
|
+
panBaseRef.current = row.baseYScales.get(id) ?? null;
|
|
146
|
+
},
|
|
147
|
+
onPan: (totalDeltaPx) => {
|
|
148
|
+
if (onBoundsChange !== undefined) {
|
|
149
|
+
// **Controlled**: shift the pixel window by the drag, same pixel-space
|
|
150
|
+
// inversion `onZoom` uses so the maths stays correct on `log` and
|
|
151
|
+
// `symlog` — a pan is just a zoom with every pixel shifted by the same
|
|
152
|
+
// amount rather than scaled about a pivot. Read from the *press-time*
|
|
153
|
+
// base, not the live one: `min`/`max` come back through this axis's own
|
|
154
|
+
// props after every move, so the live base already carries however far
|
|
155
|
+
// this drag has gone so far, and re-deriving the total delta against it
|
|
156
|
+
// would double-apply everything already reported.
|
|
157
|
+
const base = panBaseRef.current;
|
|
158
|
+
if (base === null)
|
|
159
|
+
return;
|
|
160
|
+
const [r0, r1] = base.range();
|
|
161
|
+
// `totalDeltaPx` is a SCREEN delta, but `base` is the pre-transform
|
|
162
|
+
// scale, and the two pixel spaces differ by the container's `k` (and
|
|
163
|
+
// this axis's own, if it carries one). Translating `base` by a screen
|
|
164
|
+
// delta therefore pans by `k`× too much whenever the plot's own y zoom
|
|
165
|
+
// is engaged — the grabbed value outruns the cursor. Both transforms
|
|
166
|
+
// are applied in *pixel* space (`narrow` inverts `(px - ty) / k`), so
|
|
167
|
+
// the base↔screen relation is exactly affine for every scale kind and
|
|
168
|
+
// the correction is a plain division — no per-scale special-casing.
|
|
169
|
+
// The division is exact on `log` and `symlog` too; end-to-end
|
|
170
|
+
// tracking on `symlog` still drifts, because `symlogConstant` is
|
|
171
|
+
// derived from the domain and so the knee moves as the domain pans.
|
|
172
|
+
// That is pre-existing and independent of this correction — the
|
|
173
|
+
// sibling zoom test already documents it as "knee drift".
|
|
174
|
+
//
|
|
175
|
+
// `ownK` is 1 in the ordinary controlled path: `applyAxisTransform`
|
|
176
|
+
// is only ever called from the UNCONTROLLED branches, so a controlled
|
|
177
|
+
// axis carries no transform of its own. It is composed here for the
|
|
178
|
+
// one window where that is false — a consumer that pans uncontrolled
|
|
179
|
+
// and then supplies `onBoundsChange`, leaving a stale axis transform
|
|
180
|
+
// until the next reset. No test covers that window.
|
|
181
|
+
//
|
|
182
|
+
// Sibling of the pivot-space bugs in `onZoom`; invisible at
|
|
183
|
+
// `k === 1`, which is every test that does not first zoom the plot.
|
|
184
|
+
const ownK = panStartRef.current?.k ?? 1;
|
|
185
|
+
const pixelK = container.yTransform.k * ownK;
|
|
186
|
+
const deltaBasePx = Number.isFinite(pixelK) && pixelK !== 0
|
|
187
|
+
? totalDeltaPx / pixelK
|
|
188
|
+
: totalDeltaPx;
|
|
189
|
+
const at = (px) => +base.invert(px - deltaBasePx);
|
|
190
|
+
const next = [at(r0), at(r1)];
|
|
191
|
+
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1]))
|
|
192
|
+
return;
|
|
193
|
+
if (next[0] === next[1])
|
|
194
|
+
return;
|
|
195
|
+
onBoundsChange(unpadDomain(next, pad, scale));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// **Uncontrolled**: shift this axis's own pixel transform. Anchored on
|
|
199
|
+
// the press's `panStartRef`, not the live `row.axisTransforms` value —
|
|
200
|
+
// reading that fresh on every move would double-apply everything moved
|
|
201
|
+
// so far this drag.
|
|
202
|
+
const start = panStartRef.current ?? IDENTITY_TRANSFORM;
|
|
203
|
+
applyAxisTransform(id, { k: start.k, ty: start.ty + totalDeltaPx });
|
|
204
|
+
},
|
|
205
|
+
onZoom: (factor, wheelPivotPx) => {
|
|
206
|
+
// `zeroAnchored` substitutes the wheel's own pointer pixel for wherever
|
|
207
|
+
// `0` currently renders — read off the LIVE scale (post pan/zoom, both
|
|
208
|
+
// the container's and this axis's own), which is the pixel a viewer
|
|
209
|
+
// actually sees the baseline sitting at right now. Falls back to the
|
|
210
|
+
// pointer if `0` has no position at all (a log axis, which never
|
|
211
|
+
// admits it — `scaleLog()(0)` is `NaN`) rather than silently doing
|
|
212
|
+
// nothing.
|
|
213
|
+
// Used by the UNCONTROLLED branch below, which composes its transform in
|
|
214
|
+
// live pixel space. The controlled branch re-reads `0` off `baseYScales`
|
|
215
|
+
// instead — see there.
|
|
216
|
+
const zeroPx = row.yScales.get(id)?.(0);
|
|
217
|
+
const pivotPx = zeroAnchored && zeroPx !== undefined && Number.isFinite(zeroPx)
|
|
218
|
+
? zeroPx
|
|
219
|
+
: wheelPivotPx;
|
|
220
|
+
// Read the scale at gesture time, not from the render that built this
|
|
221
|
+
// closure — a wheel notch mid-stream must compose onto what is drawn now.
|
|
222
|
+
if (onBoundsChange !== undefined) {
|
|
223
|
+
// **Controlled**: report the bounds the gesture reached and draw nothing
|
|
224
|
+
// ourselves — `min`/`max` coming back is what moves the axis.
|
|
225
|
+
//
|
|
226
|
+
// Computed by inverting through the scale in **pixel** space, not by
|
|
227
|
+
// affine arithmetic on its domain. That is what makes it correct on every
|
|
228
|
+
// scale kind: `log` and `symlog` are not affine in value space, so
|
|
229
|
+
// zooming their domain numerically drifts the grabbed pixel (visibly, on
|
|
230
|
+
// symlog, whose knee is re-derived from the domain each time) and can
|
|
231
|
+
// overflow to `[0, Infinity]` on a hard log zoom-out.
|
|
232
|
+
//
|
|
233
|
+
// Read from `baseYScales` — the axis's *resolved* scale, before the
|
|
234
|
+
// container's uniform `yTransform` and this axis's own transform. The
|
|
235
|
+
// consumer's `min`/`max` live in that space, so reporting a value read
|
|
236
|
+
// off the visible scale would have the transforms applied to it twice.
|
|
237
|
+
const base = row.baseYScales.get(id);
|
|
238
|
+
if (base === undefined)
|
|
239
|
+
return;
|
|
240
|
+
// `pivotPx` above was read off the LIVE scale — the pixel a viewer
|
|
241
|
+
// sees, which is right for the uncontrolled branch because it composes
|
|
242
|
+
// a transform in that same space. Here the inversion happens in
|
|
243
|
+
// `base`'s pixel space, and the two differ by the container's own
|
|
244
|
+
// `yTransform`, so `0` has to be re-read there. Mixing them lets the
|
|
245
|
+
// baseline drift a few px per notch whenever the plot's y pan/zoom is
|
|
246
|
+
// active (`panZoom="panZoomY"` / `"panZoomXY"`) — precisely the motion
|
|
247
|
+
// `zeroAnchored` exists to forbid, and invisible without a plot-level
|
|
248
|
+
// y zoom, where the two spaces coincide.
|
|
249
|
+
const baseZeroPx = zeroAnchored ? base(0) : undefined;
|
|
250
|
+
// The fallback — and the ordinary, non-`zeroAnchored` case — is the
|
|
251
|
+
// pointer's own pixel, which is a SCREEN pixel and needs the same
|
|
252
|
+
// change of space. (#678 corrected only the `zeroAnchored` pivot,
|
|
253
|
+
// leaving the plain controlled wheel pivoting about the wrong point
|
|
254
|
+
// under a plot y zoom; the pan in this same file had the mirror
|
|
255
|
+
// defect on its delta. Three faces of one root cause: a screen-space
|
|
256
|
+
// pixel used where `base`'s pixel space is meant.) `ty` matters here
|
|
257
|
+
// because a pivot is an absolute position, not a delta.
|
|
258
|
+
const ownK = panStartRef.current?.k ?? 1;
|
|
259
|
+
const pixelK = container.yTransform.k * ownK;
|
|
260
|
+
const pixelTy = container.yTransform.ty;
|
|
261
|
+
const toBasePx = (screenPx) => Number.isFinite(pixelK) && pixelK !== 0
|
|
262
|
+
? (screenPx - pixelTy) / pixelK
|
|
263
|
+
: screenPx;
|
|
264
|
+
const controlledPivotPx = baseZeroPx !== undefined && Number.isFinite(baseZeroPx)
|
|
265
|
+
? baseZeroPx
|
|
266
|
+
: toBasePx(wheelPivotPx);
|
|
267
|
+
const [r0, r1] = base.range();
|
|
268
|
+
const lo = Math.min(r0, r1);
|
|
269
|
+
const hi = Math.max(r0, r1);
|
|
270
|
+
// Clamp into the scale's own range, not the gutter box: a
|
|
271
|
+
// `labelPlacement="top"` row reserves a header, so a press up there would
|
|
272
|
+
// otherwise pivot about a value the axis never draws.
|
|
273
|
+
const pivot = Math.max(lo, Math.min(hi, controlledPivotPx));
|
|
274
|
+
// `factor` scales the visible span, so the pixel window scales by its
|
|
275
|
+
// reciprocal about the pivot.
|
|
276
|
+
const at = (px) => +base.invert(pivot + (px - pivot) * factor);
|
|
277
|
+
const next = [at(r0), at(r1)];
|
|
278
|
+
// Orientation is preserved rather than required: `resolveYDomain` keeps
|
|
279
|
+
// an explicit `[max, min]` as a deliberate axis flip, and rejecting
|
|
280
|
+
// descending results would have made adding this callback silently
|
|
281
|
+
// disable the gesture on a flipped axis.
|
|
282
|
+
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1]))
|
|
283
|
+
return;
|
|
284
|
+
if (next[0] === next[1])
|
|
285
|
+
return;
|
|
286
|
+
// `pad` is applied last and to explicit bounds too, so the resolved
|
|
287
|
+
// domain already includes it; handing that back would re-pad it and
|
|
288
|
+
// inflate the axis by `1 + 2·pad` per notch (see `unpadDomain`).
|
|
289
|
+
onBoundsChange(unpadDomain(next, pad, scale));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// **Uncontrolled**: hold the zoom ourselves as this axis's own pixel
|
|
293
|
+
// transform. Read at gesture time for the same reason the scale is: two
|
|
294
|
+
// wheel notches inside one frame both see the render-scope value, so the
|
|
295
|
+
// second would compose onto the first's *input* and the notch be lost.
|
|
296
|
+
const own = row.axisTransforms.get(id) ?? IDENTITY_TRANSFORM;
|
|
297
|
+
// Same range clamp as the controlled path: keep the pivot on the scale.
|
|
298
|
+
const visible = row.yScales.get(id);
|
|
299
|
+
const vr = (visible?.range() ?? [0, 0]);
|
|
300
|
+
const pivot = Math.max(Math.min(vr[0], vr[1]), Math.min(Math.max(vr[0], vr[1]), pivotPx));
|
|
301
|
+
// `factor` scales the domain span, so its reciprocal is the pixel-space
|
|
302
|
+
// zoom — the relationship the plot's wheel handler uses.
|
|
303
|
+
const z = 1 / factor;
|
|
304
|
+
const nk = Math.min(MAX_AXIS_K, Math.max(MIN_AXIS_K, own.k * z));
|
|
305
|
+
// Re-derive the zoom the clamp actually allowed, so a gesture held at a
|
|
306
|
+
// limit stops moving the pivot too (rather than sliding the axis).
|
|
307
|
+
const zEff = own.k === 0 ? 1 : nk / own.k;
|
|
308
|
+
applyAxisTransform(id, {
|
|
309
|
+
k: nk,
|
|
310
|
+
// Zoom about the grabbed pixel: p' = pivot + (p − pivot)·z, expanded
|
|
311
|
+
// through the existing transform p = ty + k·base. No pan clamp here —
|
|
312
|
+
// the plot's exists to stop zoomed content sliding off the canvas, and
|
|
313
|
+
// this transform is applied by narrowing the domain, so there is no
|
|
314
|
+
// canvas to leave.
|
|
315
|
+
ty: pivot * (1 - zEff) + own.ty * zEff,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
// Back to auto: `null` tells a controlled consumer to drop its override (the
|
|
319
|
+
// same thing their "manual → auto" toggle does), and an uncontrolled axis
|
|
320
|
+
// drops its own transform.
|
|
321
|
+
onReset: () => {
|
|
322
|
+
if (onBoundsChange !== undefined)
|
|
323
|
+
onBoundsChange(null);
|
|
324
|
+
else
|
|
325
|
+
applyAxisTransform(id, IDENTITY_TRANSFORM);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
76
328
|
// `hide`: everything above still runs — the axis is registered, so its scale
|
|
77
329
|
// exists and layers bind to it — and everything below (the gutter chrome)
|
|
78
330
|
// does not. Placed after the last hook so the early return can't change hook
|
|
@@ -133,6 +385,9 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
133
385
|
// and the event is dropped. A categorical row labels by slot, matching its
|
|
134
386
|
// ticks; every other row reads this axis's own tick format.
|
|
135
387
|
const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
|
|
388
|
+
// See the x strip's: a zoom drag's trailing click is not a click on a value.
|
|
389
|
+
if (event.type === 'click' && gestures.consumeDrag())
|
|
390
|
+
return null;
|
|
136
391
|
if (!yScale)
|
|
137
392
|
return null;
|
|
138
393
|
// Clamp on the **scale's** range, not the box: a row with a
|
|
@@ -151,7 +406,14 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
151
406
|
: fmt(value),
|
|
152
407
|
};
|
|
153
408
|
});
|
|
154
|
-
return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse,
|
|
409
|
+
return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ref: gestures.ref, ...gestures.props, ...mouse,
|
|
410
|
+
// See the x strip's: two `onDoubleClick`s meet here (the reset and the
|
|
411
|
+
// consumer's report) and a spread would silently drop one.
|
|
412
|
+
onDoubleClick: (e) => {
|
|
413
|
+
mouse.onDoubleClick?.(e);
|
|
414
|
+
gestures.props.onDoubleClick?.();
|
|
415
|
+
}, style: {
|
|
416
|
+
...gestures.style,
|
|
155
417
|
flex: `0 0 ${slotWidth}px`,
|
|
156
418
|
display: 'flex',
|
|
157
419
|
justifyContent: side === 'left' ? 'flex-end' : 'flex-start',
|
package/dist/annotations.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { ContainerContext, RowContext, } from './context.js';
|
|
4
|
-
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
4
|
+
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle, axisPillConnector, } from './chip.js';
|
|
5
5
|
import { useSlotKey } from './use-slot-key.js';
|
|
6
6
|
/**
|
|
7
7
|
* User-authored **annotations** — marks you place *on* a chart, in a register
|
|
@@ -569,12 +569,19 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
|
|
|
569
569
|
}, children: text })), indicator &&
|
|
570
570
|
(() => {
|
|
571
571
|
const half = container.theme.font.size / 2 + 1;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
572
|
+
const side = row.axisSides.get(axisId) ?? 'left';
|
|
573
|
+
const offset = row.axisOffsets.get(axisId) ?? 0;
|
|
574
|
+
const top = `${Math.max(half, Math.min(row.height - half, y))}px`;
|
|
575
|
+
return (_jsxs(_Fragment, { children: [offset > 0 && (_jsx("div", { style: {
|
|
576
|
+
...axisPillConnector(side, w, offset, ann.color),
|
|
577
|
+
top,
|
|
578
|
+
transform: 'translateY(-50%)',
|
|
579
|
+
} })), _jsx("div", { style: {
|
|
580
|
+
...axisPillStyle(container.theme, ann.color),
|
|
581
|
+
top,
|
|
582
|
+
transform: 'translateY(-50%)',
|
|
583
|
+
...axisPillX(side, w, offset),
|
|
584
|
+
}, children: fmt ? fmt(value) : String(value) })] }));
|
|
578
585
|
})()] }));
|
|
579
586
|
}
|
|
580
587
|
/**
|
package/dist/bars.d.ts
CHANGED
|
@@ -38,12 +38,25 @@ export declare function barExtent(cs: BarSeries): [number, number] | null;
|
|
|
38
38
|
* - When the domain sits entirely below `0`: the bars hang from the **axis top**
|
|
39
39
|
* (`0` clamped down into the domain) — the symmetric case.
|
|
40
40
|
*
|
|
41
|
-
* I.e. `0` clamped into `[floor, top]`. The domain bounds come from
|
|
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/chip.d.ts
CHANGED
|
@@ -41,14 +41,57 @@ export declare function axisPillStyle(theme: ChartTheme, color: string): CSSProp
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function pointerStyle(side: 'left' | 'right', color: string): CSSProperties;
|
|
43
43
|
/**
|
|
44
|
-
* CSS placing a value pill **on
|
|
45
|
-
* edge at the plot boundary (`plotWidth`)
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
44
|
+
* CSS placing a value pill **on an axis gutter** at `side`: anchor its inner
|
|
45
|
+
* edge at that axis's inner edge — the plot boundary (`plotWidth`) plus the
|
|
46
|
+
* axis's own `offset` out into the gutter — and let it overflow outward (the
|
|
47
|
+
* plot div doesn't clip), lifted with `zIndex` above the sibling axis columns
|
|
48
|
+
* (rendered later in the row) so it covers the tick behind it. The one placement
|
|
49
|
+
* every on-axis pill goes through — the crosshair cursor's value pill, a
|
|
50
|
+
* `<Baseline indicator>`, and {@link YAxisIndicator} — so they cannot drift
|
|
51
|
+
* apart.
|
|
52
|
+
*
|
|
53
|
+
* `offset` is `0` for the innermost axis on a side (the single-axis case, and the
|
|
54
|
+
* behaviour before it existed) and the reserved widths of the axes nearer the
|
|
55
|
+
* plot for one further out — {@link RowFrame.axisOffsets}. Passing it is what
|
|
56
|
+
* puts the pill on the axis whose scale produced the number, rather than on
|
|
57
|
+
* whichever axis happens to sit against the plot. **`YAxisIndicator` does not
|
|
58
|
+
* pass it** and so still lands innermost: it takes an explicit `side` beside its
|
|
59
|
+
* `axis`, and what an offset should mean when those two disagree is unsettled
|
|
60
|
+
* (see `[PND-XHAIRAXIS]` in the charts plan).
|
|
61
|
+
*
|
|
62
|
+
* A pill is deliberately unclipped, so a long formatted value can overflow past
|
|
63
|
+
* the gutter it sits in — further out for an outer-axis pill, which has only its
|
|
64
|
+
* own column left before the container's edge. Sized-to-content and unclipped
|
|
65
|
+
* beats truncating a number, but a very wide readout on a narrow outer axis will
|
|
66
|
+
* spill outside the chart box.
|
|
67
|
+
*/
|
|
68
|
+
export declare function axisPillX(side: 'left' | 'right', plotWidth: number, offset?: number): CSSProperties;
|
|
69
|
+
/**
|
|
70
|
+
* The **connector** for a pill placed further out than the innermost axis: a 1px
|
|
71
|
+
* bridge from the plot's `side` edge across `offset` px of gutter to the pill's
|
|
72
|
+
* inner edge, so the in-plot line and its pill read as one object rather than as
|
|
73
|
+
* a value floating in a gutter two columns away. The y-side twin of the
|
|
74
|
+
* crosshair's x-axis time connector, which exists for exactly this reason.
|
|
75
|
+
*
|
|
76
|
+
* The caller positions it vertically (`top` + a `translateY(-50%)`), at the
|
|
77
|
+
* **pill's** centre rather than the raw value's — the two agree except where the
|
|
78
|
+
* pill is clamped inside the row, and a connector attached to the pill is what
|
|
79
|
+
* sells them as one object.
|
|
80
|
+
*
|
|
81
|
+
* In the pill's own colour and above the axis column (`zIndex`, as the pill is),
|
|
82
|
+
* but at **half opacity** — unlike the x-axis time connector, which is solid.
|
|
83
|
+
* The difference is what each one crosses: the time connector runs over an empty
|
|
84
|
+
* strip, while this one runs over *another axis's tick labels* (measured: a
|
|
85
|
+
* connector at a value whose neighbouring axis has a tick at the same height
|
|
86
|
+
* overlaps that label's glyphs). Half opacity keeps the labels legible and reads
|
|
87
|
+
* the bridge as subordinate chrome — the weight the flag cursor's staffs already
|
|
88
|
+
* use for "this line only connects two things I have drawn".
|
|
89
|
+
*
|
|
90
|
+
* Only drawn when `offset > 0`: at offset `0` the pill already touches the plot
|
|
91
|
+
* edge where the line ends, so a connector would be zero-length ink over a tick
|
|
92
|
+
* label for nothing.
|
|
50
93
|
*/
|
|
51
|
-
export declare function
|
|
94
|
+
export declare function axisPillConnector(side: 'left' | 'right', plotWidth: number, offset: number, color: string): CSSProperties;
|
|
52
95
|
/**
|
|
53
96
|
* Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
|
|
54
97
|
* `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
|