@pond-ts/charts 0.59.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.
@@ -14,6 +14,108 @@ export interface ChartContainerProps {
14
14
  * the data — so a tuple stays a time domain on a time chart.
15
15
  */
16
16
  range?: readonly [number, number] | TimeRange;
17
+ /**
18
+ * **Make the x axis ordinal at the container level** — one equal-width slot
19
+ * per name, in this order ([PND-IGNITECAT]).
20
+ *
21
+ * Note this is a list of **names** (`string[]`), unlike `<BarChart
22
+ * categories>`, which takes `{ label, value }` data. The container names the
23
+ * slots; the bar layer fills them.
24
+ *
25
+ * Until this prop, the band scale was reachable only *through a layer*:
26
+ * `<BarChart categories>` (and a **horizontal** heat map) reported
27
+ * `xKind: 'category'`,
28
+ * every other layer reported `'time'` or `'value'`, and the container throws
29
+ * on a mix — so **a line, a point or an envelope over categorical bars was
30
+ * not expressible at all**. The workaround was to key every layer to a
31
+ * synthetic integer index and hand-supply the tick labels, which forfeits two
32
+ * features the ordinal axis already implements: `<XAxis>` label thinning
33
+ * (gated on a category axis with no custom ticks) and the
34
+ * {@link maxBandWidth} / {@link bandAlign} slot packing.
35
+ *
36
+ * Declaring the categories here inverts that. The container owns the ordinal
37
+ * domain, so **any value-keyed layer can live on it** and both of those
38
+ * features keep working.
39
+ *
40
+ * ## Keying a layer to the slots
41
+ *
42
+ * The band scale's domain is **numeric** — slot `i` occupies `[i, i+1]`, so
43
+ * its **centre is `i + 0.5`**. Key a `ValueSeries` there and the mark lands
44
+ * on the slot centre, which is also where `<XAxis>` puts the tick:
45
+ *
46
+ * ```tsx
47
+ * const line = ValueSeries.from(
48
+ * tickers.map((t, i) => ({ x: i + 0.5, target: t.target })),
49
+ * { key: 'x' },
50
+ * );
51
+ *
52
+ * <ChartContainer categories={tickers.map((t) => t.label)} width="auto">
53
+ * <ChartRow height={220}>
54
+ * <YAxis id="v" />
55
+ * <Layers>
56
+ * <BarChart categories={bars} />
57
+ * <LineChart series={line} column="target" axis="v" />
58
+ * </Layers>
59
+ * </ChartRow>
60
+ * </ChartContainer>;
61
+ * ```
62
+ *
63
+ * ## What still errors
64
+ *
65
+ * - **A time-keyed layer.** A `TimeSeries` has no slot to sit in; mixing one
66
+ * into an ordinal container is a hard error, as a mixed x-kind always was.
67
+ * - **A category layer that disagrees.** `<BarChart categories>` in an
68
+ * ordinal container must name the same list in the same order — this prop
69
+ * is authoritative, and a silent mismatch would draw bars under the wrong
70
+ * labels.
71
+ * ## What declaring it costs
72
+ *
73
+ * Setting this makes the x axis ordinal, and two container capabilities are
74
+ * defined only on a continuous x. Both were already true of an *inferred*
75
+ * category axis; they are stated here because this prop lets you opt a
76
+ * previously-continuous container into them:
77
+ *
78
+ * - **x pan and zoom stop.** `panZoom` keeps working on **y** (`panY` /
79
+ * `zoomY`), but the x half is gated off — sliding between named slots is
80
+ * not a gesture the axis has a meaning for.
81
+ * - **{@link range} stops applying to x.** The domain is `[0, n]`, derived
82
+ * from the slot count, so an x range is a no-op rather than an error.
83
+ * Show a subset by passing fewer categories.
84
+ * - **{@link xScale} stops applying.** `'log'` / `'symlog'` describe how a
85
+ * *continuous* x spaces its values; ordinal slots are evenly spaced by
86
+ * definition, so the kind is ignored (as it already is on a time axis).
87
+ *
88
+ * ## The hazard this cannot catch
89
+ *
90
+ * **A value-keyed layer is taken at its word.** Anything reporting `'value'`
91
+ * is read as slot coordinates, so a layer whose x means something *else*
92
+ * will draw — in the wrong place, silently. The sharpest instance is a
93
+ * **horizontal categorical `<BarChart>`**: its x is bar *length*, not a
94
+ * coordinate, so on an ordinal x it plots magnitudes as slot positions.
95
+ * Don't mix one into an ordinal container.
96
+ *
97
+ * This is documented rather than enforced, and the reason is worth keeping:
98
+ * a guard was written for it, testing `binCategories`. That is the generic
99
+ * "my **y** is ordinal" channel, and a *vertical* heat map sets it too — so
100
+ * the guard rejected a `ValueSeries` grid with named columns on x, which is
101
+ * a wanted layout (ordinal rows plus ordinal columns is just a 2-D grid),
102
+ * with an error naming a `<BarChart>` that wasn't in the tree. Nothing on a
103
+ * layer source distinguishes "my x is a coordinate" from "my x is a
104
+ * magnitude", so there is no contradiction to detect — and a flag invented
105
+ * to carry it would buy a false sense of coverage while every other misuse
106
+ * stayed silent.
107
+ *
108
+ * ## One more edge
109
+ *
110
+ * **`categories={[]}` is an ordinal axis with no slots yet**, not a fallback
111
+ * to time. That is the useful reading for a loading state: the kind stays
112
+ * put when the data arrives, instead of flipping and rebuilding every scale
113
+ * mid-session.
114
+ *
115
+ * Omit for the inferred behaviour: a container with only category layers
116
+ * still resolves its slots from them, exactly as before.
117
+ */
118
+ categories?: readonly string[];
17
119
  /**
18
120
  * **Cap the slot pitch** on a **category** x axis, in CSS pixels
19
121
  * ([PND-BANDPACK]). A band scale otherwise spreads its categories across the
@@ -99,6 +201,38 @@ export interface ChartContainerProps {
99
201
  * carries its own metric).
100
202
  */
101
203
  spacing?: 'proportional' | 'uniform';
204
+ /**
205
+ * How the **value** x axis maps data to pixels. **Omitted ⇒ `'linear'`.**
206
+ *
207
+ * `'log'` for a quantity spanning orders of magnitude — a power–duration
208
+ * curve is watts against 1s · 5s · 1m · 20m · 3h, which is unreadable on a
209
+ * linear x. `'symlog'` is the same but linear through zero, for data that
210
+ * crosses it.
211
+ *
212
+ * **Ignored on a time or category axis**, which have their own spacing rules.
213
+ *
214
+ * **Why this lives on the container and not on `<XAxis scale>`,** which is
215
+ * where `<YAxis scale>`'s mirror would put it: **the rows are stacked
216
+ * vertically, so a given pixel column has to mean the same x in every one of
217
+ * them** — otherwise the stack doesn't line up and a cursor at one pixel
218
+ * reads a different value per row. The x scale and its domain are therefore
219
+ * *shared by requirement*, not by convention, and a shared thing is declared
220
+ * once by the thing that contains them. `<YAxis>` is the opposite for the
221
+ * same reason: each row carries its own quantity, so its scale **must** be
222
+ * per-row, which is why `min` / `max` / `pad` / `scale` belong to the axis.
223
+ *
224
+ * That gives the test for what belongs here rather than on `<XAxis>`: **does
225
+ * it define the mapping or the domain?** `origin`, `spacing`, `calendar` and
226
+ * the viewport props all do, and sit here for the same reason. Every
227
+ * `<XAxis>` prop (`format`, `label`, `side`, `ticks`, `align`, …) does not —
228
+ * they style a scale the axis only draws, and putting a scale-defining prop
229
+ * among them would mean a registration round-trip to the component that
230
+ * already owns it.
231
+ *
232
+ * (Had `<XAxis>` been mandatory in the declaration, the props would more
233
+ * naturally have lived there and x would mirror y — see [PND-XLOG].)
234
+ */
235
+ xScale?: 'linear' | 'log' | 'symlog';
102
236
  /**
103
237
  * Draw the reference gridlines behind the data. On a calendar (time) axis
104
238
  * the verticals are the **full grain populations** — every day / month /
@@ -124,8 +258,42 @@ export interface ChartContainerProps {
124
258
  * separators-on-a-clean-plot look.
125
259
  */
126
260
  sessionDividers?: 'labeled' | 'all' | 'none';
127
- /** Total width in CSS pixels (plot + axis gutters). */
128
- width: number;
261
+ /**
262
+ * Total width in CSS pixels (plot + axis gutters), or **`'auto'` to fill the
263
+ * available width** — which is also what an omitted `width` means.
264
+ *
265
+ * The canvas renderer needs real pixels to lay out ticks and slots before it
266
+ * draws, so `'auto'` does not hand the canvas a percentage: the container
267
+ * renders a plain full-width box, measures it with a `ResizeObserver`, and
268
+ * mounts the chart at that pixel width, re-rendering as the box resizes.
269
+ * **Nothing paints until a real width exists** — a zero-width chart is
270
+ * degenerate, not empty — so an auto container renders an empty box for the
271
+ * first layout pass.
272
+ *
273
+ * This is the [responsive-width recipe](https://pond-ts.github.io/pond/docs/recipes/responsive-width)
274
+ * moved inside the library, and it closes that recipe's sharpest edge by
275
+ * construction: the measured box is one the library owns, so it can never be
276
+ * the caller's padded or bordered box (whose border-box width overflows the
277
+ * chart by exactly the padding). Style your own wrapper *around* the
278
+ * container as freely as you like.
279
+ *
280
+ * **The parent needs a definite width.** `'auto'` measures a `width: 100%`
281
+ * box, so a parent whose own width comes from its *content* — a float, an
282
+ * `inline-block`, a grid `auto` track, a flex child without `min-width: 0` —
283
+ * measures 0, and the chart is the content that would have given it a width.
284
+ * That is a standing deadlock, not a slow start: the chart stays blank with
285
+ * no error. Give the parent a width, a `flex` basis, or `min-width: 0`, or
286
+ * pass a number.
287
+ *
288
+ * A container hidden by an ancestor's `display: none` is fine — it keeps the
289
+ * last width it measured and stays mounted, so a tab switch does not discard
290
+ * pan/zoom position, selection or hover.
291
+ *
292
+ * Pass a number whenever the width is already known — a fixed-size panel, a
293
+ * print layout, a test. It skips the measure pass and paints on the first
294
+ * render.
295
+ */
296
+ width?: number | 'auto';
129
297
  /** Vertical space between rows in CSS pixels (not under the axis). Default 0. */
130
298
  rowGap?: number;
131
299
  /**
@@ -449,6 +617,10 @@ export interface ChartContainerProps {
449
617
  * the shared time `xScale`. It renders its rows (separated by `rowGap`) then one
450
618
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
451
619
  * (`<YAxis>`).
620
+ *
621
+ * A `width` in pixels renders straight through; `'auto'` (or an omitted
622
+ * `width`) measures the available width first — see {@link
623
+ * ChartContainerProps.width} and {@link AutoWidthContainer}.
452
624
  */
453
- export declare function ChartContainer({ range, maxBandWidth, bandAlign, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, panZoom, bounds, onTimeRangeChange, minDuration, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
625
+ export declare function ChartContainer(props: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
454
626
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -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
@@ -453,6 +453,7 @@ export function ChartRow({ height, cursor, children }) {
453
453
  }, [effectiveAxes]);
454
454
  const frame = useMemo(() => ({
455
455
  height,
456
+ topInset: topHeader,
456
457
  cursor,
457
458
  isFirstRow,
458
459
  rowKey,
@@ -470,6 +471,7 @@ export function ChartRow({ height, cursor, children }) {
470
471
  layers: layerList,
471
472
  }), [
472
473
  height,
474
+ topHeader,
473
475
  cursor,
474
476
  isFirstRow,
475
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.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
@@ -114,7 +114,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
114
114
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
115
115
  : layerCategories !== null
116
116
  ? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
117
- : (yScale ? yTickValues(yScale, count) : []).map((t) => ({
117
+ : (yScale ? tickValues(yScale, count) : []).map((t) => ({
118
118
  value: t,
119
119
  label: fmt(t),
120
120
  }));