@pond-ts/charts 0.53.1 → 0.55.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/list.d.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The **list family**'s data model + pure row logic — the shared substrate of
3
+ * {@link BarList} and {@link BoxList}, the DOM-rendered *ranked row lists*
4
+ * (react-timeseries-charts' `HorizontalBarChart`, the esnet "traffic by
5
+ * interface" table, an activity's per-split bars).
6
+ *
7
+ * A list is a **table, not a plot**: rows are entities (interfaces, splits,
8
+ * symbols), each row carries a label, optional data cells, and one glyph line
9
+ * per configured column — a value bar ({@link BarList}) or a five-number
10
+ * distribution box ({@link BoxList}). That is why this family renders DOM
11
+ * rather than registering canvas layers in a `<ChartContainer>`: the defining
12
+ * features (link labels, arbitrary cells, a per-row expander, custom sort) are
13
+ * table semantics a band-scaled plot can't carry. The in-plot horizontal bars
14
+ * remain `<BarChart orientation="horizontal">` — that's the histogram; this is
15
+ * the table.
16
+ *
17
+ * Everything here is pure (no React rendering): row/column/cell types, the
18
+ * shared value scale, and sorting. The components consume these so the two
19
+ * sisters can never disagree on semantics.
20
+ */
21
+ import type { ReactNode } from 'react';
22
+ import type { SeriesSchema, TimeSeries, ValueSeries, ValueSeriesSchema } from 'pond-ts';
23
+ /**
24
+ * One cell value of a {@link ListRow}: a finite number renders (bar length,
25
+ * box quantile, sortable), a string rides along for data cells and
26
+ * lexicographic sort, and `undefined` / non-finite is a **gap** (no glyph,
27
+ * sorts last). The tolerant union keeps one `values` record serving glyphs,
28
+ * cells, and sort at once.
29
+ */
30
+ export type ListValue = number | string | undefined;
31
+ /**
32
+ * One row of a {@link BarList} / {@link BoxList}.
33
+ *
34
+ * - `key` is the row's **stable identity** — selection, expansion, and React
35
+ * keys all pin to it, so it must be unique in the list (a duplicate key
36
+ * would expand/select both rows at once).
37
+ * - `label` is the built-in first cell's content (a plain string, or a link /
38
+ * any node). **Omitted ⇒ the `key` renders.**
39
+ * - `values` is the row's flat data record. Glyph columns and `sortBy` read it
40
+ * by name; data cells may read it or any extra field a consumer adds (both
41
+ * components are generic over `R extends ListRow`, so custom fields ride
42
+ * through to `render` / `renderExpanded` fully typed).
43
+ */
44
+ export interface ListRow {
45
+ readonly key: string;
46
+ readonly label?: ReactNode;
47
+ readonly values: Readonly<Record<string, ListValue>>;
48
+ }
49
+ /**
50
+ * One **bar line** of a {@link BarList} row — laid out top→bottom in `columns`
51
+ * order within each row (the esnet to-site / from-site pairing is two of
52
+ * these).
53
+ */
54
+ export interface BarListColumn {
55
+ /** Name of the {@link ListRow.values} entry holding this bar's length. A
56
+ * missing / non-numeric value is a gap — the track renders empty. */
57
+ readonly column: string;
58
+ /**
59
+ * The bar's semantic identifier — what the data _is_. The theme maps it to a
60
+ * `BarStyle` (`theme.bar[as] ?? theme.bar.default`), the same single
61
+ * styling channel `<BarChart>` uses. **Omitted ⇒ the `default` bar style.**
62
+ */
63
+ readonly as?: string;
64
+ }
65
+ /**
66
+ * One **box line** of a {@link BoxList} row — a five-number distribution
67
+ * summary plus an optional *current value* tick, each field naming an entry of
68
+ * {@link ListRow.values}. The quantile vocabulary (`lower`/`q1`/`median`/`q3`/
69
+ * `upper`, `q1`+`q3` both-or-neither for a range-only box) mirrors the canvas
70
+ * `<BoxPlot>` exactly, so moving between the plot and the list renames
71
+ * nothing.
72
+ */
73
+ export interface BoxListColumn {
74
+ /** `values` entry for the lower whisker end (e.g. a `p5` / `min` fact). Required. */
75
+ readonly lower: string;
76
+ /** `values` entry for the box bottom (Q1). Omit with `q3` for a range-only box. */
77
+ readonly q1?: string;
78
+ /** `values` entry for the median line. Omit for no centre line. */
79
+ readonly median?: string;
80
+ /** `values` entry for the box top (Q3). Omit with `q1` for a range-only box. */
81
+ readonly q3?: string;
82
+ /** `values` entry for the upper whisker end (e.g. a `p95` / `max` fact). Required. */
83
+ readonly upper: string;
84
+ /**
85
+ * `values` entry for the **current-value tick** — the dark now-marker over
86
+ * the distribution (the esnet look: range band + tick + printed value).
87
+ * Omit for a distribution-only box.
88
+ */
89
+ readonly value?: string;
90
+ /** Semantic identifier → `theme.box[as] ?? theme.box.default`. */
91
+ readonly as?: string;
92
+ /**
93
+ * Formats the current value's **inline label**, printed just right of the
94
+ * tick (`"150Gbps"`). **Omitted ⇒ no label** (the tick still draws). Only
95
+ * read when `value` is set.
96
+ */
97
+ readonly format?: (value: number) => string;
98
+ }
99
+ /**
100
+ * One **data cell** column, rendered before or after the glyph cell (the
101
+ * split's `15.3 mph`, an interface's type tag). `render` receives the whole
102
+ * row — including any consumer-added fields beyond {@link ListRow} — and
103
+ * returns any node; the table gives every cell of a spec its own shrink-to-fit
104
+ * table column, so cells align down the list.
105
+ */
106
+ export interface ListCellSpec<R extends ListRow = ListRow> {
107
+ /** Stable identity for the cell column (React key). */
108
+ readonly key: string;
109
+ /** Horizontal text alignment. **Omitted ⇒ `'left'`.** */
110
+ readonly align?: 'left' | 'right' | 'center';
111
+ /** The cell's content for one row. */
112
+ readonly render: (row: R) => ReactNode;
113
+ }
114
+ /**
115
+ * A **reference marker** on the list's shared value scale — a labelled
116
+ * vertical dotted rule through every row (an SLA threshold, a capacity line,
117
+ * the fleet average). The list-family counterpart of the canvas `<Marker>` /
118
+ * `<Baseline>`: user-authored reference, so it draws in the **annotation
119
+ * (marks) register**, never a data hue.
120
+ *
121
+ * `value` is in data units on the shared scale. When the domain is
122
+ * auto-fitted, marker values **join the fit** — a threshold above the data
123
+ * max widens the scale rather than clamping to the edge (an explicit
124
+ * `domain` prop still wins, and then an out-of-domain marker clamps).
125
+ */
126
+ export interface ListMarker {
127
+ /** Position in data units on the shared scale. */
128
+ readonly value: number;
129
+ /** Printed above the list, centred on the rule. Omit for a bare rule. */
130
+ readonly label?: string;
131
+ }
132
+ /** Row order: `'desc'` puts the largest value at the top (the ranked-list
133
+ * default), `'asc'` the smallest. */
134
+ export type ListSortDirection = 'asc' | 'desc';
135
+ /**
136
+ * Sort rows for display. Precedence: a `custom` comparator wins outright;
137
+ * else `sortBy` names the {@link ListRow.values} entry that drives the order
138
+ * (with several glyph columns, this is how you decide which one ranks the
139
+ * list); else the input order stands (the splits case — chronological rows).
140
+ *
141
+ * Value semantics under `sortBy`: numbers order numerically, strings
142
+ * lexicographically (`localeCompare`); when the two kinds meet, numbers come
143
+ * first; a missing / non-finite value sorts **last regardless of direction**
144
+ * (a dead interface stays at the bottom whether you rank best-first or
145
+ * worst-first). The sort is stable, so ties keep input order.
146
+ */
147
+ export declare function sortListRows<R extends ListRow>(rows: readonly R[], sortBy: string | undefined, direction: ListSortDirection, custom?: (a: R, b: R) => number): readonly R[];
148
+ /**
149
+ * The shared value scale's domain: every glyph line of every row maps through
150
+ * **one** `[min, max]` so lengths compare across the whole list (the point of
151
+ * a ranked list). Resolved from the data — `keys` names every `values` entry
152
+ * that lands on the scale (bar columns; box lower/upper/value) — as
153
+ * `[min(0, data min), data max]`: bars grow from zero, but a below-zero
154
+ * whisker still fits. `extra` values (reference {@link ListMarker}s) **join
155
+ * the auto fit**, so a threshold above the data max widens the scale instead
156
+ * of clamping to the right edge. An explicit `domain` prop overrides both
157
+ * ends (and ignores `extra`). An empty / all-missing list resolves `[0, 1]`
158
+ * so the mapping stays finite.
159
+ */
160
+ export declare function resolveListDomain(rows: readonly ListRow[], keys: readonly string[], explicit?: readonly [number, number], extra?: readonly number[]): readonly [number, number];
161
+ /**
162
+ * Map one value onto the shared scale as a **fraction of the track width**,
163
+ * clamped to `[0, 1]` (an out-of-domain value pins to an edge rather than
164
+ * escaping the row). `null` for a gap (missing / non-numeric / non-finite) —
165
+ * the caller draws nothing. A degenerate `min === max` domain maps everything
166
+ * to `0` (nothing to proportion against).
167
+ */
168
+ export declare function listFraction(value: ListValue, domain: readonly [number, number]): number | null;
169
+ /**
170
+ * Reject a half-specified box body — `q1`/`q3` are both-or-neither, the same
171
+ * contract as the canvas `<BoxPlot>` / `BoxColumns` (a box needs two body
172
+ * edges or none). Throws on exactly one.
173
+ */
174
+ export declare function validateBoxListColumn(column: BoxListColumn): void;
175
+ /** Options for {@link listRowsFromTimeSeries} / {@link listRowsFromValueSeries}. */
176
+ export interface ListRowsOptions {
177
+ /**
178
+ * The built-in label cell's content per row, from the row's ordinal position
179
+ * and its axis key (epoch ms for a `TimeSeries` — an interval key passes its
180
+ * `begin` — or the axis value for a `ValueSeries`). **Omitted ⇒ no label**,
181
+ * so the cell falls back to the row `key` (the stringified axis key);
182
+ * real-world lists almost always want this (`(i) => \`${i + 1}\`` for
183
+ * splits, a date formatter for daily rows).
184
+ */
185
+ readonly label?: (i: number, key: number) => ReactNode;
186
+ }
187
+ /**
188
+ * Read a series into {@link ListRow}s — **one row per event**, every value
189
+ * column (numeric *and* string) landing in `values` under its own name. The
190
+ * split-table reader: an `aggregate` per-split rollup feeds straight in, glyph
191
+ * columns pick the columns to draw, cells format the rest. The row `key` is
192
+ * the stringified axis key (an interval key's `begin`), which is unique in a
193
+ * series by construction.
194
+ *
195
+ * Reads per event via `event.get()` (row counts here are table-sized; the
196
+ * typed-array fast paths stay with the canvas readers).
197
+ */
198
+ export declare function listRowsFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, options?: ListRowsOptions): ListRow[];
199
+ /**
200
+ * The value-axis sibling of {@link listRowsFromTimeSeries} — one row per axis
201
+ * key (`series.byValue('dist')` per-km splits). The row `key` is the
202
+ * stringified axis value.
203
+ */
204
+ export declare function listRowsFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, options?: ListRowsOptions): ListRow[];
205
+ //# sourceMappingURL=list.d.ts.map
package/dist/list.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Sort rows for display. Precedence: a `custom` comparator wins outright;
3
+ * else `sortBy` names the {@link ListRow.values} entry that drives the order
4
+ * (with several glyph columns, this is how you decide which one ranks the
5
+ * list); else the input order stands (the splits case — chronological rows).
6
+ *
7
+ * Value semantics under `sortBy`: numbers order numerically, strings
8
+ * lexicographically (`localeCompare`); when the two kinds meet, numbers come
9
+ * first; a missing / non-finite value sorts **last regardless of direction**
10
+ * (a dead interface stays at the bottom whether you rank best-first or
11
+ * worst-first). The sort is stable, so ties keep input order.
12
+ */
13
+ export function sortListRows(rows, sortBy, direction, custom) {
14
+ if (custom !== undefined)
15
+ return [...rows].sort(custom);
16
+ if (sortBy === undefined)
17
+ return rows;
18
+ const dir = direction === 'asc' ? 1 : -1;
19
+ return [...rows].sort((a, b) => {
20
+ const av = a.values[sortBy];
21
+ const bv = b.values[sortBy];
22
+ const aNum = typeof av === 'number' && Number.isFinite(av);
23
+ const bNum = typeof bv === 'number' && Number.isFinite(bv);
24
+ const aStr = typeof av === 'string';
25
+ const bStr = typeof bv === 'string';
26
+ // Missing sorts last in BOTH directions — absence is not a small value.
27
+ const aMissing = !aNum && !aStr;
28
+ const bMissing = !bNum && !bStr;
29
+ if (aMissing || bMissing)
30
+ return aMissing === bMissing ? 0 : aMissing ? 1 : -1;
31
+ if (aNum && bNum)
32
+ return dir * (av - bv);
33
+ if (aStr && bStr)
34
+ return dir * av.localeCompare(bv);
35
+ // Mixed kinds: numbers rank before strings, direction-independent (a
36
+ // deliberate tie-break, not an ordering claim across kinds).
37
+ return aNum ? -1 : 1;
38
+ });
39
+ }
40
+ /**
41
+ * The shared value scale's domain: every glyph line of every row maps through
42
+ * **one** `[min, max]` so lengths compare across the whole list (the point of
43
+ * a ranked list). Resolved from the data — `keys` names every `values` entry
44
+ * that lands on the scale (bar columns; box lower/upper/value) — as
45
+ * `[min(0, data min), data max]`: bars grow from zero, but a below-zero
46
+ * whisker still fits. `extra` values (reference {@link ListMarker}s) **join
47
+ * the auto fit**, so a threshold above the data max widens the scale instead
48
+ * of clamping to the right edge. An explicit `domain` prop overrides both
49
+ * ends (and ignores `extra`). An empty / all-missing list resolves `[0, 1]`
50
+ * so the mapping stays finite.
51
+ */
52
+ export function resolveListDomain(rows, keys, explicit, extra) {
53
+ if (explicit !== undefined)
54
+ return explicit;
55
+ let min = Infinity;
56
+ let max = -Infinity;
57
+ const take = (v) => {
58
+ if (typeof v === 'number' && Number.isFinite(v)) {
59
+ if (v < min)
60
+ min = v;
61
+ if (v > max)
62
+ max = v;
63
+ }
64
+ };
65
+ for (const row of rows) {
66
+ for (const key of keys)
67
+ take(row.values[key]);
68
+ }
69
+ if (extra !== undefined)
70
+ for (const v of extra)
71
+ take(v);
72
+ if (min === Infinity)
73
+ return [0, 1];
74
+ return [Math.min(0, min), max];
75
+ }
76
+ /**
77
+ * Map one value onto the shared scale as a **fraction of the track width**,
78
+ * clamped to `[0, 1]` (an out-of-domain value pins to an edge rather than
79
+ * escaping the row). `null` for a gap (missing / non-numeric / non-finite) —
80
+ * the caller draws nothing. A degenerate `min === max` domain maps everything
81
+ * to `0` (nothing to proportion against).
82
+ */
83
+ export function listFraction(value, domain) {
84
+ if (typeof value !== 'number' || !Number.isFinite(value))
85
+ return null;
86
+ const [d0, d1] = domain;
87
+ if (d1 === d0)
88
+ return 0;
89
+ const f = (value - d0) / (d1 - d0);
90
+ return f < 0 ? 0 : f > 1 ? 1 : f;
91
+ }
92
+ /**
93
+ * Reject a half-specified box body — `q1`/`q3` are both-or-neither, the same
94
+ * contract as the canvas `<BoxPlot>` / `BoxColumns` (a box needs two body
95
+ * edges or none). Throws on exactly one.
96
+ */
97
+ export function validateBoxListColumn(column) {
98
+ if ((column.q1 === undefined) !== (column.q3 === undefined)) {
99
+ throw new RangeError(`BoxList: 'q1' and 'q3' are both-or-neither — a box body needs both edges ` +
100
+ `(got q1=${column.q1 ?? 'undefined'}, q3=${column.q3 ?? 'undefined'}). ` +
101
+ `Omit both for a range-only box (a lower→upper band).`);
102
+ }
103
+ }
104
+ /**
105
+ * Read a series into {@link ListRow}s — **one row per event**, every value
106
+ * column (numeric *and* string) landing in `values` under its own name. The
107
+ * split-table reader: an `aggregate` per-split rollup feeds straight in, glyph
108
+ * columns pick the columns to draw, cells format the rest. The row `key` is
109
+ * the stringified axis key (an interval key's `begin`), which is unique in a
110
+ * series by construction.
111
+ *
112
+ * Reads per event via `event.get()` (row counts here are table-sized; the
113
+ * typed-array fast paths stay with the canvas readers).
114
+ */
115
+ export function listRowsFromTimeSeries(series, options = {}) {
116
+ const begin = series.keyColumn().begin;
117
+ return buildListRows(valueColumnNames(series.schema), (name) => columnReader(series.column(name)), series.length, (i) => begin[i], options);
118
+ }
119
+ /**
120
+ * The value-axis sibling of {@link listRowsFromTimeSeries} — one row per axis
121
+ * key (`series.byValue('dist')` per-km splits). The row `key` is the
122
+ * stringified axis value.
123
+ */
124
+ export function listRowsFromValueSeries(series, options = {}) {
125
+ const axis = series.axisValues();
126
+ return buildListRows(valueColumnNames(series.schema), (name) => columnReader(series.column(name)), series.length, (i) => axis[i], options);
127
+ }
128
+ /** A schema's value column names in order (the key column excluded). */
129
+ function valueColumnNames(schema) {
130
+ const cols = schema;
131
+ return cols.slice(1).map((c) => c.name);
132
+ }
133
+ /**
134
+ * A column handle's per-cell reader. `read(i)` is a method on the column
135
+ * *class* (the same access path `data.ts` uses — the bulk readers are mounted
136
+ * by a side-effect import that bundlers tree-shake away); the cast mirrors
137
+ * `assertNumericColumn`'s. `undefined` for an unknown column name.
138
+ */
139
+ function columnReader(col) {
140
+ if (col === undefined || col === null)
141
+ return undefined;
142
+ const c = col;
143
+ return (i) => c.read(i);
144
+ }
145
+ /** Shared body of the two readers: walk rows, lift each value column. */
146
+ function buildListRows(names, readerOf, length, keyAt, options) {
147
+ const readers = names.map((name) => ({ name, read: readerOf(name) }));
148
+ const out = [];
149
+ for (let i = 0; i < length; i += 1) {
150
+ const values = {};
151
+ for (const { name, read } of readers) {
152
+ const v = read?.(i);
153
+ values[name] =
154
+ typeof v === 'number' || typeof v === 'string' ? v : undefined;
155
+ }
156
+ const key = keyAt(i);
157
+ out.push({
158
+ key: String(key),
159
+ ...(options.label !== undefined ? { label: options.label(i, key) } : {}),
160
+ values,
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+ //# sourceMappingURL=list.js.map
package/dist/theme.d.ts CHANGED
@@ -330,6 +330,36 @@ export interface BarStyle {
330
330
  readonly gap: number;
331
331
  readonly minWidth: number;
332
332
  readonly outlineWidth: number;
333
+ /**
334
+ * Optional distinct **hover** fill, so a bar can read a three-step emphasis —
335
+ * `fill` at rest → `hover` under the pointer → `highlight` (+ outline) when
336
+ * selected. **Omitted ⇒ `highlight`**, which is the shipped behaviour: hover
337
+ * and select share one colour and differ only by the selected bar's outline.
338
+ *
339
+ * The scatter analogue is `outline` vs {@link ScatterStyle.selectedOutline} —
340
+ * bars were the less expressive layer for the same two-state interaction
341
+ * ([#577](https://github.com/pond-ts/pond/issues/577)). This is the *hover*
342
+ * half rather than a rename of `highlight`, so no existing theme changes
343
+ * meaning; a theme that wants the distinction opts in by adding one colour.
344
+ *
345
+ * **Where it applies.** Read by the `drawBars` single-series path, which
346
+ * since [PND-BARSEM] covers every **one-segment vertical** bar however it
347
+ * was fed — a `series` + `column` chart, a one-column `bins` histogram, or
348
+ * a one-entry `columns`. Still not read by:
349
+ *
350
+ * - a genuine **multi-group stack** (`columns` / a `Map` series), whose
351
+ * {@link StackStyle} has no hover channel — segments in one bin would
352
+ * need their own hovered identity;
353
+ * - **`categories`** and **horizontal** charts, which keep the transposed
354
+ * stacked draw path ([PND-HCAT] tracks the categorical half);
355
+ * - **`binColors`** (per-bar colours), which pops each bar's *own* fill for
356
+ * both states so a red/green volume bar keeps its meaning while live —
357
+ * the one *design* exclusion rather than a path consequence.
358
+ *
359
+ * The **decimated** dense-bar pass also draws the flat fill only, as it
360
+ * already did for `highlight`.
361
+ */
362
+ readonly hover?: string;
333
363
  }
334
364
  /**
335
365
  * The neutral default theme. `default` / `primary` match the M1 `LineChart`
package/dist/theme.js CHANGED
@@ -74,6 +74,18 @@ export const defaultTheme = {
74
74
  whisker: '#aabee9',
75
75
  whiskerWidth: 1,
76
76
  },
77
+ // The warm accent box — the second series of a paired distribution (an
78
+ // in/out traffic list), mirroring `bar.secondary` / `line.secondary`.
79
+ secondary: {
80
+ fill: '#e8836b',
81
+ fillOpacity: 0.3,
82
+ stroke: '#d65f43',
83
+ strokeWidth: 1.5,
84
+ median: '#b4442a',
85
+ medianWidth: 2,
86
+ whisker: '#f0c2b2',
87
+ whiskerWidth: 1,
88
+ },
77
89
  },
78
90
  candle: {
79
91
  // Neutral / unbranded up-down pair — *not* market green/red (a consumer
@@ -225,6 +237,18 @@ export const estelaTheme = {
225
237
  whisker: '#a4e4d9', // --es-reef
226
238
  whiskerWidth: 1.5,
227
239
  },
240
+ // The warm filament accent — the paired second distribution, mirroring
241
+ // `bar.secondary` / `line.hr` on the dark ground.
242
+ secondary: {
243
+ fill: '#E0B36A', // --es-filament
244
+ fillOpacity: 0.28,
245
+ stroke: '#E0B36A',
246
+ strokeWidth: 1.5,
247
+ median: '#F1FBF9', // --es-foam
248
+ medianWidth: 2,
249
+ whisker: '#EDD5A8',
250
+ whiskerWidth: 1.5,
251
+ },
228
252
  },
229
253
  candle: {
230
254
  // On the dark ground: brand teal rising, warm filament falling — the estela
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.53.0",
42
- "pond-ts": "^0.53.0",
41
+ "@pond-ts/react": "^0.55.0",
42
+ "pond-ts": "^0.55.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {