@pond-ts/charts 0.58.0 → 0.60.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 +580 -0
- package/CHANGELOG.md +339 -1
- package/dist/AreaChart.d.ts +53 -1
- package/dist/AreaChart.js +16 -3
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +88 -73
- package/dist/BarList.d.ts +22 -0
- package/dist/BarList.js +42 -9
- package/dist/ChartContainer.d.ts +175 -3
- package/dist/ChartContainer.js +190 -11
- package/dist/ChartRow.js +92 -2
- package/dist/Layers.js +14 -4
- package/dist/XAxis.js +19 -14
- package/dist/YAxis.d.ts +58 -2
- package/dist/YAxis.js +5 -3
- package/dist/area.d.ts +43 -1
- package/dist/area.js +122 -5
- package/dist/bars.d.ts +10 -3
- package/dist/bars.js +13 -9
- package/dist/context.d.ts +46 -8
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/format.d.ts +16 -1
- package/dist/format.js +17 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +11 -0
- package/dist/range.d.ts +14 -1
- package/dist/range.js +24 -3
- package/dist/theme.d.ts +80 -4
- 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 +8 -1
- package/dist/yticks.js +109 -1
- package/package.json +6 -5
package/dist/ChartContainer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
-
import { scaleLinear } from 'd3-scale';
|
|
3
|
+
import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
|
|
4
4
|
import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
|
|
5
5
|
import { scaleBand } from './bandScale.js';
|
|
6
6
|
import { scaleElapsed } from './elapsed.js';
|
|
@@ -52,8 +52,75 @@ function normalizeRange(range) {
|
|
|
52
52
|
* the shared time `xScale`. It renders its rows (separated by `rowGap`) then one
|
|
53
53
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
54
54
|
* (`<YAxis>`).
|
|
55
|
+
*
|
|
56
|
+
* A `width` in pixels renders straight through; `'auto'` (or an omitted
|
|
57
|
+
* `width`) measures the available width first — see {@link
|
|
58
|
+
* ChartContainerProps.width} and {@link AutoWidthContainer}.
|
|
55
59
|
*/
|
|
56
|
-
export function ChartContainer(
|
|
60
|
+
export function ChartContainer(props) {
|
|
61
|
+
const { width } = props;
|
|
62
|
+
// The measure pass is a *different component* rather than a branch inside
|
|
63
|
+
// the resolved one, because the resolved container may not render at all
|
|
64
|
+
// until a width exists — and ~60 hooks cannot be conditional. Choosing the
|
|
65
|
+
// component by the prop's kind (number vs auto) means flipping a container
|
|
66
|
+
// between fixed and auto remounts it; that is a layout change, and a
|
|
67
|
+
// remount is the honest response to one.
|
|
68
|
+
if (typeof width === 'number') {
|
|
69
|
+
return _jsx(ResolvedChartContainer, { ...props, width: width });
|
|
70
|
+
}
|
|
71
|
+
return _jsx(AutoWidthContainer, { ...props });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `width="auto"` half: render a plain full-width box, measure it, and
|
|
75
|
+
* mount the chart at that pixel width.
|
|
76
|
+
*
|
|
77
|
+
* Three details, each of which the shipped
|
|
78
|
+
* [responsive-width recipe](https://pond-ts.github.io/pond/docs/recipes/responsive-width)
|
|
79
|
+
* had to spell out for consumers and which now hold by construction:
|
|
80
|
+
*
|
|
81
|
+
* 1. **`useLayoutEffect`, so the first real width lands before paint** — no
|
|
82
|
+
* flash of the empty box.
|
|
83
|
+
* 2. **Measure synchronously on mount, then let `ResizeObserver` take over.**
|
|
84
|
+
* RO's own first callback is not guaranteed to fire promptly in every
|
|
85
|
+
* browser; relying on it alone can leave a chart that never mounts.
|
|
86
|
+
* 3. **The measured box is plain.** No padding, no border — so
|
|
87
|
+
* `getBoundingClientRect().width` is the content width, and the chart can
|
|
88
|
+
* never overflow its own measurement. A caller who wants a bordered frame
|
|
89
|
+
* puts it on a wrapper *outside* the container.
|
|
90
|
+
*/
|
|
91
|
+
function AutoWidthContainer(props) {
|
|
92
|
+
const boxRef = useRef(null);
|
|
93
|
+
const [measured, setMeasured] = useState(0);
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const el = boxRef.current;
|
|
96
|
+
if (el === null)
|
|
97
|
+
return;
|
|
98
|
+
const measure = () => setMeasured((prev) => {
|
|
99
|
+
const next = Math.round(el.getBoundingClientRect().width);
|
|
100
|
+
// **Latch the last non-zero width.** A box measures 0 whenever it is
|
|
101
|
+
// not laid out — most often because an ancestor went `display: none`
|
|
102
|
+
// (a tab switch, a collapsed accordion), which is a *hidden* chart,
|
|
103
|
+
// not a resized one. Writing that 0 through would unmount the resolved
|
|
104
|
+
// container and discard everything it owns: pan/zoom position,
|
|
105
|
+
// selection, hover, and every layer's memoized draw state, all
|
|
106
|
+
// rebuilt on the way back. Keeping the stale width holds the chart
|
|
107
|
+
// mounted through the hide, and the next real measurement corrects it.
|
|
108
|
+
return next > 0 ? next : prev;
|
|
109
|
+
});
|
|
110
|
+
measure();
|
|
111
|
+
// Guarded rather than assumed: a non-browser render target (SSR, an older
|
|
112
|
+
// test DOM) has no ResizeObserver, and a chart that measured once is a far
|
|
113
|
+
// better failure than one that throws on mount.
|
|
114
|
+
if (typeof ResizeObserver === 'undefined')
|
|
115
|
+
return;
|
|
116
|
+
const ro = new ResizeObserver(measure);
|
|
117
|
+
ro.observe(el);
|
|
118
|
+
return () => ro.disconnect();
|
|
119
|
+
}, []);
|
|
120
|
+
return (_jsx("div", { ref: boxRef, style: { width: '100%' }, children: measured > 0 && _jsx(ResolvedChartContainer, { ...props, width: measured }) }));
|
|
121
|
+
}
|
|
122
|
+
/** {@link ChartContainer} with its width resolved to a concrete pixel number. */
|
|
123
|
+
function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
|
|
57
124
|
// ── Legacy cursor props (deprecated) ───────────────────────────────────────
|
|
58
125
|
// The string surface keeps working for one minor: the resolved mode is
|
|
59
126
|
// synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
|
|
@@ -61,6 +128,19 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
61
128
|
// *explicitly* set (never on the defaults). Mounted cursor components in the
|
|
62
129
|
// same scope override the shim. See docs/rfcs/interaction.md §9 / A4.4.
|
|
63
130
|
const cursor = cursorProp ?? DEFAULT_CURSOR_MODE;
|
|
131
|
+
// [PND-IGNITECAT] The declared slot list, normalized to `null` when absent
|
|
132
|
+
// and held by **content** identity. An inline `categories={['a', 'b']}` is a
|
|
133
|
+
// fresh array every render; keying the kind/scale memos off the raw prop
|
|
134
|
+
// would rebuild the band scale — and therefore repaint every row — on every
|
|
135
|
+
// parent render, which is the shape of bug the `<Legend>` items array hit.
|
|
136
|
+
//
|
|
137
|
+
// JSON, not `join` — a separator collides (`['a b','c']` and `['a','b c']`
|
|
138
|
+
// join identically), and a category label containing a space is not a
|
|
139
|
+
// hypothetical for venue or instrument names.
|
|
140
|
+
const categoriesKey = categoriesProp === undefined ? null : JSON.stringify(categoriesProp);
|
|
141
|
+
const declaredCategories = useMemo(() => categoriesProp === undefined ? null : [...categoriesProp],
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- content identity
|
|
143
|
+
[categoriesKey]);
|
|
64
144
|
const cursorTime = cursorTimeProp ?? false;
|
|
65
145
|
const crosshairSnap = crosshairSnapProp ?? true;
|
|
66
146
|
const warnedLegacyRef = useRef(false);
|
|
@@ -347,6 +427,39 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
347
427
|
// — a mix is a hard error. Defaults to `'time'` until a layer registers (the
|
|
348
428
|
// two-pass: register → re-resolve → rescale).
|
|
349
429
|
const resolvedKind = useMemo(() => {
|
|
430
|
+
// [PND-IGNITECAT] A container-level `categories` *declares* the ordinal
|
|
431
|
+
// axis rather than inferring it, which is what lets a non-category layer
|
|
432
|
+
// join one. A value-keyed layer is compatible by construction: the band
|
|
433
|
+
// scale's domain is numeric (`[0, n]`, slot `i` at `[i, i+1]`) with a
|
|
434
|
+
// linear pixel mapping, so a ValueSeries keyed on slot coordinates already
|
|
435
|
+
// lands where the bars do. A time-keyed layer is not — a timestamp has no
|
|
436
|
+
// slot — so that stays the hard error a mixed kind always was.
|
|
437
|
+
if (declaredCategories !== null) {
|
|
438
|
+
for (const s of sources.values()) {
|
|
439
|
+
if (s.xKind === 'time') {
|
|
440
|
+
throw new Error(`ChartContainer: a time-keyed layer cannot plot on a category ` +
|
|
441
|
+
`axis. This container declares \`categories\`, so its x axis is ` +
|
|
442
|
+
`ordinal slots — key the layer to a ValueSeries on slot ` +
|
|
443
|
+
`coordinates instead (slot i's centre is i + 0.5).`);
|
|
444
|
+
}
|
|
445
|
+
// No guard here for a **horizontal categorical `<BarChart>`**, whose x
|
|
446
|
+
// is bar *length* rather than a coordinate and which therefore draws
|
|
447
|
+
// nonsense on an ordinal x. One was written and removed: it tested
|
|
448
|
+
// `binCategories`, and that is the generic "**my y** is ordinal"
|
|
449
|
+
// channel — a *vertical* heat map sets it too (`HeatMap.tsx:361`, its
|
|
450
|
+
// rows), so the guard rejected a `ValueSeries` grid with named columns
|
|
451
|
+
// on x. That is a legitimate and wanted layout, not a contradiction:
|
|
452
|
+
// ordinal rows and ordinal columns is simply a 2-D grid.
|
|
453
|
+
//
|
|
454
|
+
// The framing was the error. There is no contradiction to detect,
|
|
455
|
+
// because nothing on a layer source distinguishes "my x is a
|
|
456
|
+
// coordinate" from "my x is a magnitude", and inventing a flag to
|
|
457
|
+
// carry that for one guard buys a false sense of coverage — every
|
|
458
|
+
// *other* misuse of the value-layer allowance stays silent regardless.
|
|
459
|
+
// The hazard is documented on the prop instead ([PND-IGNITECAT]).
|
|
460
|
+
}
|
|
461
|
+
return 'category';
|
|
462
|
+
}
|
|
350
463
|
let kind;
|
|
351
464
|
for (const s of sources.values()) {
|
|
352
465
|
if (kind === undefined)
|
|
@@ -354,31 +467,44 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
354
467
|
else if (kind !== s.xKind) {
|
|
355
468
|
throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
|
|
356
469
|
`A container has one shared x axis — every row must plot the same ` +
|
|
357
|
-
`kind (all time-keyed, all value-keyed, or all category)
|
|
470
|
+
`kind (all time-keyed, all value-keyed, or all category). ` +
|
|
471
|
+
`To put value-keyed layers on an ordinal axis, declare the slots ` +
|
|
472
|
+
`on the container: <ChartContainer categories={[…]}>.`);
|
|
358
473
|
}
|
|
359
474
|
}
|
|
360
475
|
return kind ?? 'time';
|
|
361
|
-
}, [sources]);
|
|
476
|
+
}, [sources, declaredCategories]);
|
|
362
477
|
// A `'category'` container's ordered category names — the ordinal axis domain.
|
|
363
478
|
// Every category layer must agree on the same list (a mix is an error, like the
|
|
364
479
|
// kind), so the shared band scale has one authoritative slot order. `null` when
|
|
365
480
|
// no category layer has registered (or the kind isn't category).
|
|
481
|
+
//
|
|
482
|
+
// [PND-IGNITECAT] The container's own `categories` prop, when given, is the
|
|
483
|
+
// authority: layer-derived lists are then *validated* against it rather than
|
|
484
|
+
// being the source. Reconciling both directions in one pass keeps a single
|
|
485
|
+
// error message for a disagreement, whichever side is wrong.
|
|
366
486
|
const categories = useMemo(() => {
|
|
367
|
-
|
|
487
|
+
const same = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
488
|
+
let cats = declaredCategories;
|
|
368
489
|
for (const s of sources.values()) {
|
|
369
490
|
const c = s.xCategories?.() ?? null;
|
|
370
491
|
if (c === null)
|
|
371
492
|
continue;
|
|
372
493
|
if (cats === null)
|
|
373
494
|
cats = c;
|
|
374
|
-
else if (cats
|
|
375
|
-
throw new Error(
|
|
376
|
-
`
|
|
377
|
-
|
|
495
|
+
else if (!same(cats, c)) {
|
|
496
|
+
throw new Error(declaredCategories !== null
|
|
497
|
+
? `ChartContainer: a category layer's columns disagree with the ` +
|
|
498
|
+
`container's \`categories\`. The container's list is ` +
|
|
499
|
+
`authoritative, so they must match in order (container ` +
|
|
500
|
+
`[${declaredCategories.join(', ')}], layer [${c.join(', ')}]).`
|
|
501
|
+
: `ChartContainer: category rows disagree on the axis categories. ` +
|
|
502
|
+
`Every category layer in one container must share the same ordered ` +
|
|
503
|
+
`column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
|
|
378
504
|
}
|
|
379
505
|
}
|
|
380
506
|
return cats;
|
|
381
|
-
}, [sources]);
|
|
507
|
+
}, [sources, declaredCategories]);
|
|
382
508
|
// Auto-fit extent — the union of the layers' x extents — used as the domain
|
|
383
509
|
// when no explicit `range` is given. (Same source registry as the kind; the
|
|
384
510
|
// two-pass register→resolve applies.)
|
|
@@ -801,7 +927,30 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
801
927
|
};
|
|
802
928
|
}
|
|
803
929
|
if (resolvedKind === 'value') {
|
|
804
|
-
|
|
930
|
+
// `scaleLog` / `scaleSymlog` share d3's continuous-scale surface — the
|
|
931
|
+
// call signature, `invert`, `ticks`, `domain`, `range` — so nothing
|
|
932
|
+
// downstream branches on which one this is. That is the whole of log
|
|
933
|
+
// support at the scale layer; the work is in the arithmetic that reads
|
|
934
|
+
// the domain (see `ViewportOptions`) and in the tick ladder.
|
|
935
|
+
//
|
|
936
|
+
// A log scale cannot represent a non-positive domain, and silently
|
|
937
|
+
// clamping would invent a view the caller did not ask for. So fall back
|
|
938
|
+
// to linear and say so, matching how `<YAxis scale="log">` behaves.
|
|
939
|
+
const wantsLog = xScaleKind !== 'linear';
|
|
940
|
+
const logUsable = xScaleKind === 'symlog' || (d0 > 0 && d1 > 0);
|
|
941
|
+
if (isDev && wantsLog && !logUsable) {
|
|
942
|
+
console.warn(`<ChartContainer xScale="log">: the x domain [${d0}, ${d1}] includes ` +
|
|
943
|
+
'zero or a negative value, which a log scale cannot represent. ' +
|
|
944
|
+
'Falling back to a linear x axis — use xScale="symlog" for data ' +
|
|
945
|
+
'that crosses zero.');
|
|
946
|
+
}
|
|
947
|
+
const s = (!wantsLog || !logUsable
|
|
948
|
+
? scaleLinear()
|
|
949
|
+
: xScaleKind === 'symlog'
|
|
950
|
+
? scaleSymlog()
|
|
951
|
+
: scaleLog())
|
|
952
|
+
.domain([d0, d1])
|
|
953
|
+
.range([0, plotWidth]);
|
|
805
954
|
if (elapsedOrigin !== undefined) {
|
|
806
955
|
// Offset (elapsed) value axis: same pixels, ticks anchored at the
|
|
807
956
|
// origin, labels reading `v - origin`. A `timeFormat` / `cursorFormat`
|
|
@@ -1019,6 +1168,34 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1019
1168
|
const values = Array.from(sources.values()).flatMap((s) => s.sampleAt(time));
|
|
1020
1169
|
cb({ time, values });
|
|
1021
1170
|
}, [cursorX, xScale, sources, plotWidth]);
|
|
1171
|
+
// Structural, not `xScaleKind !== 'linear'`: a log scale asked for over a
|
|
1172
|
+
// non-positive domain falls back to linear above, and the gestures must see
|
|
1173
|
+
// what was actually built rather than what was requested. `base()` exists on
|
|
1174
|
+
// d3's log scales and on no other continuous scale — the same test the y side
|
|
1175
|
+
// already uses in `yticks.ts`.
|
|
1176
|
+
// Both probes, because d3 splits them: `base()` is on `scaleLog` and
|
|
1177
|
+
// `constant()` on `scaleSymlog` — the same pair `tickValues` tests.
|
|
1178
|
+
// `xScale` shapes the VALUE axis only — a logarithmic time axis is
|
|
1179
|
+
// meaningless and a category axis has its own band spacing. Saying so out
|
|
1180
|
+
// loud rather than ignoring the prop: a request that quietly does nothing is
|
|
1181
|
+
// the failure mode `panZoom2D` shipped with, where a mode named two axes and
|
|
1182
|
+
// silently moved one.
|
|
1183
|
+
// `sources.size > 0` because `resolvedKind` falls back to 'time' until the
|
|
1184
|
+
// layers have registered — without the guard this fires once on every mount,
|
|
1185
|
+
// including the valid ones.
|
|
1186
|
+
if (isDev &&
|
|
1187
|
+
xScaleKind !== 'linear' &&
|
|
1188
|
+
sources.size > 0 &&
|
|
1189
|
+
resolvedKind !== 'value') {
|
|
1190
|
+
console.warn(`<ChartContainer xScale="${xScaleKind}">: ignored on a ${resolvedKind} ` +
|
|
1191
|
+
'x axis — it applies to a value axis only. A `TimeSeries` gives a time ' +
|
|
1192
|
+
'axis; key the data on the quantity itself (a `ValueSeries`) to get a ' +
|
|
1193
|
+
'value axis you can scale.');
|
|
1194
|
+
}
|
|
1195
|
+
const xIsLog = ((s) => {
|
|
1196
|
+
const probe = s;
|
|
1197
|
+
return (typeof probe.base === 'function' || typeof probe.constant === 'function');
|
|
1198
|
+
})(xScale);
|
|
1022
1199
|
// Pack overlapping top-flag labels (markers + regions) into stacked lanes so
|
|
1023
1200
|
// close-in-x labels don't collide; chips read their lane back off the frame.
|
|
1024
1201
|
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey, plotWidth), [annotations, xScale, draggingKey]);
|
|
@@ -1112,6 +1289,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1112
1289
|
zoomEnabled,
|
|
1113
1290
|
minDuration,
|
|
1114
1291
|
applyRange,
|
|
1292
|
+
xIsLog,
|
|
1115
1293
|
zoomX,
|
|
1116
1294
|
zoomY,
|
|
1117
1295
|
panX,
|
|
@@ -1191,6 +1369,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1191
1369
|
zoomEnabled,
|
|
1192
1370
|
minDuration,
|
|
1193
1371
|
applyRange,
|
|
1372
|
+
xIsLog,
|
|
1194
1373
|
zoomX,
|
|
1195
1374
|
zoomY,
|
|
1196
1375
|
panX,
|
package/dist/ChartRow.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
-
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
3
|
+
import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
|
|
4
4
|
import { isDev } from './dev.js';
|
|
5
5
|
import { useIndexedChildren } from './child-index.js';
|
|
6
6
|
import { logAxisWarning, needsExtents, resolveYDomain } from './domain.js';
|
|
@@ -11,6 +11,41 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
11
11
|
import { LegacyCursor } from './cursors.js';
|
|
12
12
|
import { YAxis } from './YAxis.js';
|
|
13
13
|
import { ContainerContext, RowContext, } from './context.js';
|
|
14
|
+
/**
|
|
15
|
+
* `scale="symlog"`'s default linear window — the knee at **2% of the domain's
|
|
16
|
+
* largest magnitude** ([PND-SYMLOG]). Chosen because it is what the reporting
|
|
17
|
+
* consumer's own transform used (`maxAbs / 50`), and confirmed with them as
|
|
18
|
+
* generalizing: every caller they had passed the data's own max-abs, and none
|
|
19
|
+
* had a case for an absolute constant.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_LINEAR_WINDOW = 0.02;
|
|
22
|
+
/**
|
|
23
|
+
* `scaleSymlog`'s `constant` (the linear window's half-width in data units) for
|
|
24
|
+
* an axis's domain-relative {@link YAxisProps.linearWindow} ([PND-SYMLOG]).
|
|
25
|
+
*
|
|
26
|
+
* **The clamp is the point.** d3's symlog transform is
|
|
27
|
+
* `sign(x)·log1p(|x / constant|)`, so a `constant` of `0` — or of
|
|
28
|
+
* `Number.MIN_VALUE`, which the first version of this "clamped" to — divides
|
|
29
|
+
* every sample by ~zero, and `(∞ − ∞) / (∞ − ∞)` makes **every mapped pixel
|
|
30
|
+
* `NaN`**: a blank plot, `NaN` gridline coordinates, no error. That is strictly
|
|
31
|
+
* worse than the mistake it was guarding, so an unusable fraction (non-finite,
|
|
32
|
+
* `<= 0`, or `> 1`) falls back to the **default** and dev-warns (see the
|
|
33
|
+
* `linearWindow` diagnostics below) rather than being nudged to a value that
|
|
34
|
+
* technically satisfies `> 0`.
|
|
35
|
+
*
|
|
36
|
+
* A degenerate all-zero domain has no magnitude to take a fraction *of*, so the
|
|
37
|
+
* knee falls back to `1`; the axis is linear across it either way.
|
|
38
|
+
*/
|
|
39
|
+
function symlogConstant(linearWindow, lo, hi) {
|
|
40
|
+
const usable = linearWindow !== undefined &&
|
|
41
|
+
Number.isFinite(linearWindow) &&
|
|
42
|
+
linearWindow > 0 &&
|
|
43
|
+
linearWindow <= 1;
|
|
44
|
+
const fraction = usable ? linearWindow : DEFAULT_LINEAR_WINDOW;
|
|
45
|
+
const maxAbs = Math.max(Math.abs(lo), Math.abs(hi));
|
|
46
|
+
const knee = fraction * maxAbs;
|
|
47
|
+
return Number.isFinite(knee) && knee > 0 ? knee : 1;
|
|
48
|
+
}
|
|
14
49
|
/** Sentinel id for the implicit axis a row gets when no `<YAxis>` is declared. */
|
|
15
50
|
const IMPLICIT_AXIS_ID = '__default__';
|
|
16
51
|
/** Element-wise compare of two optional number arrays (an axis's tick values) —
|
|
@@ -43,6 +78,10 @@ function axisSpecEqual(a, b) {
|
|
|
43
78
|
a.side === b.side &&
|
|
44
79
|
a.width === b.width &&
|
|
45
80
|
a.scale === b.scale &&
|
|
81
|
+
// Easy to forget when adding a scale-shaping field, and the failure is
|
|
82
|
+
// silent: an axis whose `linearWindow` alone changed would be discarded by
|
|
83
|
+
// the guard and keep drawing with the previous knee.
|
|
84
|
+
a.linearWindow === b.linearWindow &&
|
|
46
85
|
// Object.is (not ===) so a degenerate NaN bound compares equal to itself and
|
|
47
86
|
// doesn't re-register every render.
|
|
48
87
|
Object.is(a.min, b.min) &&
|
|
@@ -253,7 +292,16 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
253
292
|
// `scaleLog` and `scaleLinear` share the call/ticks/tickFormat/invert
|
|
254
293
|
// surface every consumer uses (see `YScale`), so choosing between them
|
|
255
294
|
// here is the whole of log support — no draw layer branches on it.
|
|
256
|
-
|
|
295
|
+
// `scaleSymlog` shares the same call/ticks/tickFormat/invert surface, so
|
|
296
|
+
// as with log, choosing it here is the whole of symlog support — no draw
|
|
297
|
+
// layer branches on it. Its `constant` (the linear window's half-width) is
|
|
298
|
+
// resolved from the axis's DOMAIN-RELATIVE fraction: absolute would need
|
|
299
|
+
// recomputing whenever the domain moved ([PND-SYMLOG]).
|
|
300
|
+
const base = ax.scale === 'log'
|
|
301
|
+
? scaleLog()
|
|
302
|
+
: ax.scale === 'symlog'
|
|
303
|
+
? scaleSymlog().constant(symlogConstant(ax.linearWindow, lo, hi))
|
|
304
|
+
: scaleLinear();
|
|
257
305
|
const s = base.domain([lo, hi]).range([height, topHeader]);
|
|
258
306
|
// 2-D pan/zoom is carried as a **pixel** transform (`k`, `ty`) so one
|
|
259
307
|
// gesture serves every axis in the row whatever its units, and all of them
|
|
@@ -319,6 +367,46 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
319
367
|
}
|
|
320
368
|
}
|
|
321
369
|
}, [effectiveAxes, layerList, defaultAxisId]);
|
|
370
|
+
// Dev-mode diagnostics for `linearWindow` ([PND-SYMLOG]). Both cases it covers
|
|
371
|
+
// are *silent* otherwise, which is the whole reason it exists: a
|
|
372
|
+
// `linearWindow` on a linear or log axis is read by nothing, and a fraction
|
|
373
|
+
// outside `(0, 1]` is unusable as a knee (see `symlogConstant`), so the axis
|
|
374
|
+
// silently draws with the **default** window instead of the one asked for.
|
|
375
|
+
// Neither throws and neither looks broken — it just isn't the scale the call
|
|
376
|
+
// site asked for.
|
|
377
|
+
//
|
|
378
|
+
// Same shape as the log diagnostics above: an effect rather than the memo, and
|
|
379
|
+
// deduped in `warnedRef` under a suffixed key so it cannot collide with the
|
|
380
|
+
// log message stored under the bare axis id.
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!isDev)
|
|
383
|
+
return;
|
|
384
|
+
const warned = warnedRef.current;
|
|
385
|
+
for (const ax of effectiveAxes) {
|
|
386
|
+
const key = `${ax.id}:linearWindow`;
|
|
387
|
+
const w = ax.linearWindow;
|
|
388
|
+
let message = null;
|
|
389
|
+
if (w !== undefined && ax.scale !== 'symlog') {
|
|
390
|
+
message =
|
|
391
|
+
`<YAxis id="${ax.id}"> sets linearWindow=${w} but scale is ` +
|
|
392
|
+
`"${ax.scale}" — linearWindow only applies to scale="symlog" and is ` +
|
|
393
|
+
`ignored here.`;
|
|
394
|
+
}
|
|
395
|
+
else if (w !== undefined && (!Number.isFinite(w) || w <= 0 || w > 1)) {
|
|
396
|
+
message =
|
|
397
|
+
`<YAxis id="${ax.id}"> has linearWindow=${w}, outside (0, 1] — the ` +
|
|
398
|
+
`axis is drawing with the default ${DEFAULT_LINEAR_WINDOW} instead. ` +
|
|
399
|
+
`It is a fraction of the domain's largest magnitude, so 0.02 means ` +
|
|
400
|
+
`"linear through 2% of the domain".`;
|
|
401
|
+
}
|
|
402
|
+
if (message === null)
|
|
403
|
+
warned.delete(key);
|
|
404
|
+
else if (warned.get(key) !== message) {
|
|
405
|
+
warned.set(key, message);
|
|
406
|
+
console.warn(message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}, [effectiveAxes]);
|
|
322
410
|
// Resolved auto-tick count per axis — explicit `<YAxis tickCount>` else
|
|
323
411
|
// height-derived (see resolveYTickCount). The single source the `<YAxis>`
|
|
324
412
|
// labels, the readout formatter (below), and the `Layers` gridlines all read,
|
|
@@ -365,6 +453,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
365
453
|
}, [effectiveAxes]);
|
|
366
454
|
const frame = useMemo(() => ({
|
|
367
455
|
height,
|
|
456
|
+
topInset: topHeader,
|
|
368
457
|
cursor,
|
|
369
458
|
isFirstRow,
|
|
370
459
|
rowKey,
|
|
@@ -382,6 +471,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
382
471
|
layers: layerList,
|
|
383
472
|
}), [
|
|
384
473
|
height,
|
|
474
|
+
topHeader,
|
|
385
475
|
cursor,
|
|
386
476
|
isFirstRow,
|
|
387
477
|
rowKey,
|
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.js
CHANGED
|
@@ -3,6 +3,7 @@ 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';
|
|
@@ -261,20 +262,24 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
261
262
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
262
263
|
: derived !== null
|
|
263
264
|
? honestDerived()
|
|
264
|
-
: xScale.ticks
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
265
|
+
: // `tickValues`, not `xScale.ticks` — d3's raw `scaleLog.ticks()` is
|
|
266
|
+
// nearly a step function (see `yticks.ts`), so a log x needs the same
|
|
267
|
+
// decade ladder the y axis already builds. For a time or linear scale
|
|
268
|
+
// it defers to `scale.ticks(count)`, so this is a no-op there.
|
|
269
|
+
tickValues(xScale, xTickCount).map((d) => ({
|
|
270
|
+
x: xScale(d),
|
|
271
|
+
label: tickFmt(+d),
|
|
272
|
+
// A **period turn** renders emphasized (bold), consistently across
|
|
273
|
+
// styles: in stacked, a tick on a band divider (matched by pixel);
|
|
274
|
+
// in flat, a tick whose label was *promoted* to a coarser period
|
|
275
|
+
// (its flat label differs from the terse base) — the same boundaries,
|
|
276
|
+
// so `Feb` / `2026` read as strong in flat just as the band turns do.
|
|
277
|
+
bold: stacked
|
|
278
|
+
? dividerXs.has(Math.round(xScale(d)))
|
|
279
|
+
: flatFmt !== undefined &&
|
|
280
|
+
baseFmt !== undefined &&
|
|
281
|
+
flatFmt(+d) !== baseFmt(+d),
|
|
282
|
+
}));
|
|
278
283
|
// A category axis ticks once per category; thin + truncate its labels when they
|
|
279
284
|
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
280
285
|
const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -46,8 +46,64 @@ export interface YAxisProps {
|
|
|
46
46
|
*
|
|
47
47
|
* A dev-mode warning fires for the cases that are unambiguously a mistake: a
|
|
48
48
|
* refused bound, negative data, or an axis with no positive data at all.
|
|
49
|
+
*
|
|
50
|
+
* `'symlog'` is **linear through zero, logarithmic beyond** — for data that
|
|
51
|
+
* spans orders of magnitude *on both sides of zero*, which `'log'` cannot
|
|
52
|
+
* express at all (it admits no zero and no negatives). The linear window is
|
|
53
|
+
* {@link linearWindow}. Because it admits zero, it resolves its domain on the
|
|
54
|
+
* ordinary **linear** path: no positive-only bound refusal, no rounding out to
|
|
55
|
+
* decades, no gapping of non-positive samples.
|
|
56
|
+
*
|
|
57
|
+
* **The axis owns tick placement, and that is the substance of the feature.**
|
|
58
|
+
* d3's symlog supplies the transform but ticks it *linearly*, which on a ±1M
|
|
59
|
+
* domain with a 20k knee labels nothing below the knee — the exact region the
|
|
60
|
+
* scale was chosen to reveal. pond grids it on zero, the knee (±`linearWindow ×
|
|
61
|
+
* maxAbs`) and mirrored decades beyond, thinned by the same rule the log axis
|
|
62
|
+
* uses. See `yticks.ts`.
|
|
63
|
+
*
|
|
64
|
+
* **The curve is `log1p`, not piecewise — read this before replacing a
|
|
65
|
+
* hand-rolled one.** "Linear through zero, logarithmic beyond" describes how the
|
|
66
|
+
* axis *reads*, not two joined segments: `scaleSymlog` is the single smooth
|
|
67
|
+
* `sign(x) · log1p(|x / knee|)`, so there is no exact boundary at which one law
|
|
68
|
+
* stops and the other starts. A common hand-rolled curve *is* piecewise —
|
|
69
|
+
* exactly linear below the knee, `log10` above — and the two are the same family
|
|
70
|
+
* with materially different shape. Swapping one for the other, a reporting
|
|
71
|
+
* consumer measured small values landing at **roughly half** their former height
|
|
72
|
+
* (a ±9M domain: 283k went from 0.44 to 0.24 of the half-plot above the zero
|
|
73
|
+
* line), while order, the dominance of the tail, and a several-fold lift over a
|
|
74
|
+
* linear axis all held — the chart still says the same thing, but it does not
|
|
75
|
+
* say it identically.
|
|
76
|
+
*
|
|
77
|
+
* **No `linearWindow` recovers a piecewise shape.** The same consumer tried: a
|
|
78
|
+
* smaller window fits the large values while overshooting the small ones about
|
|
79
|
+
* 2×, because the difference is the curve, not the knee. If you need the
|
|
80
|
+
* piecewise curve exactly, you need your own transform — which is the thing this
|
|
81
|
+
* scale exists to let you delete, so weigh that before reaching for it.
|
|
82
|
+
*/
|
|
83
|
+
scale?: 'linear' | 'log' | 'symlog';
|
|
84
|
+
/**
|
|
85
|
+
* `scale="symlog"`'s **linear window**, as a fraction of the domain's largest
|
|
86
|
+
* magnitude. **Default `0.02`** — the knee sits at 2% of `maxAbs`, so a ±1M
|
|
87
|
+
* domain is linear through ±20k and logarithmic beyond. Ignored on any other
|
|
88
|
+
* scale.
|
|
89
|
+
*
|
|
90
|
+
* **Domain-relative, not absolute** (d3's own `constant` is absolute). A chart
|
|
91
|
+
* that re-keys to the largest magnitude on every update would otherwise need
|
|
92
|
+
* the constant recomputed each tick, and would drift silently the moment
|
|
93
|
+
* someone forgot — the fraction survives a domain change with no call-site
|
|
94
|
+
* arithmetic at all.
|
|
95
|
+
*
|
|
96
|
+
* Precisely: a fraction of the **resolved domain before any pan/zoom** — the
|
|
97
|
+
* one the axis's `min`/`max`/`pad`/auto-fit produce. A 2-D gesture is carried
|
|
98
|
+
* as a *pixel* transform and the knee is deliberately **not** recomputed from
|
|
99
|
+
* the zoomed window, so zooming moves the plot without moving the boundary
|
|
100
|
+
* between the two régimes underneath it. (Recomputing would make the same
|
|
101
|
+
* datum linear at one zoom level and logarithmic at the next.)
|
|
102
|
+
*
|
|
103
|
+
* A value outside `(0, 1]` cannot be a knee; the axis draws with the default
|
|
104
|
+
* instead and dev-warns which window is in force.
|
|
49
105
|
*/
|
|
50
|
-
|
|
106
|
+
linearWindow?: number;
|
|
51
107
|
/** Explicit domain bounds; omit to auto-fit the charts linked to this axis. */
|
|
52
108
|
min?: number;
|
|
53
109
|
max?: number;
|
|
@@ -155,5 +211,5 @@ export interface YAxisProps {
|
|
|
155
211
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
156
212
|
* (default: the first axis).
|
|
157
213
|
*/
|
|
158
|
-
export declare function YAxis({ id, side, label, scale, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
|
|
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;
|
|
159
215
|
//# sourceMappingURL=YAxis.d.ts.map
|
package/dist/YAxis.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
7
|
const DEFAULT_WIDTH = 50;
|
|
8
8
|
/** Fallback tick count before the row has published its resolved count (the
|
|
9
9
|
* first render, pre-registration). The row's height-derived value takes over
|
|
@@ -17,7 +17,7 @@ const DEFAULT_TICK_COUNT = 5;
|
|
|
17
17
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
18
18
|
* (default: the first axis).
|
|
19
19
|
*/
|
|
20
|
-
export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
|
|
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
21
|
const container = useContext(ContainerContext);
|
|
22
22
|
if (container === null) {
|
|
23
23
|
throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -35,6 +35,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
|
|
|
35
35
|
// and layers still bind to it, which is the whole point of the prop.
|
|
36
36
|
width: hide ? 0 : width,
|
|
37
37
|
scale,
|
|
38
|
+
linearWindow,
|
|
38
39
|
min,
|
|
39
40
|
max,
|
|
40
41
|
pad,
|
|
@@ -49,6 +50,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
|
|
|
49
50
|
width,
|
|
50
51
|
hide,
|
|
51
52
|
scale,
|
|
53
|
+
linearWindow,
|
|
52
54
|
min,
|
|
53
55
|
max,
|
|
54
56
|
pad,
|
|
@@ -112,7 +114,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
|
|
|
112
114
|
? ticks.map((t) => ({ value: t.at, label: t.label }))
|
|
113
115
|
: layerCategories !== null
|
|
114
116
|
? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
|
|
115
|
-
: (yScale ?
|
|
117
|
+
: (yScale ? tickValues(yScale, count) : []).map((t) => ({
|
|
116
118
|
value: t,
|
|
117
119
|
label: fmt(t),
|
|
118
120
|
}));
|