@pond-ts/charts 0.56.2 → 0.58.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/CHANGELOG.md +1218 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +295 -40
- 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 +79 -48
- package/dist/ChartContainer.js +482 -60
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +86 -12
- 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 +28 -1
- package/dist/YAxis.js +24 -2
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +178 -5
- package/dist/bars.js +504 -46
- 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 +871 -36
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +25 -2
- 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/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 +517 -11
- package/dist/theme.js +220 -39
- 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/package.json +3 -3
package/dist/bars.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
2
|
import { visibleSpanRange } from './culling.js';
|
|
3
3
|
import { decimateBars } from './decimate.js';
|
|
4
|
+
import { NO_SPANS, spanMatchesAny } from './span.js';
|
|
4
5
|
/**
|
|
5
6
|
* The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
|
|
6
7
|
* **widened to include `0`**, since a bar spans from its value to the baseline
|
|
@@ -81,6 +82,124 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
81
82
|
const yBase = yScale(baseline);
|
|
82
83
|
return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The value-space span `[lo, hi]` of **threshold band `k`** along a bar running
|
|
87
|
+
* from `base` to `v`, or `null` when the bar doesn't reach that band.
|
|
88
|
+
*
|
|
89
|
+
* A threshold ladder colours one bar **along its length** — neutral up to the
|
|
90
|
+
* first threshold, then warning, then alarm — so a long bar shows how far
|
|
91
|
+
* through the ladder it travelled rather than only which band it ended in. With
|
|
92
|
+
* `thresholds = [t0, t1]` there are three bands: `[0, t0)`, `[t0, t1)`,
|
|
93
|
+
* `[t1, ∞)`. Band `k` spans magnitudes `[thresholds[k-1] ?? 0, thresholds[k] ??
|
|
94
|
+
* ∞)`, each end clipped to the bar's own magnitude — so a bar that stops inside
|
|
95
|
+
* band 1 yields a truncated band 1 and `null` for band 2.
|
|
96
|
+
*
|
|
97
|
+
* **Breakpoints are absolute data values, not offsets from the baseline** — a
|
|
98
|
+
* `thresholds={[1, 2]}` ladder means "warning above 1, alarm above 2" in the
|
|
99
|
+
* axis's own units, which is what a threshold means everywhere else. They are
|
|
100
|
+
* matched on the **magnitude** and applied to whichever side of zero the bar
|
|
101
|
+
* is on, so a bar hanging below the baseline walks the same ladder downward
|
|
102
|
+
* and a ±3.5 diverging scale bands symmetrically without the caller supplying
|
|
103
|
+
* negative breakpoints. (An asymmetric ladder would need signed breakpoints;
|
|
104
|
+
* deferred until a consumer pulls — see [PND-BANDBAR2].)
|
|
105
|
+
*
|
|
106
|
+
* The painted span is then **clipped to what the bar actually draws**, which
|
|
107
|
+
* is what makes a domain that excludes zero behave: with `<YAxis min={10}>` a
|
|
108
|
+
* bar rests on 10, so a `[1, 2]` ladder leaves it entirely in the top band
|
|
109
|
+
* rather than banding at 11 and 12. Measuring the ladder from the *resolved
|
|
110
|
+
* baseline* instead would silently shift every breakpoint by the axis floor —
|
|
111
|
+
* exactly the class of quiet wrongness this feature exists to remove.
|
|
112
|
+
*
|
|
113
|
+
* Note this is **draw-only geometry**. Hit-testing still treats the bar as one
|
|
114
|
+
* target ({@link barSlotRect} / {@link barAt}), which is the whole reason this
|
|
115
|
+
* is a mark rather than the N-layer overpaint recipe it replaces: one bar keeps
|
|
116
|
+
* one hit region, one stable `mark`, and one legend row.
|
|
117
|
+
*
|
|
118
|
+
* `thresholds` is assumed ascending and finite — {@link normalizeThresholds}
|
|
119
|
+
* enforces that once at the prop boundary rather than per bar per frame.
|
|
120
|
+
*/
|
|
121
|
+
export function bandSpan(base, v, thresholds, k) {
|
|
122
|
+
return bandSpanInto(base, v, thresholds, k) ? [bandLo, bandHi] : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The band-`k` span, written to {@link bandLo} / {@link bandHi} instead of
|
|
126
|
+
* returned — `true` when the bar reaches this band, `false` when it doesn't.
|
|
127
|
+
*
|
|
128
|
+
* This is {@link bandSpan}'s implementation, split out because the tuple
|
|
129
|
+
* mattered: a K-band ladder allocates K tuples **per bar per frame**, and the
|
|
130
|
+
* bench showed that turning a banded draw from ~44% *cheaper* than the N-layer
|
|
131
|
+
* workaround it replaces into ~44% *dearer* than it. Same arithmetic, no
|
|
132
|
+
* garbage. `bandSpan` stays as the allocating wrapper so the geometry is
|
|
133
|
+
* testable as a value.
|
|
134
|
+
*
|
|
135
|
+
* Module-scope scratch is safe here: the draw path is single-threaded and reads
|
|
136
|
+
* both fields immediately after a `true`, before any other call can run.
|
|
137
|
+
*/
|
|
138
|
+
let bandLo = 0;
|
|
139
|
+
let bandHi = 0;
|
|
140
|
+
function bandSpanInto(base, v, thresholds, k) {
|
|
141
|
+
const lo = k === 0 ? 0 : thresholds[k - 1];
|
|
142
|
+
const hi = k < thresholds.length ? thresholds[k] : Infinity;
|
|
143
|
+
// The bar's drawn extent, ascending. `resolveBarBaseline` clamps 0 into the
|
|
144
|
+
// domain, so this span never straddles zero: either it starts at 0, or the
|
|
145
|
+
// whole domain sits to one side of it.
|
|
146
|
+
const sLo = base < v ? base : v;
|
|
147
|
+
const sHi = base < v ? v : base;
|
|
148
|
+
// Band `k` covers the *absolute* values `[lo, hi)`, which is two intervals —
|
|
149
|
+
// `[lo, hi]` and `[-hi, -lo]`. Pick the one on the bar's own side of zero.
|
|
150
|
+
const positive = sHi > 0;
|
|
151
|
+
let bLo = positive ? lo : -hi;
|
|
152
|
+
// `-lo` when `lo === 0` is **negative zero**, which would escape through the
|
|
153
|
+
// exported `bandSpan` and fail any consumer's `Object.is` / `toEqual` against
|
|
154
|
+
// a plain `0`. Normalize at the source rather than letting each caller cope.
|
|
155
|
+
let bHi = positive ? hi : lo === 0 ? 0 : -lo;
|
|
156
|
+
// Clip to what the bar actually draws. An empty or inverted result means this
|
|
157
|
+
// band lies outside the bar's span — either beyond its reach, or (on a domain
|
|
158
|
+
// that excludes zero) entirely below its floor.
|
|
159
|
+
if (bLo < sLo)
|
|
160
|
+
bLo = sLo;
|
|
161
|
+
if (bHi > sHi)
|
|
162
|
+
bHi = sHi;
|
|
163
|
+
if (bHi <= bLo)
|
|
164
|
+
return false;
|
|
165
|
+
bandLo = bLo;
|
|
166
|
+
bandHi = bHi;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Validate + freeze a caller's threshold ladder once, at the prop boundary.
|
|
171
|
+
* Returns the ascending, strictly-positive, finite breakpoints — or `null` when
|
|
172
|
+
* there is no usable ladder left, so the caller keeps the flat path.
|
|
173
|
+
*
|
|
174
|
+
* Sorting rather than rejecting an out-of-order ladder is deliberate — the
|
|
175
|
+
* bands are defined by their boundaries, so `[2, 1]` and `[1, 2]` describe the
|
|
176
|
+
* same three bands and there is no second reading to guess at.
|
|
177
|
+
*
|
|
178
|
+
* Three kinds of entry are **dropped**:
|
|
179
|
+
*
|
|
180
|
+
* - **non-finite** — would swallow every band above it;
|
|
181
|
+
* - **negative** — the ladder is walked on the *magnitude* and mirrored onto
|
|
182
|
+
* whichever side of zero the bar is on, so a negative breakpoint has no
|
|
183
|
+
* meaning. Left in, `[-2, -1]` silently clipped every lower band away and
|
|
184
|
+
* painted the whole bar in the final colour — a one-colour bar that looks
|
|
185
|
+
* deliberate (Codex adversarial review). Signed breakpoints are the
|
|
186
|
+
* asymmetric-ladder feature deferred in [PND-BANDBAR2], not this;
|
|
187
|
+
* - **zero** — band 0 already starts at zero, so a `0` breakpoint describes an
|
|
188
|
+
* empty band and shifts every colour by one.
|
|
189
|
+
*
|
|
190
|
+
* Dropping rather than throwing matches how the rest of this prop behaves
|
|
191
|
+
* (a short colour ladder degrades, it doesn't fail), and `BarChart` dev-warns
|
|
192
|
+
* whenever normalization removed anything — a silently-ignored breakpoint is
|
|
193
|
+
* the failure mode this whole feature exists to avoid.
|
|
194
|
+
*/
|
|
195
|
+
export function normalizeThresholds(thresholds) {
|
|
196
|
+
if (thresholds === undefined || thresholds.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
const clean = thresholds.filter((t) => Number.isFinite(t) && t > 0);
|
|
199
|
+
if (clean.length === 0)
|
|
200
|
+
return null;
|
|
201
|
+
return clean.sort((a, b) => a - b);
|
|
202
|
+
}
|
|
84
203
|
/**
|
|
85
204
|
* Does `m` identify the bar with stable identity `stable` and key `begin`?
|
|
86
205
|
* The mark decides **only when both sides have one** — `m.mark` (the selection
|
|
@@ -107,6 +226,78 @@ function barMatches(m, seriesId, stable, begin) {
|
|
|
107
226
|
? m.mark === stable
|
|
108
227
|
: m.key === begin;
|
|
109
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Does **any** member of the selection set identify this bar? The set form of
|
|
231
|
+
* {@link barMatches} ([PND-MULTISEL]).
|
|
232
|
+
*
|
|
233
|
+
* Linear over the set on purpose: a selection is a handful of marks a person
|
|
234
|
+
* clicked, not a data structure — building a Set per draw would cost more than
|
|
235
|
+
* it saves at these sizes, and the common cases are 0 or 1 members, which this
|
|
236
|
+
* short-circuits on.
|
|
237
|
+
*
|
|
238
|
+
* **The `<MultiSelector>` sweep broke that assumption for large sets** (RFC
|
|
239
|
+
* §8): a live preview can put every visible bar in `hovered`, and a
|
|
240
|
+
* demoted-on-edit selection can hold as many mark entries — V visible bars ×
|
|
241
|
+
* C entries is quadratic-shaped (measured: 2.6k×2.6k ≈ 4.5 ms/frame, 10k×10k
|
|
242
|
+
* ≈ 64 ms). Past {@link MARK_INDEX_THRESHOLD} the draw builds
|
|
243
|
+
* {@link buildBarMarkIndex} once — the *same* match, O(1) per bar — and small
|
|
244
|
+
* sets keep this scan (cheaper than a Set at click sizes).
|
|
245
|
+
*/
|
|
246
|
+
function barMatchesAny(sel, seriesId, stable, begin) {
|
|
247
|
+
for (let i = 0; i < sel.length; i += 1) {
|
|
248
|
+
if (barMatches(sel[i], seriesId, stable, begin))
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
/** Past this many entries, a per-draw set index beats the linear scan. Small
|
|
254
|
+
* enough that a sweep preview always indexes; big enough that a clicked
|
|
255
|
+
* handful never pays a Set build. */
|
|
256
|
+
const MARK_INDEX_THRESHOLD = 16;
|
|
257
|
+
/**
|
|
258
|
+
* Stroke a selected mark's outline **inside** its ink rect. Canvas strokes
|
|
259
|
+
* centre on the path, so a plain `strokeRect(x0, yTop, …)` paints
|
|
260
|
+
* `lineWidth / 2` *outside* the rect — which, with the default
|
|
261
|
+
* `outlineWidth: 1.5` against the default `gap: 1`, is enough to bridge the
|
|
262
|
+
* whole inter-bar gap from both sides: a swept run of selected bars fused
|
|
263
|
+
* into one unreadable block (you could not count them). Insetting the path by
|
|
264
|
+
* half the line width keeps every stroked pixel within the bar's own ink, so
|
|
265
|
+
* adjacent selected marks stay separated by exactly the gap the resting bars
|
|
266
|
+
* show. A mark too thin to contain its outline (either dimension
|
|
267
|
+
* `<= lineWidth`) skips the stroke — the highlight fill is already the
|
|
268
|
+
* signal, and an outline wider than the bar would only smear into the
|
|
269
|
+
* neighbours this exists to keep distinct.
|
|
270
|
+
*/
|
|
271
|
+
export function strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, lineWidth) {
|
|
272
|
+
const w = x1 - x0;
|
|
273
|
+
const h = yBottom - yTop;
|
|
274
|
+
if (w <= lineWidth || h <= lineWidth)
|
|
275
|
+
return;
|
|
276
|
+
const inset = lineWidth / 2;
|
|
277
|
+
ctx.lineWidth = lineWidth;
|
|
278
|
+
ctx.strokeRect(x0 + inset, yTop + inset, w - lineWidth, h - lineWidth);
|
|
279
|
+
}
|
|
280
|
+
function buildBarMarkIndex(sel, seriesId) {
|
|
281
|
+
const marks = new Set();
|
|
282
|
+
const keysMarkless = new Set();
|
|
283
|
+
const keysAll = new Set();
|
|
284
|
+
for (let i = 0; i < sel.length; i += 1) {
|
|
285
|
+
const m = sel[i];
|
|
286
|
+
if (m.id !== seriesId)
|
|
287
|
+
continue;
|
|
288
|
+
keysAll.add(m.key);
|
|
289
|
+
if (m.mark !== undefined)
|
|
290
|
+
marks.add(m.mark);
|
|
291
|
+
else
|
|
292
|
+
keysMarkless.add(m.key);
|
|
293
|
+
}
|
|
294
|
+
return { marks, keysMarkless, keysAll };
|
|
295
|
+
}
|
|
296
|
+
function indexMatches(ix, stable, begin) {
|
|
297
|
+
return stable !== undefined
|
|
298
|
+
? ix.marks.has(stable) || ix.keysMarkless.has(begin)
|
|
299
|
+
: ix.keysAll.has(begin);
|
|
300
|
+
}
|
|
110
301
|
/**
|
|
111
302
|
* Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
|
|
112
303
|
* (inset by `gapPx`) from the resolved `baseline` to the value.
|
|
@@ -159,7 +350,16 @@ function barMatches(m, seriesId, stable, begin) {
|
|
|
159
350
|
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
160
351
|
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
161
352
|
*/
|
|
162
|
-
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills
|
|
353
|
+
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills, banding,
|
|
354
|
+
// Span descriptors covering this layer (interaction RFC A5.2), already
|
|
355
|
+
// narrowed to its `id` (and constant-label `rows` resolved) by the component
|
|
356
|
+
// — see `spansForLayer`. A bar is selected when a mark entry names it OR a
|
|
357
|
+
// span covers it: an O(1)-per-bar half-open test of `begin` against the
|
|
358
|
+
// span's `x` (and the bar's value against `y` when present), so a span over
|
|
359
|
+
// ten thousand bars costs each one interval test, not a scan. Like the mark
|
|
360
|
+
// highlight, spans are ignored on the decimated envelope path (aggregate
|
|
361
|
+
// columns have no per-bar identity).
|
|
362
|
+
spans = NO_SPANS) {
|
|
163
363
|
ctx.save();
|
|
164
364
|
ctx.globalAlpha = style.opacity;
|
|
165
365
|
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
@@ -168,6 +368,12 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
168
368
|
// the `begin[i]` selection/hover match stays correct; full range when `xScale`
|
|
169
369
|
// has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
|
|
170
370
|
// (its highlight would be off-screen anyway).
|
|
371
|
+
// Dim only when a selection actually exists AND the theme opted in: with an
|
|
372
|
+
// empty set there is nothing to recede from, and with no `dimmed` value the
|
|
373
|
+
// library never invents one (RFC A2.3). A span is a selection too — a swept
|
|
374
|
+
// range recedes the bars outside it exactly as a clicked set does.
|
|
375
|
+
const hasSpans = spans.length > 0;
|
|
376
|
+
const dimming = style.dimmed !== undefined && (selection.length > 0 || hasSpans);
|
|
171
377
|
const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
|
|
172
378
|
// Decimate the visible bars to per-column envelope rects once dense (see the
|
|
173
379
|
// header). `null` below the visible-density threshold ⇒ the full per-bar loop.
|
|
@@ -177,8 +383,14 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
177
383
|
// *empty* colour array is "no colours" (every bar would flat-fill anyway), so
|
|
178
384
|
// it stays on the legacy path end-to-end (L2 review, PR #542).
|
|
179
385
|
const fills = binFills !== undefined && binFills.length > 0 ? binFills : undefined;
|
|
386
|
+
// A threshold ladder is per-*bar* colour too, so it takes the same exits as
|
|
387
|
+
// `binFills`: no envelope pass (one rect can't carry a gradient), and it
|
|
388
|
+
// yields to an explicit `binFills` when a caller sets both (warned about at
|
|
389
|
+
// the prop boundary — the two are different answers to "what colour is this
|
|
390
|
+
// bar", and per-bar is the more specific one).
|
|
391
|
+
const ladder = fills === undefined ? banding : undefined;
|
|
180
392
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
181
|
-
const envelope = decimate !== false && fills === undefined
|
|
393
|
+
const envelope = decimate !== false && fills === undefined && ladder === undefined
|
|
182
394
|
? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
|
|
183
395
|
: null;
|
|
184
396
|
if (envelope !== null) {
|
|
@@ -204,9 +416,18 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
204
416
|
// may already have: an interactive layer echoes the hovered bar's mark on
|
|
205
417
|
// every pointer move. See BarSeries.marks — this hoist keeps the draw path
|
|
206
418
|
// clean, it doesn't make the channel free.)
|
|
207
|
-
const marks = selection
|
|
419
|
+
const marks = selection.some((m) => m.mark !== undefined) ||
|
|
420
|
+
hovered.some((m) => m.mark !== undefined)
|
|
208
421
|
? cs.marks
|
|
209
422
|
: undefined;
|
|
423
|
+
// Large sets (a sweep preview in `hovered`, a demoted sweep in `selection`)
|
|
424
|
+
// switch to the O(1)-per-bar set index; a clicked handful keeps the scan.
|
|
425
|
+
const selIndex = selection.length > MARK_INDEX_THRESHOLD
|
|
426
|
+
? buildBarMarkIndex(selection, seriesId)
|
|
427
|
+
: null;
|
|
428
|
+
const hovIndex = hovered.length > MARK_INDEX_THRESHOLD
|
|
429
|
+
? buildBarMarkIndex(hovered, seriesId)
|
|
430
|
+
: null;
|
|
210
431
|
let drawn = 0;
|
|
211
432
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
212
433
|
const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
@@ -221,21 +442,68 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
221
442
|
// sets one and `highlight` otherwise, always without the outline — so hover
|
|
222
443
|
// reads as a lighter "this bar is live" and select as the committed pick.
|
|
223
444
|
const stable = marks?.[i];
|
|
224
|
-
const selected =
|
|
225
|
-
|
|
445
|
+
const selected = (selIndex !== null
|
|
446
|
+
? indexMatches(selIndex, stable, cs.begin[i])
|
|
447
|
+
: barMatchesAny(selection, seriesId, stable, cs.begin[i])) ||
|
|
448
|
+
(hasSpans && spanMatchesAny(spans, cs.begin[i], cs.y[i]));
|
|
449
|
+
const isHovered = hovIndex !== null
|
|
450
|
+
? indexMatches(hovIndex, stable, cs.begin[i])
|
|
451
|
+
: barMatchesAny(hovered, seriesId, stable, cs.begin[i]);
|
|
226
452
|
if (fills !== undefined) {
|
|
227
453
|
// Per-bar fills: the bar keeps its own colour under hover / selection —
|
|
228
454
|
// highlight pops the alpha to 1 and outlines the selection in the bar's
|
|
229
455
|
// own fill (the drawStacks binFills convention; see the header).
|
|
230
|
-
const fill =
|
|
231
|
-
|
|
456
|
+
const fill = dimming && !selected && !isHovered
|
|
457
|
+
? style.dimmed
|
|
458
|
+
: (fills[i] ?? style.fill);
|
|
459
|
+
ctx.globalAlpha =
|
|
460
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
232
461
|
ctx.fillStyle = fill;
|
|
233
462
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
234
463
|
drawn += 1;
|
|
235
464
|
if (selected) {
|
|
236
|
-
ctx.
|
|
237
|
-
ctx
|
|
238
|
-
|
|
465
|
+
ctx.strokeStyle = style.selectedOutline ?? fill;
|
|
466
|
+
strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
|
|
467
|
+
}
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (ladder !== undefined) {
|
|
471
|
+
// Threshold banding: one rect per band the bar reaches, sharing the bar's
|
|
472
|
+
// x-span and slicing its length at the ladder boundaries. Like `binFills`
|
|
473
|
+
// the bar keeps its own colours when live (swapping to one `highlight`
|
|
474
|
+
// would erase the very thing the bands encode) and pops the alpha
|
|
475
|
+
// instead. The whole bar stays one hit target — see `bandSpan`.
|
|
476
|
+
ctx.globalAlpha =
|
|
477
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
478
|
+
const v = cs.y[i];
|
|
479
|
+
if (dimming && !selected && !isHovered) {
|
|
480
|
+
// A dimmed banded bar draws flat: the ladder encodes *where the value
|
|
481
|
+
// sits*, which is exactly what a receded bar should stop competing
|
|
482
|
+
// over. Painting a dim gradient just lowers its voice.
|
|
483
|
+
ctx.fillStyle = style.dimmed;
|
|
484
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
485
|
+
drawn += 1;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
let topFill = ladder.colors[0];
|
|
489
|
+
for (let bk = 0; bk < ladder.colors.length; bk += 1) {
|
|
490
|
+
if (!bandSpanInto(baseline, v, ladder.thresholds, bk))
|
|
491
|
+
continue;
|
|
492
|
+
const ySpanA = yScale(bandLo);
|
|
493
|
+
const ySpanB = yScale(bandHi);
|
|
494
|
+
const bandTop = ySpanA < ySpanB ? ySpanA : ySpanB;
|
|
495
|
+
const bandBottom = ySpanA < ySpanB ? ySpanB : ySpanA;
|
|
496
|
+
ctx.fillStyle = ladder.colors[bk];
|
|
497
|
+
ctx.fillRect(x0, bandTop, x1 - x0, bandBottom - bandTop);
|
|
498
|
+
topFill = ladder.colors[bk]; // the band the value actually landed in
|
|
499
|
+
}
|
|
500
|
+
drawn += 1;
|
|
501
|
+
if (selected) {
|
|
502
|
+
// Outline the whole bar (not the last band) in the colour of the band
|
|
503
|
+
// the value reached — the one colour that means something for a bar
|
|
504
|
+
// painted in several.
|
|
505
|
+
ctx.strokeStyle = style.selectedOutline ?? topFill;
|
|
506
|
+
strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
|
|
239
507
|
}
|
|
240
508
|
continue;
|
|
241
509
|
}
|
|
@@ -244,16 +512,23 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
244
512
|
// at the resting `style.opacity`, so on an alpha'd theme a hovered bar
|
|
245
513
|
// (which has no outline) barely changed at all, and a selected one read
|
|
246
514
|
// only by its outline (#576).
|
|
247
|
-
ctx.globalAlpha =
|
|
515
|
+
ctx.globalAlpha =
|
|
516
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
248
517
|
// Three-step emphasis when the theme opts in with `hover`: rest → hover →
|
|
249
518
|
// selected. Selection outranks hover on a bar that is both (as the outline
|
|
250
519
|
// already did). With no `hover` colour this is the shipped two-step —
|
|
251
520
|
// `highlight` for either state (see BarStyle.hover).
|
|
521
|
+
// Precedence, shared with `drawStacks`: selected > hovered > dimmed > rest.
|
|
522
|
+
// Hover outranks dim deliberately — dim says "this is not in your
|
|
523
|
+
// selection", hover says "your pointer is here now", and suppressing live
|
|
524
|
+
// pointer feedback to keep a bar receded makes the chart feel broken.
|
|
252
525
|
ctx.fillStyle = selected
|
|
253
526
|
? style.highlight
|
|
254
527
|
: isHovered
|
|
255
528
|
? (style.hover ?? style.highlight)
|
|
256
|
-
:
|
|
529
|
+
: dimming
|
|
530
|
+
? style.dimmed
|
|
531
|
+
: style.fill;
|
|
257
532
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
258
533
|
drawn += 1;
|
|
259
534
|
if (selected) {
|
|
@@ -263,9 +538,8 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
263
538
|
// fill, so only the half-stroke falling outside the rect reads. A theme
|
|
264
539
|
// that needs the two states clearly apart sets `BarStyle.hover` (#577);
|
|
265
540
|
// the outline is the shape cue, not the whole signal.
|
|
266
|
-
ctx.
|
|
267
|
-
ctx
|
|
268
|
-
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
541
|
+
ctx.strokeStyle = style.selectedOutline ?? style.highlight;
|
|
542
|
+
strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
|
|
269
543
|
}
|
|
270
544
|
}
|
|
271
545
|
ctx.restore();
|
|
@@ -384,23 +658,53 @@ export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
|
|
|
384
658
|
}
|
|
385
659
|
return null;
|
|
386
660
|
}
|
|
661
|
+
function buildStackMarkIndex(sel, seriesId) {
|
|
662
|
+
const marks = new Set();
|
|
663
|
+
const labelsByKey = new Map();
|
|
664
|
+
for (let i = 0; i < sel.length; i += 1) {
|
|
665
|
+
const m = sel[i];
|
|
666
|
+
if (m.id !== seriesId)
|
|
667
|
+
continue;
|
|
668
|
+
if (m.mark !== undefined)
|
|
669
|
+
marks.add(m.mark);
|
|
670
|
+
let labels = labelsByKey.get(m.key);
|
|
671
|
+
if (labels === undefined) {
|
|
672
|
+
labels = new Set();
|
|
673
|
+
labelsByKey.set(m.key, labels);
|
|
674
|
+
}
|
|
675
|
+
labels.add(m.label);
|
|
676
|
+
}
|
|
677
|
+
return { marks, labelsByKey };
|
|
678
|
+
}
|
|
679
|
+
function stackIndexMatches(ix, stableMark, begin, group) {
|
|
680
|
+
return stableMark !== undefined
|
|
681
|
+
? ix.marks.has(stableMark)
|
|
682
|
+
: (ix.labelsByKey.get(begin)?.has(group) ?? false);
|
|
683
|
+
}
|
|
387
684
|
/**
|
|
388
685
|
* The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
|
|
389
|
-
* stack it is `[
|
|
390
|
-
*
|
|
686
|
+
* stack it is `[minNegTotal, maxPosTotal]` — each bin's positive segments summed
|
|
687
|
+
* upward and its negative segments summed downward, tracked separately
|
|
688
|
+
* ([PND-SIGNSTACK]). For a **single-group** series (`G === 1` — the plain /
|
|
391
689
|
* categorical bar case) it spans the values' own `[min, max]`, so a **negative**
|
|
392
690
|
* bar's floor is in the domain (segments below the baseline stay visible). `0` is
|
|
393
691
|
* always pulled in so the bars rest on a visible baseline (the bar analog of
|
|
394
692
|
* {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
|
|
395
693
|
* has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
|
|
396
694
|
* auto-fit for a horizontal one.
|
|
695
|
+
*
|
|
696
|
+
* The negative half is new: this used to sum only positives, matching a draw
|
|
697
|
+
* path that dropped negative segments outright. Both halves changed together —
|
|
698
|
+
* an extent that stopped at `0` below would clip the very segments the draw
|
|
699
|
+
* path now emits.
|
|
397
700
|
*/
|
|
398
701
|
export function stackValueExtent(ss) {
|
|
399
702
|
const G = ss.groups.length;
|
|
400
703
|
let max = 0;
|
|
401
704
|
let min = 0;
|
|
402
705
|
for (let b = 0; b < ss.length; b += 1) {
|
|
403
|
-
let
|
|
706
|
+
let cumPos = 0;
|
|
707
|
+
let cumNeg = 0;
|
|
404
708
|
for (let g = 0; g < G; g += 1) {
|
|
405
709
|
const v = ss.values[b * G + g];
|
|
406
710
|
if (!Number.isFinite(v))
|
|
@@ -413,11 +717,16 @@ export function stackValueExtent(ss) {
|
|
|
413
717
|
min = v;
|
|
414
718
|
}
|
|
415
719
|
else if (v > 0) {
|
|
416
|
-
|
|
720
|
+
cumPos += v; // True stack: positives stack up from the baseline…
|
|
721
|
+
}
|
|
722
|
+
else if (v < 0) {
|
|
723
|
+
cumNeg += v; // …negatives stack down from it.
|
|
417
724
|
}
|
|
418
725
|
}
|
|
419
|
-
if (
|
|
420
|
-
max =
|
|
726
|
+
if (cumPos > max)
|
|
727
|
+
max = cumPos;
|
|
728
|
+
if (cumNeg < min)
|
|
729
|
+
min = cumNeg;
|
|
421
730
|
}
|
|
422
731
|
// Empty / all-gap / all-zero → a usable unit domain; otherwise the real extent
|
|
423
732
|
// (with 0 pulled in via the `min`/`max` seeds above).
|
|
@@ -474,13 +783,22 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
|
|
|
474
783
|
const G = ss.groups.length;
|
|
475
784
|
const v = ss.values[b * G + g];
|
|
476
785
|
// Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
|
|
477
|
-
// hit-tested).
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
786
|
+
// hit-tested). **Negative segments are kept, whatever `G` is** — the caller
|
|
787
|
+
// passes the downward running total for them (see {@link drawStacks}), and
|
|
788
|
+
// the `Math.min/Math.max` below normalizes the below-baseline rect.
|
|
789
|
+
//
|
|
790
|
+
// A multi-group stack used to drop them here (`v < 0 && G > 1`) on the
|
|
791
|
+
// grounds that "stacking a negative segment is undefined". That is fair for a
|
|
792
|
+
// conventional stack and wrong for the **signed stacked histogram** — several
|
|
793
|
+
// series per bin whose values may be either sign, positives stacking up from
|
|
794
|
+
// a zero line and negatives down from it (net flow by category,
|
|
795
|
+
// inflow/outflow, buy/sell pressure by venue). That is a well-defined stack:
|
|
796
|
+
// two running totals per bin instead of one. And the old behaviour failed
|
|
797
|
+
// *silently* — the dropped segments didn't clamp, warn or throw, so every
|
|
798
|
+
// remaining segment stacked up as though they had never been in the data and
|
|
799
|
+
// a mixed-sign series rendered as a confident, wrong, all-positive chart
|
|
800
|
+
// ([PND-SIGNSTACK]).
|
|
801
|
+
if (!Number.isFinite(v) || v === 0)
|
|
484
802
|
return null;
|
|
485
803
|
if (orientation === 'vertical') {
|
|
486
804
|
const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
|
|
@@ -507,18 +825,53 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
|
|
|
507
825
|
*
|
|
508
826
|
* O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
|
|
509
827
|
*/
|
|
510
|
-
export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover
|
|
828
|
+
export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover, banding,
|
|
829
|
+
// Span descriptors covering this layer (interaction RFC A5.2), already
|
|
830
|
+
// narrowed to its `id` by the component (`spansForLayer`). A segment is
|
|
831
|
+
// selected when a mark entry names it OR a span covers it — the O(1) test of
|
|
832
|
+
// the bin `begin` against the half-open `x` interval, the segment's own
|
|
833
|
+
// value against `y` when present, and its group against `rows` when present
|
|
834
|
+
// (the group is this layer's label channel, so `rows` stays testable per
|
|
835
|
+
// segment where `drawBars`' constant label lets the component resolve it).
|
|
836
|
+
spans = NO_SPANS) {
|
|
511
837
|
const G = ss.groups.length;
|
|
512
838
|
const base = stackBase(orientation, xScale, yScale);
|
|
839
|
+
// Threshold banding applies to a **plain** bar only. `G === 1` is exactly the
|
|
840
|
+
// categorical / horizontal single-value bar (`categoryStack` builds a
|
|
841
|
+
// one-group series); a genuine multi-group stack has no defined banding —
|
|
842
|
+
// each segment is already a slice of a total — so the ladder is dropped here
|
|
843
|
+
// and warned about at the prop boundary rather than half-applied.
|
|
844
|
+
const ladder = G === 1 && style.binFills === undefined ? banding : undefined;
|
|
845
|
+
// See `drawBars` — dim only with a real selection (marks or spans) and an
|
|
846
|
+
// opted-in theme.
|
|
847
|
+
const hasSpans = spans.length > 0;
|
|
848
|
+
const dimming = (style.dimmed !== undefined || style.dimmedFills !== undefined) &&
|
|
849
|
+
(selection.length > 0 || hasSpans);
|
|
850
|
+
// The large-set switch (see barMatchesAny): a sweep preview / demoted sweep
|
|
851
|
+
// indexes once, a clicked handful keeps the linear scan.
|
|
852
|
+
const selIndex = selection.length > MARK_INDEX_THRESHOLD
|
|
853
|
+
? buildStackMarkIndex(selection, seriesId)
|
|
854
|
+
: null;
|
|
855
|
+
const hovIndex = hover.length > MARK_INDEX_THRESHOLD
|
|
856
|
+
? buildStackMarkIndex(hover, seriesId)
|
|
857
|
+
: null;
|
|
513
858
|
ctx.save();
|
|
514
859
|
ctx.globalAlpha = style.opacity;
|
|
515
860
|
for (let b = 0; b < ss.length; b += 1) {
|
|
516
|
-
|
|
861
|
+
// Two running totals per bin ([PND-SIGNSTACK]): positives stack upward from
|
|
862
|
+
// the baseline, negatives downward from it. A conventional all-positive
|
|
863
|
+
// stack never touches `cumNeg`, so its geometry is bit-identical to before.
|
|
864
|
+
let cumPos = base;
|
|
865
|
+
let cumNeg = base;
|
|
517
866
|
for (let g = 0; g < G; g += 1) {
|
|
518
|
-
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
519
867
|
const v = ss.values[b * G + g];
|
|
520
|
-
|
|
521
|
-
|
|
868
|
+
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
|
|
869
|
+
if (Number.isFinite(v)) {
|
|
870
|
+
if (v > 0)
|
|
871
|
+
cumPos += v;
|
|
872
|
+
else if (v < 0)
|
|
873
|
+
cumNeg += v;
|
|
874
|
+
}
|
|
522
875
|
if (rect === null)
|
|
523
876
|
continue;
|
|
524
877
|
const [x0, x1, yTop, yBottom] = rect;
|
|
@@ -531,19 +884,117 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
|
|
|
531
884
|
(stableMark !== undefined
|
|
532
885
|
? m.mark === stableMark
|
|
533
886
|
: m.key === ss.begin[b] && m.label === ss.groups[g]);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
887
|
+
// Indexed rather than `.some(cb)` — this is the inner loop of the draw,
|
|
888
|
+
// and a closure per segment per frame is avoidable garbage.
|
|
889
|
+
let selected = false;
|
|
890
|
+
if (selIndex !== null) {
|
|
891
|
+
selected = stackIndexMatches(selIndex, stableMark, ss.begin[b], ss.groups[g]);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
for (let si = 0; si < selection.length; si += 1) {
|
|
895
|
+
if (matches(selection[si])) {
|
|
896
|
+
selected = true;
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// A span covers this segment when the bin's `begin` is inside its
|
|
902
|
+
// half-open `x` (and its `y` / `rows` channels pass — see the param doc).
|
|
903
|
+
// The label channel is what the hit reports: the stable per-bin `mark`
|
|
904
|
+
// (a categorical bar's category name) when the series carries marks,
|
|
905
|
+
// else the group — `hitTest` builds `label: stableMark ?? name`, and the
|
|
906
|
+
// span test must read the same field or `selectionContains` and the
|
|
907
|
+
// canvas disagree on every categorical bar.
|
|
908
|
+
if (!selected && hasSpans) {
|
|
909
|
+
selected = spanMatchesAny(spans, ss.begin[b], v, stableMark ?? ss.groups[g]);
|
|
910
|
+
}
|
|
911
|
+
let isHovered = false;
|
|
912
|
+
if (hovIndex !== null) {
|
|
913
|
+
isHovered = stackIndexMatches(hovIndex, stableMark, ss.begin[b], ss.groups[g]);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
for (let hi = 0; hi < hover.length; hi += 1) {
|
|
917
|
+
if (matches(hover[hi])) {
|
|
918
|
+
isHovered = true;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// A hovered / selected segment pops its alpha; a resting one draws at the
|
|
924
|
+
// shared one. `emphasisOpacity` makes the *difference* themeable, where
|
|
925
|
+
// before only the resting floor was.
|
|
926
|
+
ctx.globalAlpha =
|
|
927
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
539
928
|
// A per-bin colour (the single-series band case) overrides the group fill.
|
|
540
929
|
const fill = style.binFills?.[b] ?? style.fills[g];
|
|
541
|
-
|
|
930
|
+
if (ladder !== undefined) {
|
|
931
|
+
// Threshold banding on the plain / categorical / horizontal bar: slice
|
|
932
|
+
// the bar's length at the ladder boundaries, transposing on
|
|
933
|
+
// orientation — vertical bars band along y, horizontal along x, while
|
|
934
|
+
// the bin span (the other axis) is shared by every band.
|
|
935
|
+
if (dimming && !selected && !isHovered) {
|
|
936
|
+
ctx.fillStyle = style.dimmedFills?.[g] ?? style.dimmed ?? fill;
|
|
937
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
let topFill = ladder.colors[0];
|
|
941
|
+
for (let bk = 0; bk < ladder.colors.length; bk += 1) {
|
|
942
|
+
if (!bandSpanInto(base, v, ladder.thresholds, bk))
|
|
943
|
+
continue;
|
|
944
|
+
ctx.fillStyle = ladder.colors[bk];
|
|
945
|
+
if (orientation === 'vertical') {
|
|
946
|
+
const a = yScale(bandLo);
|
|
947
|
+
const c = yScale(bandHi);
|
|
948
|
+
const top = a < c ? a : c;
|
|
949
|
+
ctx.fillRect(x0, top, x1 - x0, (a < c ? c : a) - top);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
const a = xScale(bandLo);
|
|
953
|
+
const c = xScale(bandHi);
|
|
954
|
+
const left = a < c ? a : c;
|
|
955
|
+
ctx.fillRect(left, yTop, (a < c ? c : a) - left, yBottom - yTop);
|
|
956
|
+
}
|
|
957
|
+
topFill = ladder.colors[bk];
|
|
958
|
+
}
|
|
959
|
+
if (selected) {
|
|
960
|
+
// A themed outline if the theme sets one, else the band the value
|
|
961
|
+
// reached — the one colour that means anything on a banded bar.
|
|
962
|
+
ctx.strokeStyle = style.selectedOutline ?? topFill;
|
|
963
|
+
strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
|
|
964
|
+
}
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
// [PND-CATEMPH] The themed three-step emphasis, applied where it can be:
|
|
968
|
+
// with no `binFills` there is no meaning-carrying colour to destroy, so a
|
|
969
|
+
// selected segment takes `highlight` and a hovered one `hover`, exactly
|
|
970
|
+
// as the single-series path does. With `binFills` the bar keeps its own
|
|
971
|
+
// colour (the design exclusion) and the alpha pop above is the signal.
|
|
972
|
+
// Same precedence as `drawBars`: selected > hovered > dimmed > rest.
|
|
973
|
+
const emphasised = dimming && !selected && !isHovered
|
|
974
|
+
? (style.dimmedFills?.[g] ?? style.dimmed ?? fill)
|
|
975
|
+
: style.binFills !== undefined
|
|
976
|
+
? fill
|
|
977
|
+
: selected
|
|
978
|
+
? // A group ramp is meaning-carrying colour, so a selected
|
|
979
|
+
// segment keeps it: the outline and the receded neighbours are
|
|
980
|
+
// the cue. Same exclusion `binFills` gets, one line up.
|
|
981
|
+
style.groupColored
|
|
982
|
+
? fill
|
|
983
|
+
: (style.highlight ?? fill)
|
|
984
|
+
: isHovered
|
|
985
|
+
? // Per-group where the ramp supplies one, so hovering
|
|
986
|
+
// brightens the segment instead of recolouring it out of
|
|
987
|
+
// its group.
|
|
988
|
+
(style.hoverFills?.[g] ??
|
|
989
|
+
style.hover ??
|
|
990
|
+
style.highlight ??
|
|
991
|
+
fill)
|
|
992
|
+
: fill;
|
|
993
|
+
ctx.fillStyle = emphasised;
|
|
542
994
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
543
995
|
if (selected) {
|
|
544
|
-
ctx.
|
|
545
|
-
ctx
|
|
546
|
-
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
996
|
+
ctx.strokeStyle = style.selectedOutline ?? emphasised;
|
|
997
|
+
strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
|
|
547
998
|
}
|
|
548
999
|
}
|
|
549
1000
|
}
|
|
@@ -564,12 +1015,19 @@ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanP
|
|
|
564
1015
|
const G = ss.groups.length;
|
|
565
1016
|
const base = stackBase(orientation, xScale, yScale);
|
|
566
1017
|
for (let b = 0; b < ss.length; b += 1) {
|
|
567
|
-
|
|
1018
|
+
// The same two accumulators `drawStacks` keeps — they must agree exactly or
|
|
1019
|
+
// the hit rect drifts from the drawn one.
|
|
1020
|
+
let cumPos = base;
|
|
1021
|
+
let cumNeg = base;
|
|
568
1022
|
for (let g = 0; g < G; g += 1) {
|
|
569
|
-
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
570
1023
|
const v = ss.values[b * G + g];
|
|
571
|
-
|
|
572
|
-
|
|
1024
|
+
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
|
|
1025
|
+
if (Number.isFinite(v)) {
|
|
1026
|
+
if (v > 0)
|
|
1027
|
+
cumPos += v;
|
|
1028
|
+
else if (v < 0)
|
|
1029
|
+
cumNeg += v;
|
|
1030
|
+
}
|
|
573
1031
|
if (rect === null)
|
|
574
1032
|
continue;
|
|
575
1033
|
const [x0, x1, yTop, yBottom] = rect;
|