@pond-ts/charts 0.59.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +21 -16
- package/CHANGELOG.md +238 -1
- package/dist/AreaChart.d.ts +53 -1
- package/dist/AreaChart.js +16 -3
- package/dist/BarChart.js +6 -61
- package/dist/BarList.d.ts +22 -0
- package/dist/BarList.js +42 -9
- package/dist/CategoryAxis.d.ts +8 -4
- package/dist/CategoryAxis.js +8 -4
- package/dist/ChartContainer.d.ts +175 -3
- package/dist/ChartContainer.js +190 -11
- package/dist/ChartRow.js +2 -0
- package/dist/Layers.js +14 -4
- package/dist/XAxis.d.ts +47 -3
- package/dist/XAxis.js +165 -41
- package/dist/YAxis.d.ts +15 -1
- package/dist/YAxis.js +31 -4
- package/dist/area.d.ts +43 -1
- package/dist/area.js +122 -5
- package/dist/axis-events.d.ts +106 -0
- package/dist/axis-events.js +56 -0
- package/dist/context.d.ts +35 -2
- package/dist/format.d.ts +1 -1
- package/dist/format.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/theme.d.ts +22 -0
- package/dist/theme.js +3 -0
- package/dist/use-band-ladder.d.ts +30 -0
- package/dist/use-band-ladder.js +81 -0
- package/dist/useChartFrame.d.ts +122 -0
- package/dist/useChartFrame.js +155 -0
- package/dist/useChartLegend.d.ts +8 -0
- package/dist/viewport.d.ts +35 -2
- package/dist/viewport.js +53 -6
- package/dist/yticks.d.ts +5 -1
- package/dist/yticks.js +5 -1
- package/package.json +3 -3
package/dist/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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
package/dist/CategoryAxis.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
package/dist/CategoryAxis.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 });
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
128
|
-
|
|
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(
|
|
625
|
+
export declare function ChartContainer(props: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
454
626
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
-
import { scaleLinear } from 'd3-scale';
|
|
3
|
+
import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
|
|
4
4
|
import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
|
|
5
5
|
import { scaleBand } from './bandScale.js';
|
|
6
6
|
import { scaleElapsed } from './elapsed.js';
|
|
@@ -52,8 +52,75 @@ function normalizeRange(range) {
|
|
|
52
52
|
* the shared time `xScale`. It renders its rows (separated by `rowGap`) then one
|
|
53
53
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
54
54
|
* (`<YAxis>`).
|
|
55
|
+
*
|
|
56
|
+
* A `width` in pixels renders straight through; `'auto'` (or an omitted
|
|
57
|
+
* `width`) measures the available width first — see {@link
|
|
58
|
+
* ChartContainerProps.width} and {@link AutoWidthContainer}.
|
|
55
59
|
*/
|
|
56
|
-
export function ChartContainer(
|
|
60
|
+
export function ChartContainer(props) {
|
|
61
|
+
const { width } = props;
|
|
62
|
+
// The measure pass is a *different component* rather than a branch inside
|
|
63
|
+
// the resolved one, because the resolved container may not render at all
|
|
64
|
+
// until a width exists — and ~60 hooks cannot be conditional. Choosing the
|
|
65
|
+
// component by the prop's kind (number vs auto) means flipping a container
|
|
66
|
+
// between fixed and auto remounts it; that is a layout change, and a
|
|
67
|
+
// remount is the honest response to one.
|
|
68
|
+
if (typeof width === 'number') {
|
|
69
|
+
return _jsx(ResolvedChartContainer, { ...props, width: width });
|
|
70
|
+
}
|
|
71
|
+
return _jsx(AutoWidthContainer, { ...props });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `width="auto"` half: render a plain full-width box, measure it, and
|
|
75
|
+
* mount the chart at that pixel width.
|
|
76
|
+
*
|
|
77
|
+
* Three details, each of which the shipped
|
|
78
|
+
* [responsive-width recipe](https://pond-ts.github.io/pond/docs/recipes/responsive-width)
|
|
79
|
+
* had to spell out for consumers and which now hold by construction:
|
|
80
|
+
*
|
|
81
|
+
* 1. **`useLayoutEffect`, so the first real width lands before paint** — no
|
|
82
|
+
* flash of the empty box.
|
|
83
|
+
* 2. **Measure synchronously on mount, then let `ResizeObserver` take over.**
|
|
84
|
+
* RO's own first callback is not guaranteed to fire promptly in every
|
|
85
|
+
* browser; relying on it alone can leave a chart that never mounts.
|
|
86
|
+
* 3. **The measured box is plain.** No padding, no border — so
|
|
87
|
+
* `getBoundingClientRect().width` is the content width, and the chart can
|
|
88
|
+
* never overflow its own measurement. A caller who wants a bordered frame
|
|
89
|
+
* puts it on a wrapper *outside* the container.
|
|
90
|
+
*/
|
|
91
|
+
function AutoWidthContainer(props) {
|
|
92
|
+
const boxRef = useRef(null);
|
|
93
|
+
const [measured, setMeasured] = useState(0);
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const el = boxRef.current;
|
|
96
|
+
if (el === null)
|
|
97
|
+
return;
|
|
98
|
+
const measure = () => setMeasured((prev) => {
|
|
99
|
+
const next = Math.round(el.getBoundingClientRect().width);
|
|
100
|
+
// **Latch the last non-zero width.** A box measures 0 whenever it is
|
|
101
|
+
// not laid out — most often because an ancestor went `display: none`
|
|
102
|
+
// (a tab switch, a collapsed accordion), which is a *hidden* chart,
|
|
103
|
+
// not a resized one. Writing that 0 through would unmount the resolved
|
|
104
|
+
// container and discard everything it owns: pan/zoom position,
|
|
105
|
+
// selection, hover, and every layer's memoized draw state, all
|
|
106
|
+
// rebuilt on the way back. Keeping the stale width holds the chart
|
|
107
|
+
// mounted through the hide, and the next real measurement corrects it.
|
|
108
|
+
return next > 0 ? next : prev;
|
|
109
|
+
});
|
|
110
|
+
measure();
|
|
111
|
+
// Guarded rather than assumed: a non-browser render target (SSR, an older
|
|
112
|
+
// test DOM) has no ResizeObserver, and a chart that measured once is a far
|
|
113
|
+
// better failure than one that throws on mount.
|
|
114
|
+
if (typeof ResizeObserver === 'undefined')
|
|
115
|
+
return;
|
|
116
|
+
const ro = new ResizeObserver(measure);
|
|
117
|
+
ro.observe(el);
|
|
118
|
+
return () => ro.disconnect();
|
|
119
|
+
}, []);
|
|
120
|
+
return (_jsx("div", { ref: boxRef, style: { width: '100%' }, children: measured > 0 && _jsx(ResolvedChartContainer, { ...props, width: measured }) }));
|
|
121
|
+
}
|
|
122
|
+
/** {@link ChartContainer} with its width resolved to a concrete pixel number. */
|
|
123
|
+
function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
|
|
57
124
|
// ── Legacy cursor props (deprecated) ───────────────────────────────────────
|
|
58
125
|
// The string surface keeps working for one minor: the resolved mode is
|
|
59
126
|
// synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
|
|
@@ -61,6 +128,19 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
61
128
|
// *explicitly* set (never on the defaults). Mounted cursor components in the
|
|
62
129
|
// same scope override the shim. See docs/rfcs/interaction.md §9 / A4.4.
|
|
63
130
|
const cursor = cursorProp ?? DEFAULT_CURSOR_MODE;
|
|
131
|
+
// [PND-IGNITECAT] The declared slot list, normalized to `null` when absent
|
|
132
|
+
// and held by **content** identity. An inline `categories={['a', 'b']}` is a
|
|
133
|
+
// fresh array every render; keying the kind/scale memos off the raw prop
|
|
134
|
+
// would rebuild the band scale — and therefore repaint every row — on every
|
|
135
|
+
// parent render, which is the shape of bug the `<Legend>` items array hit.
|
|
136
|
+
//
|
|
137
|
+
// JSON, not `join` — a separator collides (`['a b','c']` and `['a','b c']`
|
|
138
|
+
// join identically), and a category label containing a space is not a
|
|
139
|
+
// hypothetical for venue or instrument names.
|
|
140
|
+
const categoriesKey = categoriesProp === undefined ? null : JSON.stringify(categoriesProp);
|
|
141
|
+
const declaredCategories = useMemo(() => categoriesProp === undefined ? null : [...categoriesProp],
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- content identity
|
|
143
|
+
[categoriesKey]);
|
|
64
144
|
const cursorTime = cursorTimeProp ?? false;
|
|
65
145
|
const crosshairSnap = crosshairSnapProp ?? true;
|
|
66
146
|
const warnedLegacyRef = useRef(false);
|
|
@@ -347,6 +427,39 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
347
427
|
// — a mix is a hard error. Defaults to `'time'` until a layer registers (the
|
|
348
428
|
// two-pass: register → re-resolve → rescale).
|
|
349
429
|
const resolvedKind = useMemo(() => {
|
|
430
|
+
// [PND-IGNITECAT] A container-level `categories` *declares* the ordinal
|
|
431
|
+
// axis rather than inferring it, which is what lets a non-category layer
|
|
432
|
+
// join one. A value-keyed layer is compatible by construction: the band
|
|
433
|
+
// scale's domain is numeric (`[0, n]`, slot `i` at `[i, i+1]`) with a
|
|
434
|
+
// linear pixel mapping, so a ValueSeries keyed on slot coordinates already
|
|
435
|
+
// lands where the bars do. A time-keyed layer is not — a timestamp has no
|
|
436
|
+
// slot — so that stays the hard error a mixed kind always was.
|
|
437
|
+
if (declaredCategories !== null) {
|
|
438
|
+
for (const s of sources.values()) {
|
|
439
|
+
if (s.xKind === 'time') {
|
|
440
|
+
throw new Error(`ChartContainer: a time-keyed layer cannot plot on a category ` +
|
|
441
|
+
`axis. This container declares \`categories\`, so its x axis is ` +
|
|
442
|
+
`ordinal slots — key the layer to a ValueSeries on slot ` +
|
|
443
|
+
`coordinates instead (slot i's centre is i + 0.5).`);
|
|
444
|
+
}
|
|
445
|
+
// No guard here for a **horizontal categorical `<BarChart>`**, whose x
|
|
446
|
+
// is bar *length* rather than a coordinate and which therefore draws
|
|
447
|
+
// nonsense on an ordinal x. One was written and removed: it tested
|
|
448
|
+
// `binCategories`, and that is the generic "**my y** is ordinal"
|
|
449
|
+
// channel — a *vertical* heat map sets it too (`HeatMap.tsx:361`, its
|
|
450
|
+
// rows), so the guard rejected a `ValueSeries` grid with named columns
|
|
451
|
+
// on x. That is a legitimate and wanted layout, not a contradiction:
|
|
452
|
+
// ordinal rows and ordinal columns is simply a 2-D grid.
|
|
453
|
+
//
|
|
454
|
+
// The framing was the error. There is no contradiction to detect,
|
|
455
|
+
// because nothing on a layer source distinguishes "my x is a
|
|
456
|
+
// coordinate" from "my x is a magnitude", and inventing a flag to
|
|
457
|
+
// carry that for one guard buys a false sense of coverage — every
|
|
458
|
+
// *other* misuse of the value-layer allowance stays silent regardless.
|
|
459
|
+
// The hazard is documented on the prop instead ([PND-IGNITECAT]).
|
|
460
|
+
}
|
|
461
|
+
return 'category';
|
|
462
|
+
}
|
|
350
463
|
let kind;
|
|
351
464
|
for (const s of sources.values()) {
|
|
352
465
|
if (kind === undefined)
|
|
@@ -354,31 +467,44 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
354
467
|
else if (kind !== s.xKind) {
|
|
355
468
|
throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
|
|
356
469
|
`A container has one shared x axis — every row must plot the same ` +
|
|
357
|
-
`kind (all time-keyed, all value-keyed, or all category)
|
|
470
|
+
`kind (all time-keyed, all value-keyed, or all category). ` +
|
|
471
|
+
`To put value-keyed layers on an ordinal axis, declare the slots ` +
|
|
472
|
+
`on the container: <ChartContainer categories={[…]}>.`);
|
|
358
473
|
}
|
|
359
474
|
}
|
|
360
475
|
return kind ?? 'time';
|
|
361
|
-
}, [sources]);
|
|
476
|
+
}, [sources, declaredCategories]);
|
|
362
477
|
// A `'category'` container's ordered category names — the ordinal axis domain.
|
|
363
478
|
// Every category layer must agree on the same list (a mix is an error, like the
|
|
364
479
|
// kind), so the shared band scale has one authoritative slot order. `null` when
|
|
365
480
|
// no category layer has registered (or the kind isn't category).
|
|
481
|
+
//
|
|
482
|
+
// [PND-IGNITECAT] The container's own `categories` prop, when given, is the
|
|
483
|
+
// authority: layer-derived lists are then *validated* against it rather than
|
|
484
|
+
// being the source. Reconciling both directions in one pass keeps a single
|
|
485
|
+
// error message for a disagreement, whichever side is wrong.
|
|
366
486
|
const categories = useMemo(() => {
|
|
367
|
-
|
|
487
|
+
const same = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
488
|
+
let cats = declaredCategories;
|
|
368
489
|
for (const s of sources.values()) {
|
|
369
490
|
const c = s.xCategories?.() ?? null;
|
|
370
491
|
if (c === null)
|
|
371
492
|
continue;
|
|
372
493
|
if (cats === null)
|
|
373
494
|
cats = c;
|
|
374
|
-
else if (cats
|
|
375
|
-
throw new Error(
|
|
376
|
-
`
|
|
377
|
-
|
|
495
|
+
else if (!same(cats, c)) {
|
|
496
|
+
throw new Error(declaredCategories !== null
|
|
497
|
+
? `ChartContainer: a category layer's columns disagree with the ` +
|
|
498
|
+
`container's \`categories\`. The container's list is ` +
|
|
499
|
+
`authoritative, so they must match in order (container ` +
|
|
500
|
+
`[${declaredCategories.join(', ')}], layer [${c.join(', ')}]).`
|
|
501
|
+
: `ChartContainer: category rows disagree on the axis categories. ` +
|
|
502
|
+
`Every category layer in one container must share the same ordered ` +
|
|
503
|
+
`column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
|
|
378
504
|
}
|
|
379
505
|
}
|
|
380
506
|
return cats;
|
|
381
|
-
}, [sources]);
|
|
507
|
+
}, [sources, declaredCategories]);
|
|
382
508
|
// Auto-fit extent — the union of the layers' x extents — used as the domain
|
|
383
509
|
// when no explicit `range` is given. (Same source registry as the kind; the
|
|
384
510
|
// two-pass register→resolve applies.)
|
|
@@ -801,7 +927,30 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
801
927
|
};
|
|
802
928
|
}
|
|
803
929
|
if (resolvedKind === 'value') {
|
|
804
|
-
|
|
930
|
+
// `scaleLog` / `scaleSymlog` share d3's continuous-scale surface — the
|
|
931
|
+
// call signature, `invert`, `ticks`, `domain`, `range` — so nothing
|
|
932
|
+
// downstream branches on which one this is. That is the whole of log
|
|
933
|
+
// support at the scale layer; the work is in the arithmetic that reads
|
|
934
|
+
// the domain (see `ViewportOptions`) and in the tick ladder.
|
|
935
|
+
//
|
|
936
|
+
// A log scale cannot represent a non-positive domain, and silently
|
|
937
|
+
// clamping would invent a view the caller did not ask for. So fall back
|
|
938
|
+
// to linear and say so, matching how `<YAxis scale="log">` behaves.
|
|
939
|
+
const wantsLog = xScaleKind !== 'linear';
|
|
940
|
+
const logUsable = xScaleKind === 'symlog' || (d0 > 0 && d1 > 0);
|
|
941
|
+
if (isDev && wantsLog && !logUsable) {
|
|
942
|
+
console.warn(`<ChartContainer xScale="log">: the x domain [${d0}, ${d1}] includes ` +
|
|
943
|
+
'zero or a negative value, which a log scale cannot represent. ' +
|
|
944
|
+
'Falling back to a linear x axis — use xScale="symlog" for data ' +
|
|
945
|
+
'that crosses zero.');
|
|
946
|
+
}
|
|
947
|
+
const s = (!wantsLog || !logUsable
|
|
948
|
+
? scaleLinear()
|
|
949
|
+
: xScaleKind === 'symlog'
|
|
950
|
+
? scaleSymlog()
|
|
951
|
+
: scaleLog())
|
|
952
|
+
.domain([d0, d1])
|
|
953
|
+
.range([0, plotWidth]);
|
|
805
954
|
if (elapsedOrigin !== undefined) {
|
|
806
955
|
// Offset (elapsed) value axis: same pixels, ticks anchored at the
|
|
807
956
|
// origin, labels reading `v - origin`. A `timeFormat` / `cursorFormat`
|
|
@@ -1019,6 +1168,34 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1019
1168
|
const values = Array.from(sources.values()).flatMap((s) => s.sampleAt(time));
|
|
1020
1169
|
cb({ time, values });
|
|
1021
1170
|
}, [cursorX, xScale, sources, plotWidth]);
|
|
1171
|
+
// Structural, not `xScaleKind !== 'linear'`: a log scale asked for over a
|
|
1172
|
+
// non-positive domain falls back to linear above, and the gestures must see
|
|
1173
|
+
// what was actually built rather than what was requested. `base()` exists on
|
|
1174
|
+
// d3's log scales and on no other continuous scale — the same test the y side
|
|
1175
|
+
// already uses in `yticks.ts`.
|
|
1176
|
+
// Both probes, because d3 splits them: `base()` is on `scaleLog` and
|
|
1177
|
+
// `constant()` on `scaleSymlog` — the same pair `tickValues` tests.
|
|
1178
|
+
// `xScale` shapes the VALUE axis only — a logarithmic time axis is
|
|
1179
|
+
// meaningless and a category axis has its own band spacing. Saying so out
|
|
1180
|
+
// loud rather than ignoring the prop: a request that quietly does nothing is
|
|
1181
|
+
// the failure mode `panZoom2D` shipped with, where a mode named two axes and
|
|
1182
|
+
// silently moved one.
|
|
1183
|
+
// `sources.size > 0` because `resolvedKind` falls back to 'time' until the
|
|
1184
|
+
// layers have registered — without the guard this fires once on every mount,
|
|
1185
|
+
// including the valid ones.
|
|
1186
|
+
if (isDev &&
|
|
1187
|
+
xScaleKind !== 'linear' &&
|
|
1188
|
+
sources.size > 0 &&
|
|
1189
|
+
resolvedKind !== 'value') {
|
|
1190
|
+
console.warn(`<ChartContainer xScale="${xScaleKind}">: ignored on a ${resolvedKind} ` +
|
|
1191
|
+
'x axis — it applies to a value axis only. A `TimeSeries` gives a time ' +
|
|
1192
|
+
'axis; key the data on the quantity itself (a `ValueSeries`) to get a ' +
|
|
1193
|
+
'value axis you can scale.');
|
|
1194
|
+
}
|
|
1195
|
+
const xIsLog = ((s) => {
|
|
1196
|
+
const probe = s;
|
|
1197
|
+
return (typeof probe.base === 'function' || typeof probe.constant === 'function');
|
|
1198
|
+
})(xScale);
|
|
1022
1199
|
// Pack overlapping top-flag labels (markers + regions) into stacked lanes so
|
|
1023
1200
|
// close-in-x labels don't collide; chips read their lane back off the frame.
|
|
1024
1201
|
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey, plotWidth), [annotations, xScale, draggingKey]);
|
|
@@ -1112,6 +1289,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1112
1289
|
zoomEnabled,
|
|
1113
1290
|
minDuration,
|
|
1114
1291
|
applyRange,
|
|
1292
|
+
xIsLog,
|
|
1115
1293
|
zoomX,
|
|
1116
1294
|
zoomY,
|
|
1117
1295
|
panX,
|
|
@@ -1191,6 +1369,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
|
|
|
1191
1369
|
zoomEnabled,
|
|
1192
1370
|
minDuration,
|
|
1193
1371
|
applyRange,
|
|
1372
|
+
xIsLog,
|
|
1194
1373
|
zoomX,
|
|
1195
1374
|
zoomY,
|
|
1196
1375
|
panX,
|
package/dist/ChartRow.js
CHANGED
|
@@ -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,
|