@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.
Files changed (85) hide show
  1. package/API.md +576 -0
  2. package/CHANGELOG.md +1213 -1
  3. package/dist/AreaChart.d.ts +12 -1
  4. package/dist/AreaChart.js +131 -13
  5. package/dist/BarChart.d.ts +56 -7
  6. package/dist/BarChart.js +263 -39
  7. package/dist/BarList.d.ts +85 -5
  8. package/dist/BarList.js +25 -4
  9. package/dist/BoxList.d.ts +70 -3
  10. package/dist/BoxList.js +21 -7
  11. package/dist/BoxPlot.d.ts +2 -1
  12. package/dist/BoxPlot.js +101 -9
  13. package/dist/Candlestick.d.ts +13 -1
  14. package/dist/Candlestick.js +89 -3
  15. package/dist/ChartContainer.d.ts +36 -48
  16. package/dist/ChartContainer.js +465 -59
  17. package/dist/ChartRow.d.ts +9 -2
  18. package/dist/ChartRow.js +176 -14
  19. package/dist/HeatMap.d.ts +176 -0
  20. package/dist/HeatMap.js +344 -0
  21. package/dist/Layers.d.ts +5 -1
  22. package/dist/Layers.js +1014 -253
  23. package/dist/Legend.js +8 -4
  24. package/dist/LineChart.d.ts +18 -1
  25. package/dist/LineChart.js +165 -4
  26. package/dist/ListTable.d.ts +30 -3
  27. package/dist/ListTable.js +381 -23
  28. package/dist/ScatterChart.d.ts +3 -2
  29. package/dist/ScatterChart.js +68 -4
  30. package/dist/XAxis.js +40 -22
  31. package/dist/YAxis.d.ts +58 -2
  32. package/dist/YAxis.js +3 -1
  33. package/dist/area.d.ts +34 -1
  34. package/dist/area.js +88 -1
  35. package/dist/bars.d.ts +67 -6
  36. package/dist/bars.js +250 -35
  37. package/dist/box.d.ts +2 -2
  38. package/dist/box.js +158 -40
  39. package/dist/brush.d.ts +142 -0
  40. package/dist/brush.js +179 -0
  41. package/dist/child-index.d.ts +27 -0
  42. package/dist/child-index.js +57 -0
  43. package/dist/context.d.ts +870 -39
  44. package/dist/cursors.d.ts +161 -0
  45. package/dist/cursors.js +503 -0
  46. package/dist/data.d.ts +38 -0
  47. package/dist/data.js +43 -0
  48. package/dist/decimate.d.ts +78 -1
  49. package/dist/decimate.js +157 -0
  50. package/dist/format.d.ts +15 -0
  51. package/dist/format.js +16 -1
  52. package/dist/heat.d.ts +163 -0
  53. package/dist/heat.js +659 -0
  54. package/dist/index.d.ts +13 -4
  55. package/dist/index.js +27 -0
  56. package/dist/line.d.ts +137 -0
  57. package/dist/line.js +328 -0
  58. package/dist/ohlc.d.ts +16 -1
  59. package/dist/ohlc.js +93 -4
  60. package/dist/range.d.ts +14 -1
  61. package/dist/range.js +24 -3
  62. package/dist/scatter.d.ts +17 -9
  63. package/dist/scatter.js +221 -33
  64. package/dist/select.d.ts +13 -5
  65. package/dist/select.js +14 -6
  66. package/dist/selection-fixtures.d.ts +174 -0
  67. package/dist/selection-fixtures.js +569 -0
  68. package/dist/selection-stories.d.ts +73 -0
  69. package/dist/selection-stories.js +301 -0
  70. package/dist/selectors.d.ts +316 -0
  71. package/dist/selectors.js +391 -0
  72. package/dist/span.d.ts +122 -0
  73. package/dist/span.js +203 -0
  74. package/dist/sweep.d.ts +154 -0
  75. package/dist/sweep.js +282 -0
  76. package/dist/theme.d.ts +510 -5
  77. package/dist/theme.js +217 -41
  78. package/dist/tracker.d.ts +6 -0
  79. package/dist/tracker.js +6 -0
  80. package/dist/tradingAxis.fixture.d.ts +78 -0
  81. package/dist/tradingAxis.fixture.js +215 -0
  82. package/dist/useChartLegend.js +18 -3
  83. package/dist/yticks.d.ts +3 -0
  84. package/dist/yticks.js +104 -0
  85. 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