@pond-ts/charts 0.54.0 → 0.56.2

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +451 -1
  2. package/dist/AreaChart.d.ts +47 -22
  3. package/dist/AreaChart.js +24 -1
  4. package/dist/BandChart.d.ts +29 -17
  5. package/dist/BarChart.d.ts +92 -49
  6. package/dist/BarChart.js +65 -8
  7. package/dist/BarList.d.ts +127 -0
  8. package/dist/BarList.js +84 -0
  9. package/dist/BoxList.d.ts +108 -0
  10. package/dist/BoxList.js +125 -0
  11. package/dist/BoxPlot.d.ts +63 -30
  12. package/dist/Candlestick.d.ts +5 -4
  13. package/dist/ChartRow.js +57 -6
  14. package/dist/Layers.js +22 -2
  15. package/dist/LineChart.d.ts +29 -26
  16. package/dist/ListTable.d.ts +51 -0
  17. package/dist/ListTable.js +143 -0
  18. package/dist/ScatterChart.d.ts +27 -17
  19. package/dist/YAxis.d.ts +29 -1
  20. package/dist/YAxis.js +19 -5
  21. package/dist/area.js +46 -15
  22. package/dist/band.js +13 -0
  23. package/dist/bars.d.ts +89 -18
  24. package/dist/bars.js +141 -30
  25. package/dist/column-names.d.ts +74 -0
  26. package/dist/column-names.js +2 -0
  27. package/dist/context.d.ts +40 -2
  28. package/dist/data.d.ts +19 -0
  29. package/dist/data.js +46 -0
  30. package/dist/dev.d.ts +2 -0
  31. package/dist/dev.js +2 -0
  32. package/dist/domain.d.ts +54 -1
  33. package/dist/domain.js +195 -2
  34. package/dist/format.d.ts +20 -0
  35. package/dist/format.js +23 -10
  36. package/dist/gaps.d.ts +33 -0
  37. package/dist/gaps.js +49 -0
  38. package/dist/index.d.ts +19 -13
  39. package/dist/index.js +21 -13
  40. package/dist/line.js +10 -1
  41. package/dist/list-source.d.ts +61 -0
  42. package/dist/list-source.js +5 -0
  43. package/dist/list.d.ts +205 -0
  44. package/dist/list.js +165 -0
  45. package/dist/theme.d.ts +42 -0
  46. package/dist/theme.js +24 -0
  47. package/dist/viewport.d.ts +9 -1
  48. package/dist/viewport.js +40 -4
  49. package/dist/yticks.d.ts +44 -0
  50. package/dist/yticks.js +55 -0
  51. package/package.json +3 -3
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
@@ -315,6 +315,18 @@ export interface AreaStyle {
315
315
  readonly width: number;
316
316
  readonly fill: string;
317
317
  readonly fillOpacity: number;
318
+ /**
319
+ * Fill flat instead of grading to transparent at the baseline. Default
320
+ * (omitted / `false`) keeps the gradient — the elevation look a single area
321
+ * wants.
322
+ *
323
+ * Set it for **stacked** areas. A stack is drawn as overlapping cumulative
324
+ * bands, so a fade to transparent at the baseline lets every band below show
325
+ * through the one above it and the composition reads as mush. A flat fill is
326
+ * what makes the slabs opaque to each other. (`fillOpacity` still applies, so
327
+ * a stack can be uniformly translucent — just not *graded*.)
328
+ */
329
+ readonly flatFill?: boolean;
318
330
  }
319
331
  /**
320
332
  * A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
@@ -330,6 +342,36 @@ export interface BarStyle {
330
342
  readonly gap: number;
331
343
  readonly minWidth: number;
332
344
  readonly outlineWidth: number;
345
+ /**
346
+ * Optional distinct **hover** fill, so a bar can read a three-step emphasis —
347
+ * `fill` at rest → `hover` under the pointer → `highlight` (+ outline) when
348
+ * selected. **Omitted ⇒ `highlight`**, which is the shipped behaviour: hover
349
+ * and select share one colour and differ only by the selected bar's outline.
350
+ *
351
+ * The scatter analogue is `outline` vs {@link ScatterStyle.selectedOutline} —
352
+ * bars were the less expressive layer for the same two-state interaction
353
+ * ([#577](https://github.com/pond-ts/pond/issues/577)). This is the *hover*
354
+ * half rather than a rename of `highlight`, so no existing theme changes
355
+ * meaning; a theme that wants the distinction opts in by adding one colour.
356
+ *
357
+ * **Where it applies.** Read by the `drawBars` single-series path, which
358
+ * since [PND-BARSEM] covers every **one-segment vertical** bar however it
359
+ * was fed — a `series` + `column` chart, a one-column `bins` histogram, or
360
+ * a one-entry `columns`. Still not read by:
361
+ *
362
+ * - a genuine **multi-group stack** (`columns` / a `Map` series), whose
363
+ * {@link StackStyle} has no hover channel — segments in one bin would
364
+ * need their own hovered identity;
365
+ * - **`categories`** and **horizontal** charts, which keep the transposed
366
+ * stacked draw path ([PND-HCAT] tracks the categorical half);
367
+ * - **`binColors`** (per-bar colours), which pops each bar's *own* fill for
368
+ * both states so a red/green volume bar keeps its meaning while live —
369
+ * the one *design* exclusion rather than a path consequence.
370
+ *
371
+ * The **decimated** dense-bar pass also draws the flat fill only, as it
372
+ * already did for `highlight`.
373
+ */
374
+ readonly hover?: string;
333
375
  }
334
376
  /**
335
377
  * 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
@@ -26,7 +26,9 @@ export type TimeRange = readonly [number, number];
26
26
  export declare function clampToBounds(range: TimeRange, bounds: TimeRange): [number, number];
27
27
  /**
28
28
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
29
- * dragging the plot right reveals earlier data, i.e. a negative `dt`.
29
+ * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
30
+ * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
31
+ * delta through `xScale.invert()`, so it is fractional by construction.
30
32
  */
31
33
  export declare function panRange(range: TimeRange, dt: number): [number, number];
32
34
  /**
@@ -34,6 +36,12 @@ export declare function panRange(range: TimeRange, dt: number): [number, number]
34
36
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
35
37
  * duration never drops below `minDuration` (the zoom-in floor); at the floor the
36
38
  * pivot keeps its fractional position in the window.
39
+ *
40
+ * The result is snapped to whole milliseconds ({@link roundRange}). `minDuration`
41
+ * is applied **before** the snap, so the floor is honoured in the units the
42
+ * caller expressed it in; a `minDuration` below 1 ms cannot be represented and
43
+ * lands on the 1 ms floor the snap guarantees, which is the finest view this
44
+ * model has.
37
45
  */
38
46
  export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number): [number, number];
39
47
  /**
package/dist/viewport.js CHANGED
@@ -36,28 +36,64 @@ export function clampToBounds(range, bounds) {
36
36
  return [hi - span, hi];
37
37
  return [range[0], range[1]];
38
38
  }
39
+ /**
40
+ * Snap a computed view range to **whole milliseconds** — the last step of every
41
+ * gesture that derives a range from pixels.
42
+ *
43
+ * A wheel-zoom or drag-pan turns a pixel position into a time via
44
+ * `xScale.invert()`, so the result is fractional *by construction*: an ordinary
45
+ * scroll produces `1.7e12 + 0.37`. The epoch millisecond is this model's atomic
46
+ * unit — a sub-millisecond view range is not a finer view, it is a number with
47
+ * no meaning — and downstream consumers are entitled to assume it. One of them
48
+ * did: `Temporal.Instant` refuses a non-integer epoch ms outright, so a
49
+ * `cursorSequence` over a calendar grain threw on a plain scroll and unmounted
50
+ * the page. Core now floors the instant, which fixes that symptom; rounding
51
+ * here closes the class, because nothing downstream ever sees the fraction.
52
+ *
53
+ * **Never collapses a positive span.** `[10.4, 10.6]` would otherwise round to
54
+ * `[10, 10]` — a zero-width view, which is a division by zero in every scale
55
+ * built from it. A span that survives rounding keeps its rounded width; one
56
+ * that doesn't is opened to the 1 ms floor. A range that arrives degenerate
57
+ * (`hi <= lo`) is passed through rounded, since widening it would invent a view
58
+ * the caller didn't ask for.
59
+ */
60
+ function roundRange(lo, hi) {
61
+ const a = Math.round(lo);
62
+ const b = Math.round(hi);
63
+ // `Math.round` is monotonic, so `b < a` is impossible for `hi >= lo`; the only
64
+ // way a positive span collapses is both ends landing on the same integer.
65
+ return b === a && hi > lo ? [a, a + 1] : [a, b];
66
+ }
39
67
  /**
40
68
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
41
- * dragging the plot right reveals earlier data, i.e. a negative `dt`.
69
+ * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
70
+ * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
71
+ * delta through `xScale.invert()`, so it is fractional by construction.
42
72
  */
43
73
  export function panRange(range, dt) {
44
- return [range[0] + dt, range[1] + dt];
74
+ return roundRange(range[0] + dt, range[1] + dt);
45
75
  }
46
76
  /**
47
77
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
48
78
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
49
79
  * duration never drops below `minDuration` (the zoom-in floor); at the floor the
50
80
  * pivot keeps its fractional position in the window.
81
+ *
82
+ * The result is snapped to whole milliseconds ({@link roundRange}). `minDuration`
83
+ * is applied **before** the snap, so the floor is honoured in the units the
84
+ * caller expressed it in; a `minDuration` below 1 ms cannot be represented and
85
+ * lands on the 1 ms floor the snap guarantees, which is the finest view this
86
+ * model has.
51
87
  */
52
88
  export function zoomRange(range, pivot, factor, minDuration = 1) {
53
89
  const lo = pivot - (pivot - range[0]) * factor;
54
90
  const hi = pivot + (range[1] - pivot) * factor;
55
91
  if (hi - lo >= minDuration)
56
- return [lo, hi];
92
+ return roundRange(lo, hi);
57
93
  // Floor reached: hold the pivot's fractional position, set span = minDuration.
58
94
  const span = range[1] - range[0];
59
95
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
60
- return [pivot - minDuration * frac, pivot + minDuration * (1 - frac)];
96
+ return roundRange(pivot - minDuration * frac, pivot + minDuration * (1 - frac));
61
97
  }
62
98
  /**
63
99
  * Pan a range on a **trading-time** axis: shift both endpoints by the same
package/dist/yticks.d.ts CHANGED
@@ -17,4 +17,48 @@
17
17
  * nice 1-2-5 values near it, so a larger count on a tall row is exactly right.
18
18
  */
19
19
  export declare function resolveYTickCount(height: number, explicit?: number | undefined): number;
20
+ /** The slice of a scale {@link yTickValues} reads. */
21
+ interface TickableScale {
22
+ ticks(count?: number): number[];
23
+ domain(): number[];
24
+ /** Present on d3's `scaleLog` and on no other continuous scale. */
25
+ base?: () => number;
26
+ }
27
+ /**
28
+ * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
29
+ * the one list, so a label and its gridline stay on the same instants.
30
+ *
31
+ * On a linear scale this is just `scale.ticks(count)`, whose 1-2-5 selection
32
+ * treats the count as a target. **On a log scale it cannot be**, because d3's
33
+ * `scaleLog.ticks(count)` is not a target at all — it is nearly a step
34
+ * function, and the jump is catastrophic. Measured against a real seven-decade
35
+ * domain (ESnet's traffic history, 1.9e10 → 2.6e17 bytes):
36
+ *
37
+ * | `count` | ticks returned |
38
+ * | ------- | -------------- |
39
+ * | 4 | 3 (every *other* decade — 1e12, 1e14, 1e16) |
40
+ * | 6 | 7 (every decade — the one right answer) |
41
+ * | 8 | **64** (every 2,3,…9 × decade) |
42
+ *
43
+ * Since the count is height-derived (`height / 48`), that means a 260px row
44
+ * silently labels 3 of 7 decades and a 400px row draws 64 gridlines and 64
45
+ * labels — a 40px resize flipping between them. Neither is a rendering nicety;
46
+ * both are unreadable.
47
+ *
48
+ * So for a log scale we pick the decades ourselves: every `k`th power of ten,
49
+ * with `k` the smallest step whose tick count fits the budget. That is what a
50
+ * log plot is conventionally gridded on, it degrades predictably as the row
51
+ * shrinks, and it never explodes.
52
+ *
53
+ * Below two decades of span there aren't enough powers of ten to grid with, and
54
+ * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
55
+ * behaved — so that case defers to the scale.
56
+ *
57
+ * Log detection is structural: `base()` exists on `scaleLog` and on no other
58
+ * continuous scale. The alternative is threading the axis kind down to the
59
+ * gridline site, which has no `AxisSpec` in scope — the same localized-shape
60
+ * approach `resolveBarBaseline` takes to read `.domain()`.
61
+ */
62
+ export declare function yTickValues(scale: TickableScale, count: number): number[];
63
+ export {};
20
64
  //# sourceMappingURL=yticks.d.ts.map
package/dist/yticks.js CHANGED
@@ -25,4 +25,59 @@ export function resolveYTickCount(height, explicit) {
25
25
  return Math.max(1, Math.floor(explicit));
26
26
  return Math.max(2, Math.floor(height / Y_TICK_PX));
27
27
  }
28
+ /**
29
+ * The y tick **values** a `<YAxis>`'s labels and the row's gridlines draw —
30
+ * the one list, so a label and its gridline stay on the same instants.
31
+ *
32
+ * On a linear scale this is just `scale.ticks(count)`, whose 1-2-5 selection
33
+ * treats the count as a target. **On a log scale it cannot be**, because d3's
34
+ * `scaleLog.ticks(count)` is not a target at all — it is nearly a step
35
+ * function, and the jump is catastrophic. Measured against a real seven-decade
36
+ * domain (ESnet's traffic history, 1.9e10 → 2.6e17 bytes):
37
+ *
38
+ * | `count` | ticks returned |
39
+ * | ------- | -------------- |
40
+ * | 4 | 3 (every *other* decade — 1e12, 1e14, 1e16) |
41
+ * | 6 | 7 (every decade — the one right answer) |
42
+ * | 8 | **64** (every 2,3,…9 × decade) |
43
+ *
44
+ * Since the count is height-derived (`height / 48`), that means a 260px row
45
+ * silently labels 3 of 7 decades and a 400px row draws 64 gridlines and 64
46
+ * labels — a 40px resize flipping between them. Neither is a rendering nicety;
47
+ * both are unreadable.
48
+ *
49
+ * So for a log scale we pick the decades ourselves: every `k`th power of ten,
50
+ * with `k` the smallest step whose tick count fits the budget. That is what a
51
+ * log plot is conventionally gridded on, it degrades predictably as the row
52
+ * shrinks, and it never explodes.
53
+ *
54
+ * Below two decades of span there aren't enough powers of ten to grid with, and
55
+ * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
56
+ * behaved — so that case defers to the scale.
57
+ *
58
+ * Log detection is structural: `base()` exists on `scaleLog` and on no other
59
+ * continuous scale. The alternative is threading the axis kind down to the
60
+ * gridline site, which has no `AxisSpec` in scope — the same localized-shape
61
+ * approach `resolveBarBaseline` takes to read `.domain()`.
62
+ */
63
+ export function yTickValues(scale, count) {
64
+ if (typeof scale.base !== 'function')
65
+ return scale.ticks(count);
66
+ const domain = scale.domain();
67
+ const lo = Math.min(domain[0], domain[domain.length - 1]);
68
+ const hi = Math.max(domain[0], domain[domain.length - 1]);
69
+ if (!(lo > 0) || !(hi > lo))
70
+ return scale.ticks(count);
71
+ const first = Math.ceil(Math.log10(lo));
72
+ const last = Math.floor(Math.log10(hi));
73
+ const decades = last - first + 1;
74
+ if (decades < 2)
75
+ return scale.ticks(count);
76
+ const budget = Math.max(2, count);
77
+ const step = Math.max(1, Math.ceil(decades / budget));
78
+ const out = [];
79
+ for (let e = first; e <= last; e += step)
80
+ out.push(10 ** e);
81
+ return out;
82
+ }
28
83
  //# sourceMappingURL=yticks.js.map