@pond-ts/charts 0.59.0 → 0.61.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 +21 -16
- package/CHANGELOG.md +238 -1
- package/dist/AreaChart.d.ts +53 -1
- package/dist/AreaChart.js +16 -3
- package/dist/BarChart.js +6 -61
- package/dist/BarList.d.ts +22 -0
- package/dist/BarList.js +42 -9
- package/dist/CategoryAxis.d.ts +8 -4
- package/dist/CategoryAxis.js +8 -4
- package/dist/ChartContainer.d.ts +175 -3
- package/dist/ChartContainer.js +190 -11
- package/dist/ChartRow.js +2 -0
- package/dist/Layers.js +14 -4
- package/dist/XAxis.d.ts +47 -3
- package/dist/XAxis.js +165 -41
- package/dist/YAxis.d.ts +15 -1
- package/dist/YAxis.js +31 -4
- package/dist/area.d.ts +43 -1
- package/dist/area.js +122 -5
- package/dist/axis-events.d.ts +106 -0
- package/dist/axis-events.js +56 -0
- package/dist/context.d.ts +35 -2
- package/dist/format.d.ts +1 -1
- package/dist/format.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/theme.d.ts +22 -0
- package/dist/theme.js +3 -0
- package/dist/use-band-ladder.d.ts +30 -0
- package/dist/use-band-ladder.js +81 -0
- package/dist/useChartFrame.d.ts +122 -0
- package/dist/useChartFrame.js +155 -0
- package/dist/useChartLegend.d.ts +8 -0
- package/dist/viewport.d.ts +35 -2
- package/dist/viewport.js +53 -6
- package/dist/yticks.d.ts +5 -1
- package/dist/yticks.js +5 -1
- package/package.json +3 -3
package/dist/Layers.js
CHANGED
|
@@ -9,7 +9,8 @@ import { resolveSelection } from './select.js';
|
|
|
9
9
|
import { isDev } from './dev.js';
|
|
10
10
|
import { useIndexedChildren } from './child-index.js';
|
|
11
11
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
12
|
-
|
|
12
|
+
// Aliased: `tickValues` is already a local map of per-axis explicit ticks.
|
|
13
|
+
import { tickValues as axisTickValues } from './yticks.js';
|
|
13
14
|
import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
|
|
14
15
|
/** Fallback **y**-gridline tick count, used only before the row publishes its
|
|
15
16
|
* resolved `tickCounts` (pre-registration). Normally the gridlines read the
|
|
@@ -240,7 +241,7 @@ export function Layers({ children }) {
|
|
|
240
241
|
// a gridline sits under every `<YAxis>` label and no more.
|
|
241
242
|
const yCount = tickCounts.get(defaultAxisId) ?? GRID_TICKS;
|
|
242
243
|
const yTicks = gridY && !(yIsCategory && explicitY === undefined)
|
|
243
|
-
? (explicitY ??
|
|
244
|
+
? (explicitY ?? axisTickValues(gridY, yCount)).map((t) => gridY(t))
|
|
244
245
|
: [];
|
|
245
246
|
// On a calendar axis the verticals are the FULL grain populations —
|
|
246
247
|
// every day in the month, every month in the year, every aligned
|
|
@@ -1078,7 +1079,13 @@ export function Layers({ children }) {
|
|
|
1078
1079
|
else {
|
|
1079
1080
|
const span = drag.startRange[1] - drag.startRange[0];
|
|
1080
1081
|
const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
|
|
1081
|
-
|
|
1082
|
+
// A log x pans by ratio, not by offset — see `ViewportOptions`.
|
|
1083
|
+
// `snap` follows: whole-millisecond snapping is a time-axis rule and
|
|
1084
|
+
// wrong for any value axis, log or not.
|
|
1085
|
+
c.applyRange(panRange(drag.startRange, dt, {
|
|
1086
|
+
log: c.xIsLog,
|
|
1087
|
+
snap: c.xKind === 'time',
|
|
1088
|
+
}));
|
|
1082
1089
|
}
|
|
1083
1090
|
return; // tracker suppressed during a pan
|
|
1084
1091
|
}
|
|
@@ -1467,7 +1474,10 @@ export function Layers({ children }) {
|
|
|
1467
1474
|
// the minimum visible *trading* time (ms of open-market time)
|
|
1468
1475
|
// rather than wall-clock ms — the sensible meaning for this axis.
|
|
1469
1476
|
zoomRangeTrading(c.timeRange, pivot, f, c.discontinuities, c.minDuration)
|
|
1470
|
-
: zoomRange(c.timeRange, pivot, f, c.minDuration
|
|
1477
|
+
: zoomRange(c.timeRange, pivot, f, c.minDuration, {
|
|
1478
|
+
log: c.xIsLog,
|
|
1479
|
+
snap: c.xKind === 'time',
|
|
1480
|
+
});
|
|
1471
1481
|
// ── The aspect lock has to be NEGOTIATED, not asserted ────────────────
|
|
1472
1482
|
// Both axes zooming by "the same factor" only holds the ratio while both
|
|
1473
1483
|
// can actually take that factor. Each has its own limit — y cannot zoom
|
package/dist/XAxis.d.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { type AxisTransform } from './derivedTicks.js';
|
|
2
2
|
import { type AxisFormat } from './format.js';
|
|
3
|
+
import { type AxisMouseHandler } from './axis-events.js';
|
|
4
|
+
/** One placed tick — its plot-pixel x, the text to draw, and (stacked band
|
|
5
|
+
* style) whether it sits on a band turn and renders emphasized. */
|
|
6
|
+
interface PlacedTick {
|
|
7
|
+
readonly x: number;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
/** This tick opens a coarser calendar period (a day/month/year turn), so it
|
|
10
|
+
* renders emphasized (bold): a band turn in stacked, an inline promotion in
|
|
11
|
+
* flat — the same boundaries in both styles. */
|
|
12
|
+
readonly bold?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Thin + truncate a **category** axis's labels so a dense axis stays legible
|
|
16
|
+
* — triggered by **measured geometry**, not category count ([PND-CATFIT]):
|
|
17
|
+
* keep every `stride`-th label, and middle-ellipsize a kept one that still
|
|
18
|
+
* overruns its room. `stride` is the fewest slots the widest label needs once
|
|
19
|
+
* it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
|
|
20
|
+
* every full label and long ones trade thinning against truncation instead of
|
|
21
|
+
* overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
|
|
22
|
+
* `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
|
|
23
|
+
* a later option.
|
|
24
|
+
*
|
|
25
|
+
* Exported for tests only — not re-exported from the package index.
|
|
26
|
+
*/
|
|
27
|
+
export declare function thinCategoryLabels(ticks: readonly PlacedTick[], slot: number, plotWidth: number, fontSize: number, fontFamily: string): PlacedTick[];
|
|
3
28
|
export interface XAxisProps {
|
|
4
29
|
/**
|
|
5
30
|
* Tick / cursor value formatting — a d3 format/time specifier string or a
|
|
@@ -59,8 +84,10 @@ export interface XAxisProps {
|
|
|
59
84
|
* - `'auto'` — centred, but the first label left-anchors and the last
|
|
60
85
|
* right-anchors so the edge labels stay inside the plot (the old default).
|
|
61
86
|
* - `'right'` — the label sits to the **right** of an extended tick that
|
|
62
|
-
* drops from the axis line (label beside the tick, not under it) —
|
|
63
|
-
*
|
|
87
|
+
* drops from the axis line (label beside the tick, not under it) — a
|
|
88
|
+
* *style* choice (the TradingView look). It re-anchors without measuring,
|
|
89
|
+
* so it is **not** a remedy for colliding labels; on a category axis the
|
|
90
|
+
* measured fit (thin + middle-ellipsize) is what prevents collisions.
|
|
64
91
|
*/
|
|
65
92
|
align?: 'auto' | 'center' | 'right';
|
|
66
93
|
/**
|
|
@@ -80,6 +107,22 @@ export interface XAxisProps {
|
|
|
80
107
|
* on each turn is emphasized and joins its divider as one boundary line.
|
|
81
108
|
*/
|
|
82
109
|
dateStyle?: 'flat' | 'stacked';
|
|
110
|
+
/**
|
|
111
|
+
* Mouse events on the axis strip, with the **axis value under the pointer**
|
|
112
|
+
* ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click on a time axis reports the instant it
|
|
113
|
+
* landed on, a click on a category axis reports the category. The lever for
|
|
114
|
+
* axis-driven UI: pick a date by clicking its tick, open a menu on the strip
|
|
115
|
+
* (`event.type === 'contextmenu'`), drill into a category.
|
|
116
|
+
*
|
|
117
|
+
* **One handler takes every mouse event** — click, double-click, context
|
|
118
|
+
* menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
|
|
119
|
+
* attached when the prop is omitted, so the move events cost nothing unless
|
|
120
|
+
* you ask for them.
|
|
121
|
+
*
|
|
122
|
+
* The x strip has no `id` (only a `<YAxis>` does); to distinguish stacked
|
|
123
|
+
* axes, close over it: `onMouseEvent={(e) => onAxis('delta', e)}`.
|
|
124
|
+
*/
|
|
125
|
+
onMouseEvent?: AxisMouseHandler;
|
|
83
126
|
}
|
|
84
127
|
/**
|
|
85
128
|
* The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
|
|
@@ -91,5 +134,6 @@ export interface XAxisProps {
|
|
|
91
134
|
*
|
|
92
135
|
* `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
|
|
93
136
|
*/
|
|
94
|
-
export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
137
|
+
export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
138
|
+
export {};
|
|
95
139
|
//# sourceMappingURL=XAxis.d.ts.map
|
package/dist/XAxis.js
CHANGED
|
@@ -3,9 +3,11 @@ import { Fragment, useContext } from 'react';
|
|
|
3
3
|
import { scaleLinear } from 'd3-scale';
|
|
4
4
|
import { derivedTicks } from './derivedTicks.js';
|
|
5
5
|
import { ContainerContext, CursorContext, } from './context.js';
|
|
6
|
+
import { tickValues } from './yticks.js';
|
|
6
7
|
import { xAxisCursorEntries } from './cursors.js';
|
|
7
8
|
import { axisPillStyle } from './chip.js';
|
|
8
9
|
import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
|
|
10
|
+
import { axisMouseProps, axisPointerPx, } from './axis-events.js';
|
|
9
11
|
/** Tick strip height (mark + value label) in CSS px. */
|
|
10
12
|
const TICK_STRIP = 22;
|
|
11
13
|
/** Extra height reserved for an axis `label` line. */
|
|
@@ -17,32 +19,132 @@ const BAND_STRIP = 20;
|
|
|
17
19
|
* ladder's per-tick budget (a hair tighter: derived labels are short). */
|
|
18
20
|
const TRANSFORM_TICK_PX = 48;
|
|
19
21
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* Measure a category label's rendered width in the axis font. A shared
|
|
23
|
+
* offscreen canvas gives the browser's own metric — the thing the old
|
|
24
|
+
* per-character estimate could only approximate, and approximated low on
|
|
25
|
+
* exactly the labels category axes carry (all-caps keys with digits and
|
|
26
|
+
* hyphens), which let "fits by the estimate" labels overprint on screen
|
|
27
|
+
* ([PND-CATFIT]). Falls back to the estimate where no canvas backend exists
|
|
28
|
+
* (SSR, test DOMs). Results are cached per font+text; a webfont that loads
|
|
29
|
+
* after first measure keeps its fallback-font metric until the cache turns
|
|
30
|
+
* over, which the fit's inter-label gap absorbs.
|
|
26
31
|
*/
|
|
27
|
-
|
|
32
|
+
const measureCache = new Map();
|
|
33
|
+
// Module state: the first render's canvas context is kept for the process
|
|
34
|
+
// lifetime. In tests this captures whatever canvas stub is installed at first
|
|
35
|
+
// measure — harmless while stubs measure 0 (the estimate fallback takes over
|
|
36
|
+
// per call), but a future *nonzero* canvas mock would need a reset hook here.
|
|
37
|
+
let measureCtx;
|
|
38
|
+
function labelWidth(text, font, fontSize) {
|
|
39
|
+
const key = `${font}|${text}`;
|
|
40
|
+
const hit = measureCache.get(key);
|
|
41
|
+
if (hit !== undefined)
|
|
42
|
+
return hit;
|
|
43
|
+
if (measureCtx === undefined) {
|
|
44
|
+
try {
|
|
45
|
+
measureCtx =
|
|
46
|
+
typeof document === 'undefined'
|
|
47
|
+
? null
|
|
48
|
+
: (document.createElement('canvas').getContext('2d') ?? null);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
measureCtx = null; // a DOM shim whose getContext throws → estimate path
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let w = 0;
|
|
55
|
+
if (measureCtx !== null) {
|
|
56
|
+
measureCtx.font = font;
|
|
57
|
+
w = measureCtx.measureText(text).width;
|
|
58
|
+
}
|
|
59
|
+
// No backend, or a mock that measures everything at 0 → per-glyph estimate.
|
|
60
|
+
if (!(w > 0))
|
|
61
|
+
w = text.length * fontSize * 0.62;
|
|
62
|
+
if (measureCache.size > 4096)
|
|
63
|
+
measureCache.clear();
|
|
64
|
+
measureCache.set(key, w);
|
|
65
|
+
return w;
|
|
66
|
+
}
|
|
67
|
+
/** Minimum clear space between two neighbouring drawn labels, px. */
|
|
68
|
+
const LABEL_GAP = 4;
|
|
69
|
+
/**
|
|
70
|
+
* A kept label may ellipsize down to this fraction of its full width before
|
|
71
|
+
* the fit prefers dropping labels (growing `stride`) instead — below it, the
|
|
72
|
+
* text no longer identifies its category.
|
|
73
|
+
*/
|
|
74
|
+
const TRUNC_KEEP = 0.6;
|
|
75
|
+
/**
|
|
76
|
+
* Ellipsize `text` from the **middle** to fit `room` px: category keys often
|
|
77
|
+
* share a prefix and differ in the tail (or the reverse), so keeping both ends
|
|
78
|
+
* preserves whichever part distinguishes — end-truncation makes shared-prefix
|
|
79
|
+
* keys visually identical. Head-heavy split (60/40). Binary search on the kept
|
|
80
|
+
* **code-point** count (a UTF-16 `slice` could split a surrogate pair and
|
|
81
|
+
* emit mojibake); the result is only accepted when it *measures* within
|
|
82
|
+
* `room`, so the returned label can never overrun its space.
|
|
83
|
+
*/
|
|
84
|
+
function ellipsizeMiddle(text, room, font, fontSize) {
|
|
85
|
+
const cp = Array.from(text); // code points, not UTF-16 units
|
|
86
|
+
let lo = 1;
|
|
87
|
+
let hi = cp.length - 1;
|
|
88
|
+
let best = '…';
|
|
89
|
+
while (lo <= hi) {
|
|
90
|
+
const k = (lo + hi) >> 1;
|
|
91
|
+
const head = Math.ceil(k * 0.6);
|
|
92
|
+
const tail = k - head;
|
|
93
|
+
const s = cp.slice(0, head).join('') +
|
|
94
|
+
'…' +
|
|
95
|
+
(tail > 0 ? cp.slice(cp.length - tail).join('') : '');
|
|
96
|
+
if (labelWidth(s, font, fontSize) <= room) {
|
|
97
|
+
best = s;
|
|
98
|
+
lo = k + 1;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
hi = k - 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return best;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Thin + truncate a **category** axis's labels so a dense axis stays legible
|
|
108
|
+
* — triggered by **measured geometry**, not category count ([PND-CATFIT]):
|
|
109
|
+
* keep every `stride`-th label, and middle-ellipsize a kept one that still
|
|
110
|
+
* overruns its room. `stride` is the fewest slots the widest label needs once
|
|
111
|
+
* it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
|
|
112
|
+
* every full label and long ones trade thinning against truncation instead of
|
|
113
|
+
* overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
|
|
114
|
+
* `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
|
|
115
|
+
* a later option.
|
|
116
|
+
*
|
|
117
|
+
* Exported for tests only — not re-exported from the package index.
|
|
118
|
+
*/
|
|
119
|
+
export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily) {
|
|
28
120
|
const n = ticks.length;
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
if (!(slot > 0))
|
|
34
|
-
return [
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
121
|
+
// Degenerate / pre-layout width: nothing can be legible, so draw NO labels.
|
|
122
|
+
// The old passthrough here was the collapsed-panel smear: these are
|
|
123
|
+
// absolutely-positioned `nowrap` divs, so at width ≈ 0 every label rendered
|
|
124
|
+
// full-length at x ≈ 0, overflowing the strip and overprinting.
|
|
125
|
+
if (!(slot > 0) || !(plotWidth > 0))
|
|
126
|
+
return [];
|
|
127
|
+
const font = `${fontSize}px ${fontFamily}`;
|
|
128
|
+
const widths = ticks.map((t) => labelWidth(t.label, font, fontSize));
|
|
129
|
+
const maxW = widths.reduce((m, w) => Math.max(m, w), 0);
|
|
130
|
+
// The width a kept label must be allowed: its full measure when that's
|
|
131
|
+
// modest, else the legibility floor — TRUNC_KEEP of it, but never less than
|
|
132
|
+
// ~two glyphs of text.
|
|
133
|
+
const required = Math.min(maxW, Math.max(maxW * TRUNC_KEEP, fontSize * 2));
|
|
134
|
+
const stride = Math.max(1, Math.ceil((required + LABEL_GAP) / slot));
|
|
135
|
+
const room = Math.min(slot * stride - LABEL_GAP, plotWidth - LABEL_GAP);
|
|
136
|
+
// Not even ~two glyphs fit (a collapsed strip) — an empty axis over a smear.
|
|
137
|
+
if (!(room >= fontSize * 2))
|
|
138
|
+
return [];
|
|
39
139
|
const out = [];
|
|
40
140
|
for (let i = 0; i < n; i += stride) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
141
|
+
const t = ticks[i];
|
|
142
|
+
const label = widths[i] <= room
|
|
143
|
+
? t.label
|
|
144
|
+
: ellipsizeMiddle(t.label, room, font, fontSize);
|
|
145
|
+
// A bare ellipsis identifies nothing — leave that tick unlabeled.
|
|
146
|
+
if (label !== '…')
|
|
147
|
+
out.push({ x: t.x, label });
|
|
46
148
|
}
|
|
47
149
|
return out;
|
|
48
150
|
}
|
|
@@ -56,7 +158,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
|
|
|
56
158
|
*
|
|
57
159
|
* `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
|
|
58
160
|
*/
|
|
59
|
-
export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', } = {}) {
|
|
161
|
+
export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
|
|
60
162
|
const container = useContext(ContainerContext);
|
|
61
163
|
if (container === null) {
|
|
62
164
|
throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -261,24 +363,33 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
261
363
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
262
364
|
: derived !== null
|
|
263
365
|
? honestDerived()
|
|
264
|
-
: xScale.ticks
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
366
|
+
: // `tickValues`, not `xScale.ticks` — d3's raw `scaleLog.ticks()` is
|
|
367
|
+
// nearly a step function (see `yticks.ts`), so a log x needs the same
|
|
368
|
+
// decade ladder the y axis already builds. For a time or linear scale
|
|
369
|
+
// it defers to `scale.ticks(count)`, so this is a no-op there.
|
|
370
|
+
tickValues(xScale, xTickCount).map((d) => ({
|
|
371
|
+
x: xScale(d),
|
|
372
|
+
label: tickFmt(+d),
|
|
373
|
+
// A **period turn** renders emphasized (bold), consistently across
|
|
374
|
+
// styles: in stacked, a tick on a band divider (matched by pixel);
|
|
375
|
+
// in flat, a tick whose label was *promoted* to a coarser period
|
|
376
|
+
// (its flat label differs from the terse base) — the same boundaries,
|
|
377
|
+
// so `Feb` / `2026` read as strong in flat just as the band turns do.
|
|
378
|
+
bold: stacked
|
|
379
|
+
? dividerXs.has(Math.round(xScale(d)))
|
|
380
|
+
: flatFmt !== undefined &&
|
|
381
|
+
baseFmt !== undefined &&
|
|
382
|
+
flatFmt(+d) !== baseFmt(+d),
|
|
383
|
+
}));
|
|
278
384
|
// A category axis ticks once per category; thin + truncate its labels when they
|
|
279
|
-
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
280
|
-
|
|
281
|
-
|
|
385
|
+
// crowd (an explicit `customTicks` axis keeps its labels verbatim). The slot is
|
|
386
|
+
// the scale's own band pitch — under `maxBandWidth` packing it is narrower than
|
|
387
|
+
// `plotWidth / n`, and the fit must measure against the pitch labels actually
|
|
388
|
+
// sit on.
|
|
389
|
+
const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 0
|
|
390
|
+
? thinCategoryLabels(rawTicks, 'bandwidth' in xScale
|
|
391
|
+
? xScale.bandwidth()
|
|
392
|
+
: plotWidth / rawTicks.length, plotWidth, theme.font.size, theme.font.family)
|
|
282
393
|
: rawTicks;
|
|
283
394
|
const onTop = side === 'top';
|
|
284
395
|
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
@@ -300,7 +411,20 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
300
411
|
const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
|
|
301
412
|
(hasBands ? BAND_STRIP : 0) +
|
|
302
413
|
maxPillLane * PILL_LANE_H;
|
|
303
|
-
|
|
414
|
+
// The axis coordinate under the pointer, for `onMouseEvent`. The strip is
|
|
415
|
+
// laid out flush with the plot (the left gutter is its margin, its width is
|
|
416
|
+
// `plotWidth`), so a strip-local pixel inverts straight through the shared x
|
|
417
|
+
// scale — no gutter arithmetic. The label reads the same channel a cursor
|
|
418
|
+
// pill does: the band scale's category name on a category axis (a d3 number
|
|
419
|
+
// format can't name one), this axis's readout format everywhere else.
|
|
420
|
+
const mouse = axisMouseProps(onMouseEvent, 'x', undefined, (event) => {
|
|
421
|
+
const value = +xScale.invert(axisPointerPx(event, 'x', [0, plotWidth]));
|
|
422
|
+
return {
|
|
423
|
+
value,
|
|
424
|
+
label: xKind === 'category' ? fmt(value) : readoutFmt(value),
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
return (_jsxs("div", { "data-axis": "x", ...mouse, style: {
|
|
304
428
|
position: 'relative',
|
|
305
429
|
marginLeft: `${leftGutter}px`,
|
|
306
430
|
width: `${plotWidth}px`,
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AxisFormat } from './format.js';
|
|
2
|
+
import { type AxisMouseHandler } from './axis-events.js';
|
|
2
3
|
export interface YAxisProps {
|
|
3
4
|
/** Identifier a chart links to via its `axis` prop (and the first declared is
|
|
4
5
|
* the row's default). */
|
|
@@ -197,6 +198,19 @@ export interface YAxisProps {
|
|
|
197
198
|
* colours. Presentation-only: it never re-registers the axis.
|
|
198
199
|
*/
|
|
199
200
|
color?: string;
|
|
201
|
+
/**
|
|
202
|
+
* Mouse events on this axis's gutter, with the **axis value under the
|
|
203
|
+
* pointer** ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click reports the value it landed
|
|
204
|
+
* on, and this axis's `id`, so one handler can serve several axes. The lever
|
|
205
|
+
* for axis-driven UI: set a threshold by clicking the gutter, open a scale
|
|
206
|
+
* menu (`event.type === 'contextmenu'`), drill into a categorical row.
|
|
207
|
+
*
|
|
208
|
+
* **One handler takes every mouse event** — click, double-click, context
|
|
209
|
+
* menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
|
|
210
|
+
* attached when the prop is omitted, so the move events cost nothing unless
|
|
211
|
+
* you ask for them. A `hide`den axis draws no gutter and so fires nothing.
|
|
212
|
+
*/
|
|
213
|
+
onMouseEvent?: AxisMouseHandler;
|
|
200
214
|
/**
|
|
201
215
|
* @internal Declaration position among the row's children, injected by
|
|
202
216
|
* `ChartRow` so the first-declared axis stays the default. Do not set.
|
|
@@ -211,5 +225,5 @@ export interface YAxisProps {
|
|
|
211
225
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
212
226
|
* (default: the first axis).
|
|
213
227
|
*/
|
|
214
|
-
export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
|
|
228
|
+
export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
|
|
215
229
|
//# sourceMappingURL=YAxis.d.ts.map
|
package/dist/YAxis.js
CHANGED
|
@@ -3,7 +3,8 @@ import { useContext, useEffect, useMemo } from 'react';
|
|
|
3
3
|
import { ContainerContext, RowContext } from './context.js';
|
|
4
4
|
import { resolveAxisFormat } from './format.js';
|
|
5
5
|
import { useSlotKey } from './use-slot-key.js';
|
|
6
|
-
import {
|
|
6
|
+
import { tickValues } from './yticks.js';
|
|
7
|
+
import { axisMouseProps, axisPointerPx, } from './axis-events.js';
|
|
7
8
|
const DEFAULT_WIDTH = 50;
|
|
8
9
|
/** Fallback tick count before the row has published its resolved count (the
|
|
9
10
|
* first render, pre-registration). The row's height-derived value takes over
|
|
@@ -17,7 +18,7 @@ const DEFAULT_TICK_COUNT = 5;
|
|
|
17
18
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
18
19
|
* (default: the first axis).
|
|
19
20
|
*/
|
|
20
|
-
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, index = 0, }) {
|
|
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, }) {
|
|
21
22
|
const container = useContext(ContainerContext);
|
|
22
23
|
if (container === null) {
|
|
23
24
|
throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -114,7 +115,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
114
115
|
? ticks.map((t) => ({ value: t.at, label: t.label }))
|
|
115
116
|
: layerCategories !== null
|
|
116
117
|
? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
|
|
117
|
-
: (yScale ?
|
|
118
|
+
: (yScale ? tickValues(yScale, count) : []).map((t) => ({
|
|
118
119
|
value: t,
|
|
119
120
|
label: fmt(t),
|
|
120
121
|
}));
|
|
@@ -124,7 +125,33 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
|
|
|
124
125
|
// axes line up column-by-column. Keyed by this instance's slot key (not `id`,
|
|
125
126
|
// which may repeat across a mirror). Falls back to own width until reserved.
|
|
126
127
|
const slotWidth = row.axisSlots.get(slot) ?? width;
|
|
127
|
-
|
|
128
|
+
// The axis value under the pointer, for `onMouseEvent` — read on the **slot**
|
|
129
|
+
// box (below), so the whole reserved gutter answers, not just this axis's own
|
|
130
|
+
// narrower content. The box is exactly the row's height and shares its top
|
|
131
|
+
// edge with the plot, so a box-local pixel inverts straight through this
|
|
132
|
+
// axis's scale. Before the row has published one there is no value to report
|
|
133
|
+
// and the event is dropped. A categorical row labels by slot, matching its
|
|
134
|
+
// ticks; every other row reads this axis's own tick format.
|
|
135
|
+
const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
|
|
136
|
+
if (!yScale)
|
|
137
|
+
return null;
|
|
138
|
+
// Clamp on the **scale's** range, not the box: a row with a
|
|
139
|
+
// `labelPlacement="top"` axis reserves a header, so the range is
|
|
140
|
+
// `[height, topHeader]` while the box still starts at 0 (`ChartRow`).
|
|
141
|
+
const [r0, r1] = yScale.range();
|
|
142
|
+
const value = yScale.invert(axisPointerPx(event, 'y', [r0, r1]));
|
|
143
|
+
return {
|
|
144
|
+
value,
|
|
145
|
+
label: layerCategories !== null
|
|
146
|
+
? // A slot index, clamped to a real category: the domain's top edge
|
|
147
|
+
// inverts to exactly `n` (and rounding can nudge the bottom below
|
|
148
|
+
// 0), which no category occupies — the nearest one is the honest
|
|
149
|
+
// answer, matching the band scale's own `invert`.
|
|
150
|
+
(layerCategories[Math.min(layerCategories.length - 1, Math.max(0, Math.floor(value)))] ?? '')
|
|
151
|
+
: fmt(value),
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse, style: {
|
|
128
155
|
flex: `0 0 ${slotWidth}px`,
|
|
129
156
|
display: 'flex',
|
|
130
157
|
justifyContent: side === 'left' ? 'flex-end' : 'flex-start',
|
package/dist/area.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CurveFactory } from 'd3-shape';
|
|
2
2
|
import type { ChartSeries } from './data.js';
|
|
3
3
|
import { type Scale, type TraceState } from './line.js';
|
|
4
|
+
import type { BandLadder } from './bars.js';
|
|
4
5
|
import type { AreaStyle } from './theme.js';
|
|
5
6
|
import type { LayerDrawStats } from './context.js';
|
|
6
7
|
import { type GapMode } from './gaps.js';
|
|
@@ -69,7 +70,48 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
|
|
|
69
70
|
* bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
|
|
70
71
|
* are collected by one O(N) walk ({@link collectGapEdges}).
|
|
71
72
|
*/
|
|
72
|
-
export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): LayerDrawStats;
|
|
73
|
+
export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption, banding?: BandLadder): LayerDrawStats;
|
|
74
|
+
/**
|
|
75
|
+
* The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
|
|
76
|
+
* vertical **hard-stop gradient in pixel space**, a colour switch at every
|
|
77
|
+
* threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
|
|
78
|
+
* magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
|
|
79
|
+
* as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
|
|
80
|
+
* same currency `drawBars` takes.
|
|
81
|
+
*
|
|
82
|
+
* A gradient rather than one clipped redraw per band, and that is the
|
|
83
|
+
* load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
|
|
84
|
+
* themselves at every boundary with an antialiased seam, where a gradient
|
|
85
|
+
* draws the identical single path once and costs O(K) colour stops. It also
|
|
86
|
+
* bands the **outline for free** — `strokeStyle` takes the same gradient, so
|
|
87
|
+
* the value line switches hue exactly at a crossing, which no per-band clip
|
|
88
|
+
* can do without shearing the stroke.
|
|
89
|
+
*
|
|
90
|
+
* The ladder is walked on the **magnitude** and mirrored below zero, exactly
|
|
91
|
+
* as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
|
|
92
|
+
* zero side) from band `k + 1` (the away side). Whether "away from zero" is up
|
|
93
|
+
* or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
|
|
94
|
+
* positive and finite by construction), so a flipped axis bands correctly. A
|
|
95
|
+
* boundary with **no position** on the scale contributes no crossing — on a
|
|
96
|
+
* log axis the negative mirrors (and zero) simply don't exist, which is the
|
|
97
|
+
* right reading. A crossing **off the plot** clamps to the gradient's ends
|
|
98
|
+
* (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
|
|
99
|
+
* zoomed-in view honest: with every visible pixel inside one band, the clamp
|
|
100
|
+
* degenerates the other stops and the whole plot paints that band's colour.
|
|
101
|
+
*
|
|
102
|
+
* Falls back to the top band's flat colour when there is nothing to anchor on
|
|
103
|
+
* (no plot height, or no boundary with a position at all) — reachable only
|
|
104
|
+
* with a degenerate scale stub, since every real axis positions a positive
|
|
105
|
+
* finite value; any flat colour is equally (in)correct there, and the top
|
|
106
|
+
* band's is at least stable.
|
|
107
|
+
*
|
|
108
|
+
* Like the bar ladder, breakpoints are **absolute data values**, so the
|
|
109
|
+
* baseline plays no part here: an area resting on a non-zero floor still bands
|
|
110
|
+
* at the same heights as its neighbours — measuring from the resolved baseline
|
|
111
|
+
* instead would silently shift every breakpoint by the floor, the quiet
|
|
112
|
+
* wrongness [PND-BANDBAR2] exists to remove.
|
|
113
|
+
*/
|
|
114
|
+
export declare function buildBandGradient(ctx: CanvasRenderingContext2D, yScale: Scale, plotHeight: number, banding: BandLadder): CanvasGradient | string;
|
|
73
115
|
/**
|
|
74
116
|
* **Is the pointer inside this area?** The filled-region counterpart of
|
|
75
117
|
* `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
|