@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.
- package/CHANGELOG.md +451 -1
- package/dist/AreaChart.d.ts +47 -22
- package/dist/AreaChart.js +24 -1
- package/dist/BandChart.d.ts +29 -17
- package/dist/BarChart.d.ts +92 -49
- package/dist/BarChart.js +65 -8
- package/dist/BarList.d.ts +127 -0
- package/dist/BarList.js +84 -0
- package/dist/BoxList.d.ts +108 -0
- package/dist/BoxList.js +125 -0
- package/dist/BoxPlot.d.ts +63 -30
- package/dist/Candlestick.d.ts +5 -4
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +22 -2
- package/dist/LineChart.d.ts +29 -26
- package/dist/ListTable.d.ts +51 -0
- package/dist/ListTable.js +143 -0
- package/dist/ScatterChart.d.ts +27 -17
- package/dist/YAxis.d.ts +29 -1
- package/dist/YAxis.js +19 -5
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +89 -18
- package/dist/bars.js +141 -30
- package/dist/column-names.d.ts +74 -0
- package/dist/column-names.js +2 -0
- package/dist/context.d.ts +40 -2
- package/dist/data.d.ts +19 -0
- package/dist/data.js +46 -0
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/index.d.ts +19 -13
- package/dist/index.js +21 -13
- package/dist/line.js +10 -1
- package/dist/list-source.d.ts +61 -0
- package/dist/list-source.js +5 -0
- package/dist/list.d.ts +205 -0
- package/dist/list.js +165 -0
- package/dist/theme.d.ts +42 -0
- package/dist/theme.js +24 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- 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
|
package/dist/viewport.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|