@pond-ts/charts 0.57.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +576 -0
- package/CHANGELOG.md +1213 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +263 -39
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +36 -48
- package/dist/ChartContainer.js +465 -59
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +176 -14
- package/dist/HeatMap.d.ts +176 -0
- package/dist/HeatMap.js +344 -0
- package/dist/Layers.d.ts +5 -1
- package/dist/Layers.js +1014 -253
- package/dist/Legend.js +8 -4
- package/dist/LineChart.d.ts +18 -1
- package/dist/LineChart.js +165 -4
- package/dist/ListTable.d.ts +30 -3
- package/dist/ListTable.js +381 -23
- package/dist/ScatterChart.d.ts +3 -2
- package/dist/ScatterChart.js +68 -4
- package/dist/XAxis.js +40 -22
- package/dist/YAxis.d.ts +58 -2
- package/dist/YAxis.js +3 -1
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +67 -6
- package/dist/bars.js +250 -35
- package/dist/box.d.ts +2 -2
- package/dist/box.js +158 -40
- package/dist/brush.d.ts +142 -0
- package/dist/brush.js +179 -0
- package/dist/child-index.d.ts +27 -0
- package/dist/child-index.js +57 -0
- package/dist/context.d.ts +870 -39
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.js +16 -1
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -0
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/range.d.ts +14 -1
- package/dist/range.js +24 -3
- package/dist/scatter.d.ts +17 -9
- package/dist/scatter.js +221 -33
- package/dist/select.d.ts +13 -5
- package/dist/select.js +14 -6
- package/dist/selection-fixtures.d.ts +174 -0
- package/dist/selection-fixtures.js +569 -0
- package/dist/selection-stories.d.ts +73 -0
- package/dist/selection-stories.js +301 -0
- package/dist/selectors.d.ts +316 -0
- package/dist/selectors.js +391 -0
- package/dist/span.d.ts +122 -0
- package/dist/span.js +203 -0
- package/dist/sweep.d.ts +154 -0
- package/dist/sweep.js +282 -0
- package/dist/theme.d.ts +510 -5
- package/dist/theme.js +217 -41
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- package/dist/yticks.d.ts +3 -0
- package/dist/yticks.js +104 -0
- package/package.json +6 -5
package/dist/heat.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { barSpanPx } from './range.js';
|
|
2
|
+
import { NO_SPANS, spanContainsPoint } from './span.js';
|
|
3
|
+
import { visibleSpanRange } from './culling.js';
|
|
4
|
+
import { decimateHeat, decimateHeatRows, } from './decimate.js';
|
|
5
|
+
/**
|
|
6
|
+
* Map a value onto a **banded** ramp: `colors` split `[lo, hi]` into equal
|
|
7
|
+
* steps and a value takes the colour of the band it falls in.
|
|
8
|
+
*
|
|
9
|
+
* Banded rather than interpolated on purpose. It is what the climate-stripes
|
|
10
|
+
* card does today (its `anomalyStep` buckets into the ramp's length, which this
|
|
11
|
+
* replaces), it is the conventional reading for stripes and calendar heat maps,
|
|
12
|
+
* and a banded scale is honest about resolution in a way a smooth gradient is
|
|
13
|
+
* not — you can count the steps and read a cell against a legend. With nine or
|
|
14
|
+
* more stops it is visually indistinguishable from a gradient anyway.
|
|
15
|
+
*
|
|
16
|
+
* A non-finite value, or an empty ramp, yields `undefined` — the caller decides
|
|
17
|
+
* whether that is a skipped cell or a fallback fill.
|
|
18
|
+
*/
|
|
19
|
+
export function bandedColor(value, colors, lo, hi, scale = 'linear') {
|
|
20
|
+
if (!Number.isFinite(value) || colors.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (!(hi > lo))
|
|
23
|
+
return colors[colors.length - 1]; // degenerate domain: one band
|
|
24
|
+
// `log` bands on `log1p` of the offset from `lo`, not on `log`, so that a
|
|
25
|
+
// value **at** `lo` is a real band rather than `-Infinity`. Zero is the common
|
|
26
|
+
// case that needs it — an incidence grid is mostly zeros once a disease is
|
|
27
|
+
// eliminated, and those cells are the point of the chart, not an edge case.
|
|
28
|
+
const clamped = Math.min(hi, Math.max(lo, value));
|
|
29
|
+
const t = scale === 'log'
|
|
30
|
+
? Math.log1p(clamped - lo) / Math.log1p(hi - lo)
|
|
31
|
+
: (value - lo) / (hi - lo);
|
|
32
|
+
const band = Math.floor(t * colors.length);
|
|
33
|
+
// Clamp so the domain's own endpoints land in the first / last band rather
|
|
34
|
+
// than falling off (t === 1 would index one past the end), and so a value
|
|
35
|
+
// outside a *pinned* domain reads at the extreme instead of vanishing.
|
|
36
|
+
return colors[Math.min(colors.length - 1, Math.max(0, band))];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The `[min, max]` of the finite values across **every** cell — the colour
|
|
40
|
+
* domain when the caller does not pin one. `null` when nothing is finite.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately **not** widened to include `0`, unlike `barExtent`: a bar's
|
|
43
|
+
* height is measured from a baseline so zero must be in the domain, but a
|
|
44
|
+
* cell's colour is measured against the data's own range. Widening would waste
|
|
45
|
+
* half the ramp on an all-positive grid.
|
|
46
|
+
*
|
|
47
|
+
* Note this spans the **whole grid**, not each row — every row is read against
|
|
48
|
+
* one scale, which is what makes rows comparable to each other.
|
|
49
|
+
*/
|
|
50
|
+
export function heatValueExtent(ss) {
|
|
51
|
+
let min = Infinity;
|
|
52
|
+
let max = -Infinity;
|
|
53
|
+
for (let i = 0; i < ss.values.length; i += 1) {
|
|
54
|
+
const v = ss.values[i];
|
|
55
|
+
if (Number.isFinite(v)) {
|
|
56
|
+
if (v < min)
|
|
57
|
+
min = v;
|
|
58
|
+
if (v > max)
|
|
59
|
+
max = v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return min === Infinity ? null : [min, max];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The pixel rect of the cell at bin `b`, row `g` — `[x0, x1, yTop, yBottom]`,
|
|
66
|
+
* ascending on both axes — or `null` for a gap (non-finite value), which draws
|
|
67
|
+
* nothing and owns no hit region so a hole in the record reads as a hole.
|
|
68
|
+
*
|
|
69
|
+
* x comes from the bin's own span via {@link barSpanPx}, shared with bars so
|
|
70
|
+
* cells and bars tile identically. y is the row's **unit slot** `[g, g+1]`
|
|
71
|
+
* through the y scale, which is why the layer reports `yExtent` as `[0, G]` and
|
|
72
|
+
* labels rows via `binCategories` at each slot centre.
|
|
73
|
+
*
|
|
74
|
+
* **Row order follows the y scale**, so with the usual inverted pixel range row
|
|
75
|
+
* `0` sits at the *bottom*. That matches the existing band-axis convention
|
|
76
|
+
* (a horizontal histogram's first bin is its lowest), and a caller who wants
|
|
77
|
+
* the first column at the top reverses the column list.
|
|
78
|
+
*/
|
|
79
|
+
export function cellRect(ss, b, g, xScale, yScale, gapPx, minWidthPx, orientation = 'vertical') {
|
|
80
|
+
const G = ss.groups.length;
|
|
81
|
+
if (!Number.isFinite(ss.values[b * G + g]))
|
|
82
|
+
return null;
|
|
83
|
+
const vertical = orientation === 'vertical';
|
|
84
|
+
// Two position axes, neither of them a value axis — which is what makes a heat
|
|
85
|
+
// map's transpose simpler than a bar's. `'horizontal'` swaps which scale
|
|
86
|
+
// carries the bins and which carries the group slots; nothing else moves.
|
|
87
|
+
const binScale = vertical ? xScale : yScale;
|
|
88
|
+
const groupScale = vertical ? yScale : xScale;
|
|
89
|
+
const [spanLo, spanHi] = barSpanPx(ss.begin[b], ss.end[b], binScale, gapPx, minWidthPx);
|
|
90
|
+
const [bandLo, bandHi] = slotBandPx(groupScale, g, g + 1, gapPx);
|
|
91
|
+
return vertical
|
|
92
|
+
? [spanLo, spanHi, bandLo, bandHi]
|
|
93
|
+
: [bandLo, bandHi, spanLo, spanHi];
|
|
94
|
+
}
|
|
95
|
+
/** The pixel band of unit slots `[a, b)` on the group axis, ascending, inset by
|
|
96
|
+
* the gap but never past collapsing. Shared by the draw loop and `cellRect`. */
|
|
97
|
+
function slotBandPx(groupScale, a, b, gapPx) {
|
|
98
|
+
const p0 = groupScale(a);
|
|
99
|
+
const p1 = groupScale(b);
|
|
100
|
+
const lo = Math.min(p0, p1);
|
|
101
|
+
const hi = Math.max(p0, p1);
|
|
102
|
+
const inset = Math.min(gapPx / 2, Math.max(0, (hi - lo) / 2 - 0.5));
|
|
103
|
+
return [lo + inset, hi - inset];
|
|
104
|
+
}
|
|
105
|
+
/** Stable identity for "no marks" — the resting case, so a caller narrowing an
|
|
106
|
+
* empty set never hands `drawHeat` a fresh array, and the default argument is
|
|
107
|
+
* one allocation for the module rather than one per call. */
|
|
108
|
+
const NO_MARKS = [];
|
|
109
|
+
/**
|
|
110
|
+
* Collect into `out` the **row labels** of every member of `set` naming bin `b`
|
|
111
|
+
* of this series ([PND-MULTISEL] / RFC A4.3).
|
|
112
|
+
*
|
|
113
|
+
* **The identity rule is unchanged** from when this layer matched one mark: a
|
|
114
|
+
* member identifies the cell at (`b`, `g`) when its layer `id` matches, its bin
|
|
115
|
+
* half matches (the stable per-bin `mark` where the series carries one, else the
|
|
116
|
+
* bin `key`), and its `label` is the row's group. Keeping that rule is what
|
|
117
|
+
* keeps one selection vocabulary across bars and cells. This function applies
|
|
118
|
+
* the first two, and leaves the row loop the `label` compare.
|
|
119
|
+
*
|
|
120
|
+
* **Why it splits there.** The bin half depends only on `b`, so scanning the
|
|
121
|
+
* whole set per *cell* would be O(V·G·|set|); scanning it per *bin* and leaving
|
|
122
|
+
* the row loop a label compare against the handful that survive is
|
|
123
|
+
* O(V·|set| + V·G·k), where k is 0 for almost every bin. On a 365×45 grid with
|
|
124
|
+
* eight marks live that is ~3k member compares instead of ~130k, and it is why
|
|
125
|
+
* a heat-map repaint under a plural pin costs what it did under a single one
|
|
126
|
+
* (`scripts/perf-heat.mjs`).
|
|
127
|
+
*
|
|
128
|
+
* Linear over the set rather than indexed, the reasoning `barMatchesAny`
|
|
129
|
+
* records: a selection is a handful of cells a person clicked, not a data
|
|
130
|
+
* structure. `out` is the caller's reused scratch array, so this allocates
|
|
131
|
+
* nothing.
|
|
132
|
+
*/
|
|
133
|
+
function binLabelsInto(out, set, seriesId, ss, b) {
|
|
134
|
+
out.length = 0;
|
|
135
|
+
const stable = ss.marks?.[b];
|
|
136
|
+
const begin = ss.begin[b];
|
|
137
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
138
|
+
const m = set[i];
|
|
139
|
+
if (m.id !== seriesId)
|
|
140
|
+
continue;
|
|
141
|
+
if (stable !== undefined ? m.mark === stable : m.key === begin)
|
|
142
|
+
out.push(m.label);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Past this many entries a per-draw index beats {@link binLabelsInto}'s scan.
|
|
147
|
+
* The scan runs once per **bin** over the whole set, so it is O(W · |set|) —
|
|
148
|
+
* fine for the handful of cells a person clicks, and not fine for a rect
|
|
149
|
+
* preview, which lights its whole covered region through `hovered`. Same
|
|
150
|
+
* threshold and same reasoning as `bars.ts` / `scatter.ts`.
|
|
151
|
+
*/
|
|
152
|
+
const MARK_INDEX_THRESHOLD = 16;
|
|
153
|
+
function buildBinLabelIndex(set, seriesId) {
|
|
154
|
+
const byMark = new Map();
|
|
155
|
+
const byKey = new Map();
|
|
156
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
157
|
+
const m = set[i];
|
|
158
|
+
if (m.id !== seriesId)
|
|
159
|
+
continue;
|
|
160
|
+
if (m.mark !== undefined) {
|
|
161
|
+
const at = byMark.get(m.mark);
|
|
162
|
+
if (at)
|
|
163
|
+
at.push(m.label);
|
|
164
|
+
else
|
|
165
|
+
byMark.set(m.mark, [m.label]);
|
|
166
|
+
}
|
|
167
|
+
const at = byKey.get(m.key);
|
|
168
|
+
if (at)
|
|
169
|
+
at.push(m.label);
|
|
170
|
+
else
|
|
171
|
+
byKey.set(m.key, [m.label]);
|
|
172
|
+
}
|
|
173
|
+
return { byMark, byKey };
|
|
174
|
+
}
|
|
175
|
+
const NO_LABELS = [];
|
|
176
|
+
/** The labels named on bin `b`, through the index. */
|
|
177
|
+
function indexedBinLabels(ix, ss, b) {
|
|
178
|
+
const stable = ss.marks?.[b];
|
|
179
|
+
return ((stable !== undefined
|
|
180
|
+
? ix.byMark.get(stable)
|
|
181
|
+
: ix.byKey.get(ss.begin[b])) ?? NO_LABELS);
|
|
182
|
+
}
|
|
183
|
+
/** Is `label` one of the (usually zero or one) labels {@link binLabelsInto}
|
|
184
|
+
* gathered for this bin? Indexed rather than `includes` — this is the draw's
|
|
185
|
+
* inner loop. */
|
|
186
|
+
function hasLabel(labels, label) {
|
|
187
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
188
|
+
if (labels[i] === label)
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Does any member of `set` name this series at all? A cheap once-per-draw gate
|
|
195
|
+
* so a grid whose selection belongs to some *other* layer — the common case on a
|
|
196
|
+
* multi-layer row — never pays the per-cell scan, and a resting draw pays
|
|
197
|
+
* nothing beyond the two `length === 0` checks it always did.
|
|
198
|
+
*/
|
|
199
|
+
function namesSeries(set, seriesId) {
|
|
200
|
+
if (seriesId === undefined)
|
|
201
|
+
return false; // a no-id layer is never selectable
|
|
202
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
203
|
+
if (set[i].id === seriesId)
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Fill one cell with diagonal hatching — the no-data mark.
|
|
210
|
+
*
|
|
211
|
+
* Drawn as clipped strokes per cell rather than a `createPattern` fill, because
|
|
212
|
+
* a pattern needs a second canvas to build and this runs in headless contexts
|
|
213
|
+
* (tests, SSR) that have no `document`. The cost is a few strokes per hole, and
|
|
214
|
+
* holes are by definition the cells with nothing else to draw; a decimated grid
|
|
215
|
+
* skips them entirely, since an aggregated cell is not a hole.
|
|
216
|
+
*/
|
|
217
|
+
function hatchCell(ctx, x, y, w, h, color) {
|
|
218
|
+
if (!(w > 0) || !(h > 0))
|
|
219
|
+
return;
|
|
220
|
+
ctx.save();
|
|
221
|
+
ctx.beginPath();
|
|
222
|
+
ctx.rect(x, y, w, h);
|
|
223
|
+
ctx.clip();
|
|
224
|
+
ctx.strokeStyle = color;
|
|
225
|
+
ctx.lineWidth = 1;
|
|
226
|
+
ctx.beginPath();
|
|
227
|
+
// 45° lines every 4px. Sweeping from `-h` covers the corners the diagonal
|
|
228
|
+
// would otherwise leave bare.
|
|
229
|
+
for (let d = -h; d < w; d += 4) {
|
|
230
|
+
ctx.moveTo(x + d, y + h);
|
|
231
|
+
ctx.lineTo(x + d + h, y);
|
|
232
|
+
}
|
|
233
|
+
ctx.stroke();
|
|
234
|
+
ctx.restore();
|
|
235
|
+
}
|
|
236
|
+
/** The canvas' backing-buffer width over the x scale's CSS pixel width — the
|
|
237
|
+
* device pixel ratio, recovered rather than read from `window` so a headless
|
|
238
|
+
* context (no canvas, no range) degrades to `1` instead of throwing. */
|
|
239
|
+
function devicePixelRatioOf(ctx, xScale) {
|
|
240
|
+
const w = ctx.canvas?.width;
|
|
241
|
+
const r = xScale.range?.();
|
|
242
|
+
if (typeof w !== 'number' || w <= 0 || r === undefined || r.length < 2)
|
|
243
|
+
return 1;
|
|
244
|
+
const css = Math.abs(+r[r.length - 1] - +r[0]);
|
|
245
|
+
return css > 0 ? w / css : 1;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Fill one rectangle per cell, coloured by `colorAt(b, g)`. A gap is skipped.
|
|
249
|
+
*
|
|
250
|
+
* A live cell keeps its **own** colour. The colour is never swapped for a
|
|
251
|
+
* highlight, because that colour *is* the datum — replacing it would erase the
|
|
252
|
+
* reading the chart exists to give.
|
|
253
|
+
*
|
|
254
|
+
* That rules out the bar layers' usual affordance too. A bar says "live" by
|
|
255
|
+
* popping from `opacity` to 1, which on a heat map is both invisible (a ramp is
|
|
256
|
+
* normally drawn at full opacity already) and, where it isn't, actively
|
|
257
|
+
* misleading — dimming a cell shifts where the reader places it on the colour
|
|
258
|
+
* scale. So a live cell is marked by an **outline** instead: `outlineWidth` for
|
|
259
|
+
* hover, twice that for selection, both in `style.highlight`. The alpha pop is
|
|
260
|
+
* kept as well, so a theme that does draw cells translucent still behaves like
|
|
261
|
+
* its bars.
|
|
262
|
+
*
|
|
263
|
+
* Hover and selection share one colour deliberately — whether they should
|
|
264
|
+
* diverge is the open question in #577, and this layer should not pre-empt it.
|
|
265
|
+
*
|
|
266
|
+
* Both `selection` and `hovered` are **sets**: `ContainerFrame.selected` has
|
|
267
|
+
* been one since [PND-MULTISEL] and `hovered` since RFC A4.3, so **every** cell
|
|
268
|
+
* a member names lights — a pinned group of cells, or a drag-sweep hovering
|
|
269
|
+
* several at once, all read back rather than only the set's first member. A cell
|
|
270
|
+
* in **both** sets reads as selected (selected outranks hovered, the precedence
|
|
271
|
+
* `drawBars` / `drawStacks` / `drawBox` share) and takes one outline, never two.
|
|
272
|
+
*
|
|
273
|
+
* O(visible × G) after viewport culling on the bin axis, plus O(|set|) per
|
|
274
|
+
* visible **bin** (not per cell — see {@link binLabelsInto}) and only when a set
|
|
275
|
+
* names this layer at all, so a resting draw costs exactly what it did.
|
|
276
|
+
*/
|
|
277
|
+
export function drawHeat(ctx, ss, xScale, yScale, style, colorOf, seriesId, selection = NO_MARKS, hovered = NO_MARKS, decimate = true, orientation = 'vertical', noData = 'blank',
|
|
278
|
+
// Span descriptors covering this layer (interaction RFC A5.2), already
|
|
279
|
+
// narrowed to its `id` by the component (`spansForLayer`). A cell is
|
|
280
|
+
// selected when a mark entry names it OR a span contains it — the bin
|
|
281
|
+
// `begin` in the half-open `x` interval, its **row name** in `rows` when
|
|
282
|
+
// present (the ordinal second dimension, RFC A5.3 — never a numeric row
|
|
283
|
+
// interval, which a reorder would invalidate), and the cell value in `y`
|
|
284
|
+
// when present. The x half is narrowed once per *bin* (the same shape as
|
|
285
|
+
// `binLabelsInto`'s hoist), so the row loop tests only the spans that cover
|
|
286
|
+
// the column at all. Suppressed while decimated, like the mark match — an
|
|
287
|
+
// aggregated cell has no per-cell identity.
|
|
288
|
+
spans = NO_SPANS) {
|
|
289
|
+
const vertical = orientation === 'vertical';
|
|
290
|
+
const binScale = vertical ? xScale : yScale;
|
|
291
|
+
const groupScale = vertical ? yScale : xScale;
|
|
292
|
+
ctx.save();
|
|
293
|
+
ctx.globalAlpha = style.opacity;
|
|
294
|
+
const [srcStart, srcEnd] = visibleSpanRange(ss.begin, ss.end, ss.length, binScale);
|
|
295
|
+
// Both decimators work along whichever axis they reduce, so each needs that
|
|
296
|
+
// axis' extent in DEVICE pixels. The ratio is isotropic, so it is recovered
|
|
297
|
+
// once from x and applied to both.
|
|
298
|
+
const dpr = devicePixelRatioOf(ctx, xScale);
|
|
299
|
+
const spanCss = (s, a, b) => Math.abs(s(b) - s(a));
|
|
300
|
+
// Once the visible cells are denser than ~2 per device pixel they overlap and
|
|
301
|
+
// overpaint each other, so the picture is already a reduction — just a bad
|
|
302
|
+
// one, picked by loop order. Replace it with the mean per pixel column, which
|
|
303
|
+
// is what the overdrawn version resolves to at this size and costs O(W·G)
|
|
304
|
+
// rects instead of O(V·G). See `decimateHeat` for why a heat map can do this
|
|
305
|
+
// where a per-bar-coloured `<BarChart>` cannot.
|
|
306
|
+
const thinned = decimate === false
|
|
307
|
+
? null
|
|
308
|
+
: decimateHeat(ss, binScale, ctx, typeof decimate === 'object' ? (decimate.threshold ?? 2) : 2, srcStart, srcEnd, vertical
|
|
309
|
+
? undefined
|
|
310
|
+
: (() => {
|
|
311
|
+
const dom = [ss.begin[0] ?? 0, ss.end[ss.length - 1] ?? 0];
|
|
312
|
+
const css = spanCss(binScale, dom[0], dom[1]);
|
|
313
|
+
return {
|
|
314
|
+
deviceCount: Math.max(1, Math.round(css * dpr)),
|
|
315
|
+
spanCss: css,
|
|
316
|
+
};
|
|
317
|
+
})());
|
|
318
|
+
const grid = thinned ?? ss;
|
|
319
|
+
const srcRows = grid.groups.length;
|
|
320
|
+
const [vStart, vEnd] = thinned
|
|
321
|
+
? [0, thinned.length]
|
|
322
|
+
: [srcStart, srcEnd];
|
|
323
|
+
// The y half. Whichever axis is oversampled the argument is identical, and a
|
|
324
|
+
// gene matrix (10,000 rows x 8 samples) is oversampled on the axis the column
|
|
325
|
+
// decimator above cannot touch. `deviceRows` is the plot height in *device*
|
|
326
|
+
// pixels: the DPR is recovered from the x scale, since a canvas' backing width
|
|
327
|
+
// over its CSS width is the same ratio in both directions.
|
|
328
|
+
const k = typeof decimate === 'object' ? (decimate.threshold ?? 2) : 2;
|
|
329
|
+
const rowsThinned = decimate === false
|
|
330
|
+
? null
|
|
331
|
+
: decimateHeatRows(thinned ? grid.values : ss.values, thinned ? grid.length : ss.length, srcRows, Math.max(1, Math.floor(spanCss(groupScale, 0, srcRows) * dpr)), k);
|
|
332
|
+
const values = rowsThinned ? rowsThinned.values : grid.values;
|
|
333
|
+
const G = rowsThinned ? rowsThinned.rows : srcRows;
|
|
334
|
+
// Row `r` of a thinned grid covers source rows `[r·stride, (r+1)·stride]`, so
|
|
335
|
+
// its band is read off the UNCHANGED y scale — the coordinate space, and every
|
|
336
|
+
// axis tick in it, is untouched by the reduction.
|
|
337
|
+
const stride = rowsThinned ? rowsThinned.stride : 1;
|
|
338
|
+
// An aggregated column or row has no per-cell identity to match against, and a
|
|
339
|
+
// sub-pixel outline would not be visible anyway. Interaction still reads the
|
|
340
|
+
// source grid via `heatAt`.
|
|
341
|
+
const reduced = thinned !== null || rowsThinned !== null;
|
|
342
|
+
const sel = reduced ? NO_MARKS : selection;
|
|
343
|
+
const hov = reduced ? NO_MARKS : hovered;
|
|
344
|
+
const spanSet = reduced ? NO_SPANS : spans;
|
|
345
|
+
// Whether either set names *this* layer, settled once so the cell loop skips
|
|
346
|
+
// the per-cell scan entirely when neither does — which is every draw on a row
|
|
347
|
+
// whose selection belongs to a different layer, and every resting draw.
|
|
348
|
+
const anySelected = namesSeries(sel, seriesId);
|
|
349
|
+
const anyHovered = namesSeries(hov, seriesId);
|
|
350
|
+
// Scratch for the per-bin narrowing below: one array each, allocated **only**
|
|
351
|
+
// when a set actually names this layer and then reused across every bin — so
|
|
352
|
+
// the resting frame allocates nothing per draw, which is the property this
|
|
353
|
+
// path had when it matched a lone mark. `null` ⇒ that set is not in play.
|
|
354
|
+
const selLabels = anySelected ? [] : null;
|
|
355
|
+
const hovLabels = anyHovered ? [] : null;
|
|
356
|
+
// …and the index form for a big set, built once per draw — see
|
|
357
|
+
// `MARK_INDEX_THRESHOLD`. Only a sweep preview ever reaches it, so a
|
|
358
|
+
// clicked handful still pays nothing but the scan it always paid.
|
|
359
|
+
const selIx = anySelected && sel.length > MARK_INDEX_THRESHOLD
|
|
360
|
+
? buildBinLabelIndex(sel, seriesId)
|
|
361
|
+
: null;
|
|
362
|
+
const hovIx = anyHovered && hov.length > MARK_INDEX_THRESHOLD
|
|
363
|
+
? buildBinLabelIndex(hov, seriesId)
|
|
364
|
+
: null;
|
|
365
|
+
// Scratch for the per-bin span narrowing — the spans whose `x` contains the
|
|
366
|
+
// current bin, reused across bins so the resting frame (and any spanless
|
|
367
|
+
// frame) allocates nothing. `null` ⇒ spans are not in play at all.
|
|
368
|
+
const binSpans = spanSet.length > 0 ? [] : null;
|
|
369
|
+
// The row bands, once. Each depends only on `g`, so computing them inside the
|
|
370
|
+
// cell loop re-derived the same G boundaries for every visible bin — O(V·G)
|
|
371
|
+
// scale calls where O(G) does. Kept as two flat arrays rather than tuples so
|
|
372
|
+
// the loop allocates nothing per cell. (`cellRect` still does it per call: it
|
|
373
|
+
// is the hit-test's entry point, where there is exactly one cell and nothing
|
|
374
|
+
// to amortize over. The two paths diverge on purpose — see perf-heat.mjs.)
|
|
375
|
+
const bandLo = new Float64Array(G);
|
|
376
|
+
const bandHi = new Float64Array(G);
|
|
377
|
+
for (let g = 0; g < G; g += 1) {
|
|
378
|
+
const [lo, hi] = slotBandPx(groupScale, g * stride, Math.min((g + 1) * stride, srcRows), style.gap);
|
|
379
|
+
bandLo[g] = lo;
|
|
380
|
+
bandHi[g] = hi;
|
|
381
|
+
}
|
|
382
|
+
// ── The selected-cell grid, for the union perimeter ────────────────────
|
|
383
|
+
// A **committed** selection is one region however it was assembled: the
|
|
384
|
+
// outline merges. That is the other half of the live drag's sentence — the
|
|
385
|
+
// brush shows the snapped rect it is about to take, and only on release
|
|
386
|
+
// does that rect join what is already selected (`SweepSession.snap`).
|
|
387
|
+
//
|
|
388
|
+
// So: one outline around the union, drawn by suppressing each cell edge
|
|
389
|
+
// whose neighbour is also selected. No connectivity pass falls out of that
|
|
390
|
+
// — disconnected pieces get one outline each, and a hole in the middle of
|
|
391
|
+
// one gets its own, which is what keeps a demoted region readable instead
|
|
392
|
+
// of the "mostly border" grid a per-cell outline gives.
|
|
393
|
+
//
|
|
394
|
+
// The grid is padded one column either side of the window, because a
|
|
395
|
+
// neighbour test reaches exactly that far and an unpadded answer would draw
|
|
396
|
+
// a false edge wherever a selection runs off-screen.
|
|
397
|
+
const st = style.states;
|
|
398
|
+
const perimeter = st !== undefined && (anySelected || spanSet.length > 0);
|
|
399
|
+
const pStart = Math.max(0, vStart - 1);
|
|
400
|
+
const pEnd = Math.min(grid.length, vEnd + 1);
|
|
401
|
+
const selGrid = perimeter ? new Uint8Array((pEnd - pStart) * G) : null;
|
|
402
|
+
if (selGrid !== null) {
|
|
403
|
+
const scratch = [];
|
|
404
|
+
const covering = [];
|
|
405
|
+
for (let b = pStart; b < pEnd; b += 1) {
|
|
406
|
+
let labels = NO_LABELS;
|
|
407
|
+
if (selIx !== null)
|
|
408
|
+
labels = indexedBinLabels(selIx, ss, b);
|
|
409
|
+
else if (anySelected) {
|
|
410
|
+
binLabelsInto(scratch, sel, seriesId, ss, b);
|
|
411
|
+
labels = scratch;
|
|
412
|
+
}
|
|
413
|
+
covering.length = 0;
|
|
414
|
+
const begin = ss.begin[b];
|
|
415
|
+
for (let s = 0; s < spanSet.length; s += 1) {
|
|
416
|
+
const sp = spanSet[s];
|
|
417
|
+
if (begin >= sp.x[0] && begin < sp.x[1])
|
|
418
|
+
covering.push(sp);
|
|
419
|
+
}
|
|
420
|
+
if (labels.length === 0 && covering.length === 0)
|
|
421
|
+
continue;
|
|
422
|
+
const base = b * G;
|
|
423
|
+
const out = (b - pStart) * G;
|
|
424
|
+
for (let g = 0; g < G; g += 1) {
|
|
425
|
+
const value = values[base + g];
|
|
426
|
+
if (!Number.isFinite(value))
|
|
427
|
+
continue;
|
|
428
|
+
const group = ss.groups[g];
|
|
429
|
+
let hit = hasLabel(labels, group);
|
|
430
|
+
for (let s = 0; !hit && s < covering.length; s += 1) {
|
|
431
|
+
hit = spanContainsPoint(covering[s], begin, value, group);
|
|
432
|
+
}
|
|
433
|
+
if (hit)
|
|
434
|
+
selGrid[out + g] = 1;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Is cell `(b, g)` selected? Off-grid answers `false`; outside the
|
|
439
|
+
* precomputed window it is unknowable, which cannot happen because the
|
|
440
|
+
* window is padded by exactly the one column a neighbour test can reach. */
|
|
441
|
+
const isSel = (b, g) => selGrid !== null &&
|
|
442
|
+
b >= pStart &&
|
|
443
|
+
b < pEnd &&
|
|
444
|
+
g >= 0 &&
|
|
445
|
+
g < G &&
|
|
446
|
+
selGrid[(b - pStart) * G + g] === 1;
|
|
447
|
+
let lastFill;
|
|
448
|
+
for (let b = vStart; b < vEnd; b += 1) {
|
|
449
|
+
// The x span depends only on the BIN, so it is hoisted out of the row loop:
|
|
450
|
+
// a 45-row grid was paying two scale calls per cell for one answer per
|
|
451
|
+
// column.
|
|
452
|
+
const [spanLo, spanHi] = barSpanPx(grid.begin[b], grid.end[b], binScale, style.gap, style.minWidth);
|
|
453
|
+
// The selection / hover match narrowed to this bin, once per column rather
|
|
454
|
+
// than once per cell: what is left for the row loop is a label compare
|
|
455
|
+
// against the handful (usually none) that name this bin at all. `sel`/`hov`
|
|
456
|
+
// are empty whenever the grid is reduced, so `grid === ss` here.
|
|
457
|
+
let selNames = null;
|
|
458
|
+
let hovNames = null;
|
|
459
|
+
if (selIx !== null)
|
|
460
|
+
selNames = indexedBinLabels(selIx, ss, b);
|
|
461
|
+
else if (selLabels !== null) {
|
|
462
|
+
binLabelsInto(selLabels, sel, seriesId, ss, b);
|
|
463
|
+
selNames = selLabels;
|
|
464
|
+
}
|
|
465
|
+
if (hovIx !== null)
|
|
466
|
+
hovNames = indexedBinLabels(hovIx, ss, b);
|
|
467
|
+
else if (hovLabels !== null) {
|
|
468
|
+
binLabelsInto(hovLabels, hov, seriesId, ss, b);
|
|
469
|
+
hovNames = hovLabels;
|
|
470
|
+
}
|
|
471
|
+
// The span x half, once per bin: keep only the spans whose half-open `x`
|
|
472
|
+
// contains this bin's `begin` — the row loop then tests just their `rows` /
|
|
473
|
+
// `y` channels against the handful that survive. `spanSet` is empty
|
|
474
|
+
// whenever the grid is reduced, so `grid === ss` here.
|
|
475
|
+
if (binSpans !== null) {
|
|
476
|
+
binSpans.length = 0;
|
|
477
|
+
const begin = ss.begin[b];
|
|
478
|
+
for (let s = 0; s < spanSet.length; s += 1) {
|
|
479
|
+
const sp = spanSet[s];
|
|
480
|
+
if (begin >= sp.x[0] && begin < sp.x[1])
|
|
481
|
+
binSpans.push(sp);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const binIsLive = (selNames !== null && selNames.length > 0) ||
|
|
485
|
+
(hovNames !== null && hovNames.length > 0) ||
|
|
486
|
+
(binSpans !== null && binSpans.length > 0);
|
|
487
|
+
const base = b * G;
|
|
488
|
+
// This bin's row in the neighbour grid, or `-1` when there is none.
|
|
489
|
+
const selRow = selGrid !== null ? (b - pStart) * G : -1;
|
|
490
|
+
for (let g = 0; g < G; g += 1) {
|
|
491
|
+
// Gaps are skipped before any per-cell work, exactly as `cellRect` does
|
|
492
|
+
// by returning null: a hole in the record draws nothing and owns no hit
|
|
493
|
+
// region.
|
|
494
|
+
const value = values[base + g];
|
|
495
|
+
if (!Number.isFinite(value)) {
|
|
496
|
+
// A hole is not a low value, and on a pale ramp "draw nothing" reads as
|
|
497
|
+
// exactly that — the background shows through at the bottom of the
|
|
498
|
+
// scale. Where the distinction carries meaning (a state with no
|
|
499
|
+
// surveillance yet, against a record whose late years are real zeros)
|
|
500
|
+
// the cell must say so, and hatching is the convention because no ramp
|
|
501
|
+
// colour can be mistaken for it.
|
|
502
|
+
if (noData === 'hatch' && !reduced) {
|
|
503
|
+
hatchCell(ctx, vertical ? spanLo : bandLo[g], vertical ? bandLo[g] : spanLo, vertical ? spanHi - spanLo : bandHi[g] - bandLo[g], vertical ? bandHi[g] - bandLo[g] : spanHi - spanLo, style.gridColor);
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const fill = colorOf(value);
|
|
508
|
+
if (fill === undefined)
|
|
509
|
+
continue;
|
|
510
|
+
// The transpose, and the only place orientation reaches the geometry:
|
|
511
|
+
// which of the two spans is horizontal on the canvas.
|
|
512
|
+
const x0 = vertical ? spanLo : bandLo[g];
|
|
513
|
+
const x1 = vertical ? spanHi : bandHi[g];
|
|
514
|
+
const yTop = vertical ? bandLo[g] : spanLo;
|
|
515
|
+
const yBottom = vertical ? bandHi[g] : spanHi;
|
|
516
|
+
// **Every** named cell lights, not only the first of each set. Selection
|
|
517
|
+
// is tested first and wins outright, so a cell in both draws the selected
|
|
518
|
+
// weight once rather than stacking two strokes. `binIsLive` short-circuits
|
|
519
|
+
// the whole test for the columns nothing names, which is nearly all of
|
|
520
|
+
// them.
|
|
521
|
+
let selected = false;
|
|
522
|
+
let live = false;
|
|
523
|
+
if (binIsLive) {
|
|
524
|
+
const group = ss.groups[g];
|
|
525
|
+
if (selRow >= 0) {
|
|
526
|
+
// The neighbour grid already answered this, for every cell in the
|
|
527
|
+
// window — reading it back is the point of having built it. Redoing
|
|
528
|
+
// the label compare and the span test here would pay for the same
|
|
529
|
+
// answer twice on every selected frame.
|
|
530
|
+
selected = selGrid[selRow + g] === 1;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
selected = selNames !== null && hasLabel(selNames, group);
|
|
534
|
+
// The spans that cover this bin, against the cell's remaining
|
|
535
|
+
// channels — the row name (`rows`) and the value (`y`).
|
|
536
|
+
// `spanContainsPoint` is the single containment rule (its x re-test
|
|
537
|
+
// is two compares on an already-passing bin), so this cannot drift
|
|
538
|
+
// from `selectionContains`.
|
|
539
|
+
if (!selected && binSpans !== null && binSpans.length > 0) {
|
|
540
|
+
const begin = ss.begin[b];
|
|
541
|
+
for (let s = 0; s < binSpans.length; s += 1) {
|
|
542
|
+
if (spanContainsPoint(binSpans[s], begin, value, group)) {
|
|
543
|
+
selected = true;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
live = selected || (hovNames !== null && hasLabel(hovNames, group));
|
|
550
|
+
}
|
|
551
|
+
// Under `states` there is no alpha pop: a live cell is marked by chrome,
|
|
552
|
+
// so `opacity` stays what it is — the LAYER's base alpha — instead of
|
|
553
|
+
// quietly becoming a state and being overridden to 1.
|
|
554
|
+
ctx.globalAlpha = live && st === undefined ? 1 : style.opacity;
|
|
555
|
+
// Assigning `fillStyle` is not free — a real canvas parses the CSS colour
|
|
556
|
+
// on every set — and a banded ramp hands out long runs of the same string,
|
|
557
|
+
// so set it only when it actually changes.
|
|
558
|
+
if (fill !== lastFill) {
|
|
559
|
+
lastFill = fill;
|
|
560
|
+
ctx.fillStyle = fill;
|
|
561
|
+
}
|
|
562
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
563
|
+
if (st === undefined) {
|
|
564
|
+
if (live) {
|
|
565
|
+
// Inset by half the stroke so the outline sits inside the cell rather
|
|
566
|
+
// than straddling its edge and bleeding over the neighbour — which on a
|
|
567
|
+
// flush grid (`gap: 0`) would misreport the neighbour's colour.
|
|
568
|
+
const w = selected ? style.outlineWidth * 2 : style.outlineWidth;
|
|
569
|
+
const i = w / 2;
|
|
570
|
+
ctx.lineWidth = w;
|
|
571
|
+
ctx.strokeStyle = style.highlight;
|
|
572
|
+
ctx.strokeRect(x0 + i, yTop + i, x1 - x0 - w, yBottom - yTop - w);
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// ── The states path ────────────────────────────────────────────────
|
|
577
|
+
// Recede: a flat overlay composited over the cell, NOT an alpha — see
|
|
578
|
+
// `HeatStates.veil`. Only a committed selection recedes the field; a
|
|
579
|
+
// hovered cell keeps its value even while the rest is veiled.
|
|
580
|
+
if (perimeter && !live) {
|
|
581
|
+
lastFill = st.veil;
|
|
582
|
+
ctx.fillStyle = st.veil;
|
|
583
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
584
|
+
}
|
|
585
|
+
if (live && !selected) {
|
|
586
|
+
// The double ring, both inside the cell so both sit on its colour.
|
|
587
|
+
const w = st.ringWidth;
|
|
588
|
+
ctx.lineWidth = w;
|
|
589
|
+
for (let ring = 0; ring < 2; ring += 1) {
|
|
590
|
+
const i = w / 2 + ring * w;
|
|
591
|
+
ctx.strokeStyle = st.hoverRing[ring];
|
|
592
|
+
ctx.strokeRect(x0 + i, yTop + i, x1 - x0 - 2 * i, yBottom - yTop - 2 * i);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// **One outline around the union**, drawn as the edges this cell does
|
|
596
|
+
// not share with a selected neighbour. Summed over the region that is
|
|
597
|
+
// exactly its perimeter.
|
|
598
|
+
//
|
|
599
|
+
// Safe in the cell loop rather than deferred: every edge is inset
|
|
600
|
+
// INSIDE its own cell, so no neighbour's fill (or veil) drawn later can
|
|
601
|
+
// paint over it.
|
|
602
|
+
if (selected) {
|
|
603
|
+
const w = st.perimeterWidth;
|
|
604
|
+
const i = w / 2;
|
|
605
|
+
ctx.lineWidth = w;
|
|
606
|
+
ctx.strokeStyle = st.perimeter;
|
|
607
|
+
ctx.beginPath();
|
|
608
|
+
// Which cell sits on each side of this one **on screen**. The two
|
|
609
|
+
// orientations disagree about all four: transposing swaps the axes,
|
|
610
|
+
// and the y scale descends — so a higher ROW index is further up on a
|
|
611
|
+
// vertical grid, while a later BIN is further up on a horizontal one.
|
|
612
|
+
if (!(vertical ? isSel(b - 1, g) : isSel(b, g - 1))) {
|
|
613
|
+
ctx.moveTo(x0 + i, yTop);
|
|
614
|
+
ctx.lineTo(x0 + i, yBottom);
|
|
615
|
+
}
|
|
616
|
+
if (!(vertical ? isSel(b + 1, g) : isSel(b, g + 1))) {
|
|
617
|
+
ctx.moveTo(x1 - i, yTop);
|
|
618
|
+
ctx.lineTo(x1 - i, yBottom);
|
|
619
|
+
}
|
|
620
|
+
if (!(vertical ? isSel(b, g + 1) : isSel(b + 1, g))) {
|
|
621
|
+
ctx.moveTo(x0, yTop + i);
|
|
622
|
+
ctx.lineTo(x1, yTop + i);
|
|
623
|
+
}
|
|
624
|
+
if (!(vertical ? isSel(b, g - 1) : isSel(b - 1, g))) {
|
|
625
|
+
ctx.moveTo(x0, yBottom - i);
|
|
626
|
+
ctx.lineTo(x1, yBottom - i);
|
|
627
|
+
}
|
|
628
|
+
ctx.stroke();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
ctx.restore();
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Hit-test plot-pixel `(px, py)` against the grid — the first cell whose rect
|
|
636
|
+
* contains the point, or `null`. Returns `[bin, row, begin, rowName, value]`.
|
|
637
|
+
*
|
|
638
|
+
* The **value** is the whole point of the layer. A constant-height bar carries
|
|
639
|
+
* none, which is why the climate-stripes card looks its number up out-of-band;
|
|
640
|
+
* a cell answers directly, and so can the cursor.
|
|
641
|
+
*
|
|
642
|
+
* O(N × G), as `stackAt` is: bin and row counts are view-scale, clicks are rare.
|
|
643
|
+
*/
|
|
644
|
+
export function heatAt(ss, px, py, xScale, yScale, gapPx, minWidthPx, orientation = 'vertical') {
|
|
645
|
+
const G = ss.groups.length;
|
|
646
|
+
for (let b = 0; b < ss.length; b += 1) {
|
|
647
|
+
for (let g = 0; g < G; g += 1) {
|
|
648
|
+
const rect = cellRect(ss, b, g, xScale, yScale, gapPx, minWidthPx, orientation);
|
|
649
|
+
if (rect === null)
|
|
650
|
+
continue;
|
|
651
|
+
const [x0, x1, yTop, yBottom] = rect;
|
|
652
|
+
if (px >= x0 && px <= x1 && py >= yTop && py <= yBottom) {
|
|
653
|
+
return [b, g, ss.begin[b], ss.groups[g], ss.values[b * G + g]];
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=heat.js.map
|