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