@pond-ts/charts 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/BarList.js CHANGED
@@ -36,7 +36,7 @@ export function BarList(props) {
36
36
  // One normalized view of the union — `isSeriesSource` is the runtime
37
37
  // narrowing; the doors are mutually exclusive by construction now.
38
38
  const source = props;
39
- const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, hovered, onHover, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
39
+ const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, barColors, hovered, onHover, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
40
40
  // A runtime guard for JS consumers and `any`-typed call sites — the
41
41
  // props union makes both branches unreachable from typed TS, but a
42
42
  // silently-ignored source prop is a worse failure than a throw.
@@ -49,6 +49,21 @@ export function BarList(props) {
49
49
  (series instanceof ValueSeries
50
50
  ? listRowsFromValueSeries(series, label !== undefined ? { label } : {})
51
51
  : listRowsFromTimeSeries(series, label !== undefined ? { label } : {})), [rows, series, label]);
52
+ // Keyed by row, not indexed by render position. `barColors` aligns to the
53
+ // rows the caller passed — but the table renders `sorted`, so an index would
54
+ // silently repaint the ramp onto the wrong rows the moment `sortBy` is set.
55
+ // The key survives any reordering.
56
+ const barColorOf = useMemo(() => {
57
+ if (barColors === undefined)
58
+ return null;
59
+ const m = new Map();
60
+ allRows.forEach((r, i) => {
61
+ const c = barColors[i];
62
+ if (c !== undefined)
63
+ m.set(r.key, c);
64
+ });
65
+ return m;
66
+ }, [barColors, allRows]);
52
67
  const sorted = useMemo(() => sortListRows(allRows, sortBy, sortDirection, sort), [allRows, sortBy, sortDirection, sort]);
53
68
  const scale = useMemo(() => resolveListDomain(allRows, columns.map((c) => c.column), domain, markers?.map((m) => m.value)), [allRows, columns, domain, markers]);
54
69
  const resolvedMarkers = useMemo(() => markers?.map((m) => ({
@@ -68,17 +83,35 @@ export function BarList(props) {
68
83
  // Which is also why nothing below may be the *only* signal: strip
69
84
  // this block and a selected row still reads as selected.
70
85
  const soleMetric = columns.length === 1;
71
- const fill = state.selected && soleMetric
72
- ? style.highlight
73
- : state.dimmed
74
- ? (style.dimmed ?? style.fill)
75
- : style.fill;
86
+ // A per-row colour makes the fill mean something, which puts it in
87
+ // exactly the position a multi-metric row's fill is already in: the
88
+ // state treatment stands down rather than trading a distinction the
89
+ // reader needs for one the band and rail already give them. Same
90
+ // rule `binColors` follows on the canvas.
91
+ const own = barColorOf?.get(row.key);
92
+ const fill = own !== undefined
93
+ ? own
94
+ : state.selected && soleMetric
95
+ ? style.highlight
96
+ : state.dimmed
97
+ ? (style.dimmed ?? style.fill)
98
+ : style.fill;
76
99
  // A `dimmed` token carries its own alpha (`rgba(…,0.32)`), so
77
100
  // multiplying `opacity` on top of it would dim twice. Fall back to
78
101
  // the raw 0.32 only when the theme names no dimmed colour.
79
- const fillOpacity = state.dimmed && style.dimmed === undefined
80
- ? style.opacity * 0.32
81
- : style.opacity;
102
+ // Opacity is where a coloured bar shows its state, since its hue
103
+ // is spoken for. A `dimmed` THEME token carries its own alpha so
104
+ // multiplying on top would dim twice — but that token is not in
105
+ // play for a per-row colour, so the raw 0.32 applies there too.
106
+ const fillOpacity = own !== undefined
107
+ ? state.dimmed
108
+ ? style.opacity * 0.32
109
+ : state.selected
110
+ ? 1
111
+ : style.opacity
112
+ : state.dimmed && style.dimmed === undefined
113
+ ? style.opacity * 0.32
114
+ : style.opacity;
82
115
  return (_jsxs("div", { "data-list-track": col.column, style: {
83
116
  position: 'relative',
84
117
  height: barHeight,
@@ -7,10 +7,14 @@ import { type XAxisProps } from './XAxis.js';
7
7
  * formatter). Kept as the familiar name for categorical charts, mirroring
8
8
  * {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
9
9
  *
10
- * A high-cardinality axis (many categories) thins + truncates its labels to stay
11
- * legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
12
- * `categories` list), so a d3 `format` prop does not apply here (it can't name a
13
- * category); customize a label by changing the `categories` datum's `label`.
10
+ * A crowded axis thins + truncates its labels to stay legible — triggered by
11
+ * **measured geometry** (rendered label width vs. band pitch), not category
12
+ * count, so few-but-wide labels fit as reliably as many short ones
13
+ * ([PND-CATFIT]; categorical-axis RFC, Phase 1). Truncation is from the
14
+ * middle (`EDGE01…EQT`), keeping both the prefix and the distinguishing tail.
15
+ * The labels **come from the data** (the `categories` list), so a d3 `format`
16
+ * prop does not apply here (it can't name a category); customize a label by
17
+ * changing the `categories` datum's `label`.
14
18
  */
15
19
  export declare function CategoryAxis(props?: XAxisProps): import("react/jsx-runtime").JSX.Element;
16
20
  //# sourceMappingURL=CategoryAxis.d.ts.map
@@ -8,10 +8,14 @@ import { XAxis } from './XAxis.js';
8
8
  * formatter). Kept as the familiar name for categorical charts, mirroring
9
9
  * {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
10
10
  *
11
- * A high-cardinality axis (many categories) thins + truncates its labels to stay
12
- * legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
13
- * `categories` list), so a d3 `format` prop does not apply here (it can't name a
14
- * category); customize a label by changing the `categories` datum's `label`.
11
+ * A crowded axis thins + truncates its labels to stay legible — triggered by
12
+ * **measured geometry** (rendered label width vs. band pitch), not category
13
+ * count, so few-but-wide labels fit as reliably as many short ones
14
+ * ([PND-CATFIT]; categorical-axis RFC, Phase 1). Truncation is from the
15
+ * middle (`EDGE01…EQT`), keeping both the prefix and the distinguishing tail.
16
+ * The labels **come from the data** (the `categories` list), so a d3 `format`
17
+ * prop does not apply here (it can't name a category); customize a label by
18
+ * changing the `categories` datum's `label`.
15
19
  */
16
20
  export function CategoryAxis(props = {}) {
17
21
  return _jsx(XAxis, { ...props });
@@ -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,