@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.
@@ -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({ range, 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, grid = true, sessionDividers = 'none', children, }) {
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
- let cats = null;
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.length !== c.length || cats.some((v, i) => v !== c[i])) {
375
- throw new Error(`ChartContainer: category rows disagree on the axis categories. ` +
376
- `Every category layer in one container must share the same ordered ` +
377
- `column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
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
- const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
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
- const base = ax.scale === 'log' ? scaleLog() : scaleLinear();
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
- import { yTickValues } from './yticks.js';
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 ?? yTickValues(gridY, yCount)).map((t) => gridY(t))
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
- c.applyRange(panRange(drag.startRange, dt));
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(xTickCount).map((d) => ({
265
- x: xScale(d),
266
- label: tickFmt(+d),
267
- // A **period turn** renders emphasized (bold), consistently across
268
- // styles: in stacked, a tick on a band divider (matched by pixel);
269
- // in flat, a tick whose label was *promoted* to a coarser period
270
- // (its flat label differs from the terse base) — the same boundaries,
271
- // so `Feb` / `2026` read as strong in flat just as the band turns do.
272
- bold: stacked
273
- ? dividerXs.has(Math.round(xScale(d)))
274
- : flatFmt !== undefined &&
275
- baseFmt !== undefined &&
276
- flatFmt(+d) !== baseFmt(+d),
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
- scale?: 'linear' | 'log';
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 { yTickValues } from './yticks.js';
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 ? yTickValues(yScale, count) : []).map((t) => ({
117
+ : (yScale ? tickValues(yScale, count) : []).map((t) => ({
116
118
  value: t,
117
119
  label: fmt(t),
118
120
  }));