@pond-ts/charts 0.55.0 → 0.57.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 +288 -1
- package/dist/AreaChart.d.ts +18 -0
- package/dist/AreaChart.js +24 -1
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +113 -12
- package/dist/ChartContainer.d.ts +44 -1
- package/dist/ChartContainer.js +18 -2
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +14 -1
- package/dist/YAxis.d.ts +56 -1
- package/dist/YAxis.js +28 -3
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +139 -4
- package/dist/bars.js +300 -33
- package/dist/context.d.ts +30 -5
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/line.js +10 -1
- package/dist/theme.d.ts +73 -6
- package/dist/theme.js +5 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- package/package.json +3 -3
package/dist/bars.js
CHANGED
|
@@ -81,6 +81,124 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
81
81
|
const yBase = yScale(baseline);
|
|
82
82
|
return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The value-space span `[lo, hi]` of **threshold band `k`** along a bar running
|
|
86
|
+
* from `base` to `v`, or `null` when the bar doesn't reach that band.
|
|
87
|
+
*
|
|
88
|
+
* A threshold ladder colours one bar **along its length** — neutral up to the
|
|
89
|
+
* first threshold, then warning, then alarm — so a long bar shows how far
|
|
90
|
+
* through the ladder it travelled rather than only which band it ended in. With
|
|
91
|
+
* `thresholds = [t0, t1]` there are three bands: `[0, t0)`, `[t0, t1)`,
|
|
92
|
+
* `[t1, ∞)`. Band `k` spans magnitudes `[thresholds[k-1] ?? 0, thresholds[k] ??
|
|
93
|
+
* ∞)`, each end clipped to the bar's own magnitude — so a bar that stops inside
|
|
94
|
+
* band 1 yields a truncated band 1 and `null` for band 2.
|
|
95
|
+
*
|
|
96
|
+
* **Breakpoints are absolute data values, not offsets from the baseline** — a
|
|
97
|
+
* `thresholds={[1, 2]}` ladder means "warning above 1, alarm above 2" in the
|
|
98
|
+
* axis's own units, which is what a threshold means everywhere else. They are
|
|
99
|
+
* matched on the **magnitude** and applied to whichever side of zero the bar
|
|
100
|
+
* is on, so a bar hanging below the baseline walks the same ladder downward
|
|
101
|
+
* and a ±3.5 diverging scale bands symmetrically without the caller supplying
|
|
102
|
+
* negative breakpoints. (An asymmetric ladder would need signed breakpoints;
|
|
103
|
+
* deferred until a consumer pulls — see [PND-BANDBAR2].)
|
|
104
|
+
*
|
|
105
|
+
* The painted span is then **clipped to what the bar actually draws**, which
|
|
106
|
+
* is what makes a domain that excludes zero behave: with `<YAxis min={10}>` a
|
|
107
|
+
* bar rests on 10, so a `[1, 2]` ladder leaves it entirely in the top band
|
|
108
|
+
* rather than banding at 11 and 12. Measuring the ladder from the *resolved
|
|
109
|
+
* baseline* instead would silently shift every breakpoint by the axis floor —
|
|
110
|
+
* exactly the class of quiet wrongness this feature exists to remove.
|
|
111
|
+
*
|
|
112
|
+
* Note this is **draw-only geometry**. Hit-testing still treats the bar as one
|
|
113
|
+
* target ({@link barSlotRect} / {@link barAt}), which is the whole reason this
|
|
114
|
+
* is a mark rather than the N-layer overpaint recipe it replaces: one bar keeps
|
|
115
|
+
* one hit region, one stable `mark`, and one legend row.
|
|
116
|
+
*
|
|
117
|
+
* `thresholds` is assumed ascending and finite — {@link normalizeThresholds}
|
|
118
|
+
* enforces that once at the prop boundary rather than per bar per frame.
|
|
119
|
+
*/
|
|
120
|
+
export function bandSpan(base, v, thresholds, k) {
|
|
121
|
+
return bandSpanInto(base, v, thresholds, k) ? [bandLo, bandHi] : null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The band-`k` span, written to {@link bandLo} / {@link bandHi} instead of
|
|
125
|
+
* returned — `true` when the bar reaches this band, `false` when it doesn't.
|
|
126
|
+
*
|
|
127
|
+
* This is {@link bandSpan}'s implementation, split out because the tuple
|
|
128
|
+
* mattered: a K-band ladder allocates K tuples **per bar per frame**, and the
|
|
129
|
+
* bench showed that turning a banded draw from ~44% *cheaper* than the N-layer
|
|
130
|
+
* workaround it replaces into ~44% *dearer* than it. Same arithmetic, no
|
|
131
|
+
* garbage. `bandSpan` stays as the allocating wrapper so the geometry is
|
|
132
|
+
* testable as a value.
|
|
133
|
+
*
|
|
134
|
+
* Module-scope scratch is safe here: the draw path is single-threaded and reads
|
|
135
|
+
* both fields immediately after a `true`, before any other call can run.
|
|
136
|
+
*/
|
|
137
|
+
let bandLo = 0;
|
|
138
|
+
let bandHi = 0;
|
|
139
|
+
function bandSpanInto(base, v, thresholds, k) {
|
|
140
|
+
const lo = k === 0 ? 0 : thresholds[k - 1];
|
|
141
|
+
const hi = k < thresholds.length ? thresholds[k] : Infinity;
|
|
142
|
+
// The bar's drawn extent, ascending. `resolveBarBaseline` clamps 0 into the
|
|
143
|
+
// domain, so this span never straddles zero: either it starts at 0, or the
|
|
144
|
+
// whole domain sits to one side of it.
|
|
145
|
+
const sLo = base < v ? base : v;
|
|
146
|
+
const sHi = base < v ? v : base;
|
|
147
|
+
// Band `k` covers the *absolute* values `[lo, hi)`, which is two intervals —
|
|
148
|
+
// `[lo, hi]` and `[-hi, -lo]`. Pick the one on the bar's own side of zero.
|
|
149
|
+
const positive = sHi > 0;
|
|
150
|
+
let bLo = positive ? lo : -hi;
|
|
151
|
+
// `-lo` when `lo === 0` is **negative zero**, which would escape through the
|
|
152
|
+
// exported `bandSpan` and fail any consumer's `Object.is` / `toEqual` against
|
|
153
|
+
// a plain `0`. Normalize at the source rather than letting each caller cope.
|
|
154
|
+
let bHi = positive ? hi : lo === 0 ? 0 : -lo;
|
|
155
|
+
// Clip to what the bar actually draws. An empty or inverted result means this
|
|
156
|
+
// band lies outside the bar's span — either beyond its reach, or (on a domain
|
|
157
|
+
// that excludes zero) entirely below its floor.
|
|
158
|
+
if (bLo < sLo)
|
|
159
|
+
bLo = sLo;
|
|
160
|
+
if (bHi > sHi)
|
|
161
|
+
bHi = sHi;
|
|
162
|
+
if (bHi <= bLo)
|
|
163
|
+
return false;
|
|
164
|
+
bandLo = bLo;
|
|
165
|
+
bandHi = bHi;
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Validate + freeze a caller's threshold ladder once, at the prop boundary.
|
|
170
|
+
* Returns the ascending, strictly-positive, finite breakpoints — or `null` when
|
|
171
|
+
* there is no usable ladder left, so the caller keeps the flat path.
|
|
172
|
+
*
|
|
173
|
+
* Sorting rather than rejecting an out-of-order ladder is deliberate — the
|
|
174
|
+
* bands are defined by their boundaries, so `[2, 1]` and `[1, 2]` describe the
|
|
175
|
+
* same three bands and there is no second reading to guess at.
|
|
176
|
+
*
|
|
177
|
+
* Three kinds of entry are **dropped**:
|
|
178
|
+
*
|
|
179
|
+
* - **non-finite** — would swallow every band above it;
|
|
180
|
+
* - **negative** — the ladder is walked on the *magnitude* and mirrored onto
|
|
181
|
+
* whichever side of zero the bar is on, so a negative breakpoint has no
|
|
182
|
+
* meaning. Left in, `[-2, -1]` silently clipped every lower band away and
|
|
183
|
+
* painted the whole bar in the final colour — a one-colour bar that looks
|
|
184
|
+
* deliberate (Codex adversarial review). Signed breakpoints are the
|
|
185
|
+
* asymmetric-ladder feature deferred in [PND-BANDBAR2], not this;
|
|
186
|
+
* - **zero** — band 0 already starts at zero, so a `0` breakpoint describes an
|
|
187
|
+
* empty band and shifts every colour by one.
|
|
188
|
+
*
|
|
189
|
+
* Dropping rather than throwing matches how the rest of this prop behaves
|
|
190
|
+
* (a short colour ladder degrades, it doesn't fail), and `BarChart` dev-warns
|
|
191
|
+
* whenever normalization removed anything — a silently-ignored breakpoint is
|
|
192
|
+
* the failure mode this whole feature exists to avoid.
|
|
193
|
+
*/
|
|
194
|
+
export function normalizeThresholds(thresholds) {
|
|
195
|
+
if (thresholds === undefined || thresholds.length === 0)
|
|
196
|
+
return null;
|
|
197
|
+
const clean = thresholds.filter((t) => Number.isFinite(t) && t > 0);
|
|
198
|
+
if (clean.length === 0)
|
|
199
|
+
return null;
|
|
200
|
+
return clean.sort((a, b) => a - b);
|
|
201
|
+
}
|
|
84
202
|
/**
|
|
85
203
|
* Does `m` identify the bar with stable identity `stable` and key `begin`?
|
|
86
204
|
* The mark decides **only when both sides have one** — `m.mark` (the selection
|
|
@@ -159,7 +277,7 @@ function barMatches(m, seriesId, stable, begin) {
|
|
|
159
277
|
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
160
278
|
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
161
279
|
*/
|
|
162
|
-
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills) {
|
|
280
|
+
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills, banding) {
|
|
163
281
|
ctx.save();
|
|
164
282
|
ctx.globalAlpha = style.opacity;
|
|
165
283
|
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
@@ -177,8 +295,14 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
177
295
|
// *empty* colour array is "no colours" (every bar would flat-fill anyway), so
|
|
178
296
|
// it stays on the legacy path end-to-end (L2 review, PR #542).
|
|
179
297
|
const fills = binFills !== undefined && binFills.length > 0 ? binFills : undefined;
|
|
298
|
+
// A threshold ladder is per-*bar* colour too, so it takes the same exits as
|
|
299
|
+
// `binFills`: no envelope pass (one rect can't carry a gradient), and it
|
|
300
|
+
// yields to an explicit `binFills` when a caller sets both (warned about at
|
|
301
|
+
// the prop boundary — the two are different answers to "what colour is this
|
|
302
|
+
// bar", and per-bar is the more specific one).
|
|
303
|
+
const ladder = fills === undefined ? banding : undefined;
|
|
180
304
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
181
|
-
const envelope = decimate !== false && fills === undefined
|
|
305
|
+
const envelope = decimate !== false && fills === undefined && ladder === undefined
|
|
182
306
|
? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
|
|
183
307
|
: null;
|
|
184
308
|
if (envelope !== null) {
|
|
@@ -228,13 +352,46 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
228
352
|
// highlight pops the alpha to 1 and outlines the selection in the bar's
|
|
229
353
|
// own fill (the drawStacks binFills convention; see the header).
|
|
230
354
|
const fill = fills[i] ?? style.fill;
|
|
231
|
-
ctx.globalAlpha =
|
|
355
|
+
ctx.globalAlpha =
|
|
356
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
232
357
|
ctx.fillStyle = fill;
|
|
233
358
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
234
359
|
drawn += 1;
|
|
235
360
|
if (selected) {
|
|
236
361
|
ctx.lineWidth = style.outlineWidth;
|
|
237
|
-
ctx.strokeStyle = fill;
|
|
362
|
+
ctx.strokeStyle = style.selectedOutline ?? fill;
|
|
363
|
+
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (ladder !== undefined) {
|
|
368
|
+
// Threshold banding: one rect per band the bar reaches, sharing the bar's
|
|
369
|
+
// x-span and slicing its length at the ladder boundaries. Like `binFills`
|
|
370
|
+
// the bar keeps its own colours when live (swapping to one `highlight`
|
|
371
|
+
// would erase the very thing the bands encode) and pops the alpha
|
|
372
|
+
// instead. The whole bar stays one hit target — see `bandSpan`.
|
|
373
|
+
ctx.globalAlpha =
|
|
374
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
375
|
+
const v = cs.y[i];
|
|
376
|
+
let topFill = ladder.colors[0];
|
|
377
|
+
for (let bk = 0; bk < ladder.colors.length; bk += 1) {
|
|
378
|
+
if (!bandSpanInto(baseline, v, ladder.thresholds, bk))
|
|
379
|
+
continue;
|
|
380
|
+
const ySpanA = yScale(bandLo);
|
|
381
|
+
const ySpanB = yScale(bandHi);
|
|
382
|
+
const bandTop = ySpanA < ySpanB ? ySpanA : ySpanB;
|
|
383
|
+
const bandBottom = ySpanA < ySpanB ? ySpanB : ySpanA;
|
|
384
|
+
ctx.fillStyle = ladder.colors[bk];
|
|
385
|
+
ctx.fillRect(x0, bandTop, x1 - x0, bandBottom - bandTop);
|
|
386
|
+
topFill = ladder.colors[bk]; // the band the value actually landed in
|
|
387
|
+
}
|
|
388
|
+
drawn += 1;
|
|
389
|
+
if (selected) {
|
|
390
|
+
// Outline the whole bar (not the last band) in the colour of the band
|
|
391
|
+
// the value reached — the one colour that means something for a bar
|
|
392
|
+
// painted in several.
|
|
393
|
+
ctx.lineWidth = style.outlineWidth;
|
|
394
|
+
ctx.strokeStyle = style.selectedOutline ?? topFill;
|
|
238
395
|
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
239
396
|
}
|
|
240
397
|
continue;
|
|
@@ -244,7 +401,8 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
244
401
|
// at the resting `style.opacity`, so on an alpha'd theme a hovered bar
|
|
245
402
|
// (which has no outline) barely changed at all, and a selected one read
|
|
246
403
|
// only by its outline (#576).
|
|
247
|
-
ctx.globalAlpha =
|
|
404
|
+
ctx.globalAlpha =
|
|
405
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
248
406
|
// Three-step emphasis when the theme opts in with `hover`: rest → hover →
|
|
249
407
|
// selected. Selection outranks hover on a bar that is both (as the outline
|
|
250
408
|
// already did). With no `hover` colour this is the shipped two-step —
|
|
@@ -264,7 +422,7 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
264
422
|
// that needs the two states clearly apart sets `BarStyle.hover` (#577);
|
|
265
423
|
// the outline is the shape cue, not the whole signal.
|
|
266
424
|
ctx.lineWidth = style.outlineWidth;
|
|
267
|
-
ctx.strokeStyle = style.highlight;
|
|
425
|
+
ctx.strokeStyle = style.selectedOutline ?? style.highlight;
|
|
268
426
|
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
269
427
|
}
|
|
270
428
|
}
|
|
@@ -386,21 +544,28 @@ export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
|
|
|
386
544
|
}
|
|
387
545
|
/**
|
|
388
546
|
* The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
|
|
389
|
-
* stack it is `[
|
|
390
|
-
*
|
|
547
|
+
* stack it is `[minNegTotal, maxPosTotal]` — each bin's positive segments summed
|
|
548
|
+
* upward and its negative segments summed downward, tracked separately
|
|
549
|
+
* ([PND-SIGNSTACK]). For a **single-group** series (`G === 1` — the plain /
|
|
391
550
|
* categorical bar case) it spans the values' own `[min, max]`, so a **negative**
|
|
392
551
|
* bar's floor is in the domain (segments below the baseline stay visible). `0` is
|
|
393
552
|
* always pulled in so the bars rest on a visible baseline (the bar analog of
|
|
394
553
|
* {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
|
|
395
554
|
* has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
|
|
396
555
|
* auto-fit for a horizontal one.
|
|
556
|
+
*
|
|
557
|
+
* The negative half is new: this used to sum only positives, matching a draw
|
|
558
|
+
* path that dropped negative segments outright. Both halves changed together —
|
|
559
|
+
* an extent that stopped at `0` below would clip the very segments the draw
|
|
560
|
+
* path now emits.
|
|
397
561
|
*/
|
|
398
562
|
export function stackValueExtent(ss) {
|
|
399
563
|
const G = ss.groups.length;
|
|
400
564
|
let max = 0;
|
|
401
565
|
let min = 0;
|
|
402
566
|
for (let b = 0; b < ss.length; b += 1) {
|
|
403
|
-
let
|
|
567
|
+
let cumPos = 0;
|
|
568
|
+
let cumNeg = 0;
|
|
404
569
|
for (let g = 0; g < G; g += 1) {
|
|
405
570
|
const v = ss.values[b * G + g];
|
|
406
571
|
if (!Number.isFinite(v))
|
|
@@ -413,11 +578,16 @@ export function stackValueExtent(ss) {
|
|
|
413
578
|
min = v;
|
|
414
579
|
}
|
|
415
580
|
else if (v > 0) {
|
|
416
|
-
|
|
581
|
+
cumPos += v; // True stack: positives stack up from the baseline…
|
|
582
|
+
}
|
|
583
|
+
else if (v < 0) {
|
|
584
|
+
cumNeg += v; // …negatives stack down from it.
|
|
417
585
|
}
|
|
418
586
|
}
|
|
419
|
-
if (
|
|
420
|
-
max =
|
|
587
|
+
if (cumPos > max)
|
|
588
|
+
max = cumPos;
|
|
589
|
+
if (cumNeg < min)
|
|
590
|
+
min = cumNeg;
|
|
421
591
|
}
|
|
422
592
|
// Empty / all-gap / all-zero → a usable unit domain; otherwise the real extent
|
|
423
593
|
// (with 0 pulled in via the `min`/`max` seeds above).
|
|
@@ -435,6 +605,24 @@ export function stackBinExtent(ss) {
|
|
|
435
605
|
return null;
|
|
436
606
|
return [ss.begin[0], ss.end[ss.length - 1]];
|
|
437
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* The value a stack's **first** segment rests on, in data units — the same
|
|
610
|
+
* `0`-clamped-into-the-domain rule {@link resolveBarBaseline} applies to a plain
|
|
611
|
+
* bar, read off whichever scale carries the stacked value (`yScale` when the
|
|
612
|
+
* bars grow up, `xScale` when they grow right).
|
|
613
|
+
*
|
|
614
|
+
* Both stack walks used to start at a literal `0`, which is right only while the
|
|
615
|
+
* domain contains zero — and a **log** domain never can. `yScale(0)` on a log
|
|
616
|
+
* scale is `NaN`, `fillRect` with a `NaN` argument is a silent canvas no-op, and
|
|
617
|
+
* the same rect feeds {@link stackAt} — so the bottom segment of every stack
|
|
618
|
+
* both vanished *and* became unhittable, with nothing to see but a stack that
|
|
619
|
+
* starts one segment up. The linear case is unaffected: the value extents pull
|
|
620
|
+
* `0` into the domain, so this returns exactly `0` and the geometry is
|
|
621
|
+
* unchanged.
|
|
622
|
+
*/
|
|
623
|
+
export function stackBase(orientation, xScale, yScale) {
|
|
624
|
+
return resolveBarBaseline(orientation === 'vertical' ? yScale : xScale);
|
|
625
|
+
}
|
|
438
626
|
/**
|
|
439
627
|
* The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
|
|
440
628
|
* segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
|
|
@@ -456,13 +644,22 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
|
|
|
456
644
|
const G = ss.groups.length;
|
|
457
645
|
const v = ss.values[b * G + g];
|
|
458
646
|
// Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
|
|
459
|
-
// hit-tested).
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
647
|
+
// hit-tested). **Negative segments are kept, whatever `G` is** — the caller
|
|
648
|
+
// passes the downward running total for them (see {@link drawStacks}), and
|
|
649
|
+
// the `Math.min/Math.max` below normalizes the below-baseline rect.
|
|
650
|
+
//
|
|
651
|
+
// A multi-group stack used to drop them here (`v < 0 && G > 1`) on the
|
|
652
|
+
// grounds that "stacking a negative segment is undefined". That is fair for a
|
|
653
|
+
// conventional stack and wrong for the **signed stacked histogram** — several
|
|
654
|
+
// series per bin whose values may be either sign, positives stacking up from
|
|
655
|
+
// a zero line and negatives down from it (net flow by category,
|
|
656
|
+
// inflow/outflow, buy/sell pressure by venue). That is a well-defined stack:
|
|
657
|
+
// two running totals per bin instead of one. And the old behaviour failed
|
|
658
|
+
// *silently* — the dropped segments didn't clamp, warn or throw, so every
|
|
659
|
+
// remaining segment stacked up as though they had never been in the data and
|
|
660
|
+
// a mixed-sign series rendered as a confident, wrong, all-positive chart
|
|
661
|
+
// ([PND-SIGNSTACK]).
|
|
662
|
+
if (!Number.isFinite(v) || v === 0)
|
|
466
663
|
return null;
|
|
467
664
|
if (orientation === 'vertical') {
|
|
468
665
|
const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
|
|
@@ -489,17 +686,32 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
|
|
|
489
686
|
*
|
|
490
687
|
* O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
|
|
491
688
|
*/
|
|
492
|
-
export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover) {
|
|
689
|
+
export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover, banding) {
|
|
493
690
|
const G = ss.groups.length;
|
|
691
|
+
const base = stackBase(orientation, xScale, yScale);
|
|
692
|
+
// Threshold banding applies to a **plain** bar only. `G === 1` is exactly the
|
|
693
|
+
// categorical / horizontal single-value bar (`categoryStack` builds a
|
|
694
|
+
// one-group series); a genuine multi-group stack has no defined banding —
|
|
695
|
+
// each segment is already a slice of a total — so the ladder is dropped here
|
|
696
|
+
// and warned about at the prop boundary rather than half-applied.
|
|
697
|
+
const ladder = G === 1 && style.binFills === undefined ? banding : undefined;
|
|
494
698
|
ctx.save();
|
|
495
699
|
ctx.globalAlpha = style.opacity;
|
|
496
700
|
for (let b = 0; b < ss.length; b += 1) {
|
|
497
|
-
|
|
701
|
+
// Two running totals per bin ([PND-SIGNSTACK]): positives stack upward from
|
|
702
|
+
// the baseline, negatives downward from it. A conventional all-positive
|
|
703
|
+
// stack never touches `cumNeg`, so its geometry is bit-identical to before.
|
|
704
|
+
let cumPos = base;
|
|
705
|
+
let cumNeg = base;
|
|
498
706
|
for (let g = 0; g < G; g += 1) {
|
|
499
|
-
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
500
707
|
const v = ss.values[b * G + g];
|
|
501
|
-
|
|
502
|
-
|
|
708
|
+
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
|
|
709
|
+
if (Number.isFinite(v)) {
|
|
710
|
+
if (v > 0)
|
|
711
|
+
cumPos += v;
|
|
712
|
+
else if (v < 0)
|
|
713
|
+
cumNeg += v;
|
|
714
|
+
}
|
|
503
715
|
if (rect === null)
|
|
504
716
|
continue;
|
|
505
717
|
const [x0, x1, yTop, yBottom] = rect;
|
|
@@ -514,16 +726,63 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
|
|
|
514
726
|
: m.key === ss.begin[b] && m.label === ss.groups[g]);
|
|
515
727
|
const selected = matches(selection);
|
|
516
728
|
const isHovered = matches(hover);
|
|
517
|
-
// A hovered / selected segment pops
|
|
518
|
-
//
|
|
519
|
-
|
|
729
|
+
// A hovered / selected segment pops its alpha; a resting one draws at the
|
|
730
|
+
// shared one. `emphasisOpacity` makes the *difference* themeable, where
|
|
731
|
+
// before only the resting floor was.
|
|
732
|
+
ctx.globalAlpha =
|
|
733
|
+
selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
|
|
520
734
|
// A per-bin colour (the single-series band case) overrides the group fill.
|
|
521
735
|
const fill = style.binFills?.[b] ?? style.fills[g];
|
|
522
|
-
|
|
736
|
+
if (ladder !== undefined) {
|
|
737
|
+
// Threshold banding on the plain / categorical / horizontal bar: slice
|
|
738
|
+
// the bar's length at the ladder boundaries, transposing on
|
|
739
|
+
// orientation — vertical bars band along y, horizontal along x, while
|
|
740
|
+
// the bin span (the other axis) is shared by every band.
|
|
741
|
+
let topFill = ladder.colors[0];
|
|
742
|
+
for (let bk = 0; bk < ladder.colors.length; bk += 1) {
|
|
743
|
+
if (!bandSpanInto(base, v, ladder.thresholds, bk))
|
|
744
|
+
continue;
|
|
745
|
+
ctx.fillStyle = ladder.colors[bk];
|
|
746
|
+
if (orientation === 'vertical') {
|
|
747
|
+
const a = yScale(bandLo);
|
|
748
|
+
const c = yScale(bandHi);
|
|
749
|
+
const top = a < c ? a : c;
|
|
750
|
+
ctx.fillRect(x0, top, x1 - x0, (a < c ? c : a) - top);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
const a = xScale(bandLo);
|
|
754
|
+
const c = xScale(bandHi);
|
|
755
|
+
const left = a < c ? a : c;
|
|
756
|
+
ctx.fillRect(left, yTop, (a < c ? c : a) - left, yBottom - yTop);
|
|
757
|
+
}
|
|
758
|
+
topFill = ladder.colors[bk];
|
|
759
|
+
}
|
|
760
|
+
if (selected) {
|
|
761
|
+
ctx.lineWidth = style.outlineWidth;
|
|
762
|
+
// A themed outline if the theme sets one, else the band the value
|
|
763
|
+
// reached — the one colour that means anything on a banded bar.
|
|
764
|
+
ctx.strokeStyle = style.selectedOutline ?? topFill;
|
|
765
|
+
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
// [PND-CATEMPH] The themed three-step emphasis, applied where it can be:
|
|
770
|
+
// with no `binFills` there is no meaning-carrying colour to destroy, so a
|
|
771
|
+
// selected segment takes `highlight` and a hovered one `hover`, exactly
|
|
772
|
+
// as the single-series path does. With `binFills` the bar keeps its own
|
|
773
|
+
// colour (the design exclusion) and the alpha pop above is the signal.
|
|
774
|
+
const emphasised = style.binFills === undefined
|
|
775
|
+
? selected
|
|
776
|
+
? (style.highlight ?? fill)
|
|
777
|
+
: isHovered
|
|
778
|
+
? (style.hover ?? style.highlight ?? fill)
|
|
779
|
+
: fill
|
|
780
|
+
: fill;
|
|
781
|
+
ctx.fillStyle = emphasised;
|
|
523
782
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
524
783
|
if (selected) {
|
|
525
784
|
ctx.lineWidth = style.outlineWidth;
|
|
526
|
-
ctx.strokeStyle =
|
|
785
|
+
ctx.strokeStyle = style.selectedOutline ?? emphasised;
|
|
527
786
|
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
528
787
|
}
|
|
529
788
|
}
|
|
@@ -543,13 +802,21 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
|
|
|
543
802
|
*/
|
|
544
803
|
export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
|
|
545
804
|
const G = ss.groups.length;
|
|
805
|
+
const base = stackBase(orientation, xScale, yScale);
|
|
546
806
|
for (let b = 0; b < ss.length; b += 1) {
|
|
547
|
-
|
|
807
|
+
// The same two accumulators `drawStacks` keeps — they must agree exactly or
|
|
808
|
+
// the hit rect drifts from the drawn one.
|
|
809
|
+
let cumPos = base;
|
|
810
|
+
let cumNeg = base;
|
|
548
811
|
for (let g = 0; g < G; g += 1) {
|
|
549
|
-
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
550
812
|
const v = ss.values[b * G + g];
|
|
551
|
-
|
|
552
|
-
|
|
813
|
+
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
|
|
814
|
+
if (Number.isFinite(v)) {
|
|
815
|
+
if (v > 0)
|
|
816
|
+
cumPos += v;
|
|
817
|
+
else if (v < 0)
|
|
818
|
+
cumNeg += v;
|
|
819
|
+
}
|
|
553
820
|
if (rect === null)
|
|
554
821
|
continue;
|
|
555
822
|
const [x0, x1, yTop, yBottom] = rect;
|
package/dist/context.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ScaleLinear, ScaleTime } from 'd3-scale';
|
|
1
|
+
import type { ScaleContinuousNumeric, ScaleLinear, ScaleTime } from 'd3-scale';
|
|
2
2
|
import type { ChartTheme } from './theme.js';
|
|
3
3
|
import type { AxisFormat } from './format.js';
|
|
4
4
|
import type { LegendItemSpec } from './swatch.js';
|
|
@@ -349,6 +349,15 @@ export interface ContainerFrame {
|
|
|
349
349
|
}
|
|
350
350
|
/** The kind of an annotation, and of a creation tool. */
|
|
351
351
|
export type AnnotationKind = 'region' | 'marker' | 'baseline';
|
|
352
|
+
/**
|
|
353
|
+
* The kind of a **registered** mark — wider than {@link AnnotationKind}.
|
|
354
|
+
*
|
|
355
|
+
* A `<Zone>` is a mark you place in JSX but **not** a create *tool*: there is no
|
|
356
|
+
* draw gesture for it and no {@link CreateSpec} variant, so it registers under
|
|
357
|
+
* its own kind without widening the toolbar vocabulary (which would let
|
|
358
|
+
* `creating="zone"` type-check and then silently never fire `onCreate`).
|
|
359
|
+
*/
|
|
360
|
+
export type AnnotationSpecKind = AnnotationKind | 'zone';
|
|
352
361
|
/** What a completed create gesture reports to {@link ContainerFrame.onCreate} —
|
|
353
362
|
* the new mark's kind + position in axis units (+ the y-axis id for a baseline).
|
|
354
363
|
* (Which row a mark lands on is the consumer's call for now; multi-row routing is
|
|
@@ -376,14 +385,14 @@ export interface AnnotationSpec {
|
|
|
376
385
|
* double-click reports via {@link ContainerFrame.onSelectAnnotation}, so the
|
|
377
386
|
* consumer knows which mark to select. */
|
|
378
387
|
readonly id: string | undefined;
|
|
379
|
-
readonly kind:
|
|
388
|
+
readonly kind: AnnotationSpecKind;
|
|
380
389
|
/** The row it lives on (its `<ChartRow>`'s key), so a row skips its own marks
|
|
381
390
|
* when drawing guides. */
|
|
382
391
|
readonly rowKey: symbol;
|
|
383
392
|
/**
|
|
384
393
|
* Its vertical-guide x-position(s) in **axis units** (the shared x): a marker's
|
|
385
|
-
* `[at]`, a region's `[from, to]`. Empty for a baseline
|
|
386
|
-
* casts no vertical guide.
|
|
394
|
+
* `[at]`, a region's `[from, to]`. Empty for a baseline or a zone — a
|
|
395
|
+
* horizontal line (or band) casts no vertical guide.
|
|
387
396
|
*/
|
|
388
397
|
readonly xs: readonly number[];
|
|
389
398
|
/** Whether it's currently selected (controlled by the consumer). */
|
|
@@ -761,11 +770,27 @@ export interface LayerEntry {
|
|
|
761
770
|
readonly index: number;
|
|
762
771
|
}
|
|
763
772
|
/** A y-axis declared in a {@link ChartRow} via `<YAxis>`. */
|
|
773
|
+
/** Which scale a y axis maps its domain through. */
|
|
774
|
+
export type YScaleKind = 'linear' | 'log';
|
|
775
|
+
/**
|
|
776
|
+
* A row's resolved y scale — d3's `scaleLinear()`, or `scaleLog()` when the
|
|
777
|
+
* axis asks for `scale="log"`.
|
|
778
|
+
*
|
|
779
|
+
* Deliberately the **continuous-numeric** supertype rather than `ScaleLinear`:
|
|
780
|
+
* every consumer (the axis labels, the row's gridlines, the cursor readout, and
|
|
781
|
+
* every draw layer) only ever calls it, or reads `domain` / `range` / `ticks` /
|
|
782
|
+
* `tickFormat` / `invert` — the surface both scales share. Keeping the shared
|
|
783
|
+
* type here is what lets a log axis be transparent to the draw layers instead
|
|
784
|
+
* of every layer growing a branch.
|
|
785
|
+
*/
|
|
786
|
+
export type YScale = ScaleContinuousNumeric<number, number>;
|
|
764
787
|
export interface AxisSpec {
|
|
765
788
|
readonly id: string;
|
|
766
789
|
readonly side: 'left' | 'right';
|
|
767
790
|
/** Gutter width in CSS pixels. */
|
|
768
791
|
readonly width: number;
|
|
792
|
+
/** Which scale the axis maps its domain through ({@link YAxisProps.scale}). */
|
|
793
|
+
readonly scale: YScaleKind;
|
|
769
794
|
/** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
|
|
770
795
|
readonly min: number | undefined;
|
|
771
796
|
readonly max: number | undefined;
|
|
@@ -799,7 +824,7 @@ export interface AxisSpec {
|
|
|
799
824
|
*/
|
|
800
825
|
export interface RowFrame {
|
|
801
826
|
readonly height: number;
|
|
802
|
-
readonly yScales: ReadonlyMap<string,
|
|
827
|
+
readonly yScales: ReadonlyMap<string, YScale>;
|
|
803
828
|
/** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
|
|
804
829
|
* against its scale) — used by both the tick labels and the cursor readout, so
|
|
805
830
|
* a value reads identically in both. */
|
package/dist/dev.d.ts
ADDED
package/dist/dev.js
ADDED
package/dist/domain.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { YScaleKind } from './context.js';
|
|
1
2
|
/**
|
|
2
3
|
* Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
|
|
3
4
|
* the layers linked to it. An `undefined` bound auto-fits the data: with no
|
|
@@ -19,6 +20,58 @@
|
|
|
19
20
|
* `pad × span` on each side — headroom without hand-computing bounds, useful to
|
|
20
21
|
* lift a tight **explicit** domain off the plot edges. Applied last, to whatever
|
|
21
22
|
* domain was resolved (explicit or auto); `0` is a no-op.
|
|
23
|
+
*
|
|
24
|
+
* `scale` selects the spacing. `'log'` delegates to {@link resolveLogDomain},
|
|
25
|
+
* which applies **every policy above** — verbatim explicit bounds, an auto-fit
|
|
26
|
+
* side that moves rather than a caller's bound being discarded, `.nice()` on a
|
|
27
|
+
* fully auto-fit domain — and differs only where a log axis forces it to: a
|
|
28
|
+
* non-positive bound has no position and is refused, and `pad` is a fraction of
|
|
29
|
+
* the *decades* spanned rather than of the difference.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
|
|
32
|
+
/**
|
|
33
|
+
* Does resolving this axis's domain need its layers' extents walked?
|
|
34
|
+
* `yExtent()` is O(points) per layer, so the caller only pays it when a side
|
|
35
|
+
* actually auto-fits.
|
|
36
|
+
*
|
|
37
|
+
* A log axis **refuses a non-positive bound** ({@link resolveLogDomain}), which
|
|
38
|
+
* means such a bound is not a bound: that side auto-fits and needs the data. The
|
|
39
|
+
* naive `min === undefined || max === undefined` test misses this, and the miss
|
|
40
|
+
* is silent — `<YAxis scale="log" min={0} max={1e6}>` looked fully explicit, so
|
|
41
|
+
* no extents were gathered, so the refused floor fell back to the empty-data
|
|
42
|
+
* placeholder instead of the data's own floor. (`resolveLogDomain`'s unit tests
|
|
43
|
+
* passed throughout: they hand it the extents directly, which is precisely what
|
|
44
|
+
* the component was not doing.)
|
|
45
|
+
*/
|
|
46
|
+
export declare function needsExtents(axis: {
|
|
47
|
+
readonly scale: YScaleKind;
|
|
48
|
+
readonly min: number | undefined;
|
|
49
|
+
readonly max: number | undefined;
|
|
50
|
+
}): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The dev-mode complaint a `scale="log"` axis has about its own bounds and the
|
|
53
|
+
* data linked to it, or `null` when it has none. Pure, so the policy is unit
|
|
54
|
+
* tested directly rather than through a rendered console spy.
|
|
55
|
+
*
|
|
56
|
+
* **Every case here is unambiguous**, which is the whole design constraint. The
|
|
57
|
+
* previous version warned whenever a linked extent reached zero, and that fires
|
|
58
|
+
* on *every* `BarChart` — `barExtent` always widens its low end to `0` so a bar
|
|
59
|
+
* can reach its baseline, whether or not the data goes anywhere near it. So the
|
|
60
|
+
* `WithBars` story warned, on strictly positive data, with text asserting
|
|
61
|
+
* something false about it. A dev warning that cries wolf gets muted, and then
|
|
62
|
+
* the real ones are lost too.
|
|
63
|
+
*
|
|
64
|
+
* The cost of that precision is the one genuinely ambiguous shape: an extent of
|
|
65
|
+
* exactly `[0, hi]`, which is what a line touching zero *and* a bar layer on
|
|
66
|
+
* positive data both report. It is not warned about. That case is no longer
|
|
67
|
+
* silent, though — a sample with no position on the axis now renders as a
|
|
68
|
+
* **gap** rather than being bridged straight over, so the picture itself says
|
|
69
|
+
* the data is missing there.
|
|
22
70
|
*/
|
|
23
|
-
export declare function
|
|
71
|
+
export declare function logAxisWarning(axis: {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly scale: YScaleKind;
|
|
74
|
+
readonly min: number | undefined;
|
|
75
|
+
readonly max: number | undefined;
|
|
76
|
+
}, extents: readonly (readonly [number, number] | null)[]): string | null;
|
|
24
77
|
//# sourceMappingURL=domain.d.ts.map
|