@pond-ts/charts 0.56.2 → 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/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 = selected || isHovered ? 1 : style.opacity;
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 = selected || isHovered ? 1 : style.opacity;
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 `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
390
- * non-negative segments. For a **single-group** series (`G === 1` — the plain /
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 cum = 0;
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
- cum += v; // True stack: sum the positive segments.
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 (cum > max)
420
- max = cum;
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).
@@ -474,13 +644,22 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
474
644
  const G = ss.groups.length;
475
645
  const v = ss.values[b * G + g];
476
646
  // Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
477
- // hit-tested). A **negative** value is a gap only in a true multi-group stack
478
- // (`G > 1`) stacking a negative segment is undefined. A **single-group**
479
- // series (`G === 1`) is a plain bar: it honours its sign and draws from the
480
- // baseline *down* to a negative value (the categorical row-read's P&L / delta
481
- // case), so negatives are kept and the `Math.min/Math.max` below normalizes the
482
- // below-baseline rect.
483
- if (!Number.isFinite(v) || v === 0 || (v < 0 && G > 1))
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)
484
663
  return null;
485
664
  if (orientation === 'vertical') {
486
665
  const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
@@ -507,18 +686,32 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
507
686
  *
508
687
  * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
509
688
  */
510
- 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) {
511
690
  const G = ss.groups.length;
512
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;
513
698
  ctx.save();
514
699
  ctx.globalAlpha = style.opacity;
515
700
  for (let b = 0; b < ss.length; b += 1) {
516
- let cum = base;
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;
517
706
  for (let g = 0; g < G; g += 1) {
518
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
519
707
  const v = ss.values[b * G + g];
520
- if (Number.isFinite(v) && v > 0)
521
- cum += v;
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
+ }
522
715
  if (rect === null)
523
716
  continue;
524
717
  const [x0, x1, yTop, yBottom] = rect;
@@ -533,16 +726,63 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
533
726
  : m.key === ss.begin[b] && m.label === ss.groups[g]);
534
727
  const selected = matches(selection);
535
728
  const isHovered = matches(hover);
536
- // A hovered / selected segment pops to full opacity in its own colour; a
537
- // resting one draws at the shared alpha.
538
- ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
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;
539
734
  // A per-bin colour (the single-series band case) overrides the group fill.
540
735
  const fill = style.binFills?.[b] ?? style.fills[g];
541
- ctx.fillStyle = fill;
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;
542
782
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
543
783
  if (selected) {
544
784
  ctx.lineWidth = style.outlineWidth;
545
- ctx.strokeStyle = fill;
785
+ ctx.strokeStyle = style.selectedOutline ?? emphasised;
546
786
  ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
547
787
  }
548
788
  }
@@ -564,12 +804,19 @@ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanP
564
804
  const G = ss.groups.length;
565
805
  const base = stackBase(orientation, xScale, yScale);
566
806
  for (let b = 0; b < ss.length; b += 1) {
567
- let cum = base;
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;
568
811
  for (let g = 0; g < G; g += 1) {
569
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
570
812
  const v = ss.values[b * G + g];
571
- if (Number.isFinite(v) && v > 0)
572
- cum += v;
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
+ }
573
820
  if (rect === null)
574
821
  continue;
575
822
  const [x0, x1, yTop, yBottom] = rect;
package/dist/context.d.ts CHANGED
@@ -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: AnnotationKind;
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 a horizontal line
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). */
package/dist/index.d.ts CHANGED
@@ -64,8 +64,8 @@ export { scaleTradingTime } from './tradingTimeScale.js';
64
64
  export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
65
65
  export { scaleBand } from './bandScale.js';
66
66
  export type { ScaleBand } from './bandScale.js';
67
- export { Region, Baseline, Marker } from './annotations.js';
68
- export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
67
+ export { Region, Baseline, Marker, Zone } from './annotations.js';
68
+ export type { RegionProps, BaselineProps, MarkerProps, ZoneProps, } from './annotations.js';
69
69
  export type { AnnotationKind, CreateSpec } from './context.js';
70
70
  export { YAxisIndicator, createLiveValue } from './indicators.js';
71
71
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
package/dist/index.js CHANGED
@@ -51,8 +51,9 @@ export { scaleTradingTime } from './tradingTimeScale.js';
51
51
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
52
52
  export { scaleBand } from './bandScale.js';
53
53
  // Annotations — user-authored marks in the turquoise register (distinct from the
54
- // data): a shaded span, a horizontal value line, a vertical x line.
55
- export { Region, Baseline, Marker } from './annotations.js';
54
+ // data): a shaded x span, a horizontal value line, a vertical x line, and a
55
+ // shaded y span (`<Zone>` value-axis classifications: AQI categories, HR zones).
56
+ export { Region, Baseline, Marker, Zone } from './annotations.js';
56
57
  // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
57
58
  // `createLiveValue` is the high-frequency, isolated-repaint update path.
58
59
  export { YAxisIndicator, createLiveValue } from './indicators.js';
package/dist/theme.d.ts CHANGED
@@ -179,20 +179,36 @@ export interface ChartTheme {
179
179
  readonly color: string;
180
180
  readonly fillOpacity: number;
181
181
  readonly depth: readonly [number, number, number];
182
+ /**
183
+ * Optional dash pattern for the register's **lines** — px on/off lengths,
184
+ * the same shape as {@link LineStyle.dash} (`[6, 4]` = 6 on, 4 off). Omit or
185
+ * `[]` for solid strokes. Applies to a marker's / baseline's line and to
186
+ * region + zone boundaries; fills are never dashed.
187
+ *
188
+ * Worth reaching for when marks share a plot with data lines: a *dashed*
189
+ * reference line reads as placed rather than measured, doing the "this isn't
190
+ * data" work that colour alone can't when the register hue is near a series
191
+ * hue. Set it per {@link roles | role} to dash one kind of mark only.
192
+ */
193
+ readonly dash?: readonly number[];
182
194
  /**
183
195
  * **Optional per-role overrides** — a small map from a role name to its
184
- * `color` (and optionally `fillOpacity`), so distinct marks can be styled
185
- * at once without splitting the whole register: a `<Baseline role="atm">`
186
- * green, a `<Marker role="ref">` in another hue, each still drawn through
196
+ * `color` (and optionally `fillOpacity` / `dash`), so distinct marks can be
197
+ * styled at once without splitting the whole register: a `<Baseline
198
+ * role="atm">` green, a `<Marker role="ref">` in another hue, a `<Zone
199
+ * role="good">` per band of a value-axis scale — each still drawn through
187
200
  * the shared {@link depth} ramp. A mark's `role` resolves
188
- * `roles[role] ?? { color, fillOpacity }` (an unknown/unset role is the
189
- * base register). Colour stays a **theme** concern — there is no per-mark
190
- * colour prop (the one-styling-channel discipline).
201
+ * `roles[role] ?? { color, fillOpacity, dash }` (an unknown/unset role is
202
+ * the base register). Colour stays a **theme** concern — there is no
203
+ * per-mark colour prop (the one-styling-channel discipline), which is why a
204
+ * *scale* of bands (AQI categories, HR zones) is a role map and not six
205
+ * colours at the call site.
191
206
  */
192
207
  readonly roles?: {
193
208
  readonly [role: string]: {
194
209
  readonly color: string;
195
210
  readonly fillOpacity?: number;
211
+ readonly dash?: readonly number[];
196
212
  };
197
213
  };
198
214
  };
@@ -372,6 +388,45 @@ export interface BarStyle {
372
388
  * already did for `highlight`.
373
389
  */
374
390
  readonly hover?: string;
391
+ /**
392
+ * The **threshold-band ladder** — ordered fills for a bar coloured *along its
393
+ * length* against `<BarChart thresholds>`: `bands[0]` up to the first
394
+ * threshold, `bands[1]` between the first and second, and so on. A ladder of
395
+ * `n` thresholds reads `n + 1` entries.
396
+ *
397
+ * This lives on `BarStyle` rather than as a `theme.bar.bands` sibling because
398
+ * `theme.bar` is a semantic **map** (`{ default, [semantic]: BarStyle }`) — a
399
+ * top-level key would collide with a role of that name. Per-role is also the
400
+ * more useful shape: `bar.default.bands` and `bar.capacity.bands` can differ,
401
+ * and the ladder resolves through the same `bar[semantic] ?? bar.default`
402
+ * lookup as every other bar colour.
403
+ *
404
+ * **Overridden by `<BarChart bandColors>`** at the call site. If neither
405
+ * resolves enough entries for the ladder, the bar falls back to its flat
406
+ * {@link fill} and (in dev) warns — a silently-unbanded bar is exactly the
407
+ * failure mode [PND-BANDBAR2] exists to remove.
408
+ *
409
+ * Read by the single-series `drawBars` path and by the `G === 1` stacked path
410
+ * (which is where `categories` and every horizontal bar live). A genuine
411
+ * **multi-group stack** ignores it and warns: banding a segment that is
412
+ * already one slice of a total has no defined meaning.
413
+ */
414
+ readonly bands?: readonly string[];
415
+ /**
416
+ * Stroke for a **selected** bar's outline, where the default is the bar's own
417
+ * resolved fill. The one selection cue that still works when the fill cannot
418
+ * change — a `binColors` bar keeps its own colour by design, so without this
419
+ * the alpha pop was the whole signal and nothing about it was themeable
420
+ * ([PND-CATEMPH]).
421
+ */
422
+ readonly selectedOutline?: string;
423
+ /**
424
+ * The alpha a hovered / selected bar pops to. **Default `1`** (the shipped
425
+ * behaviour). Previously the pop was hard-coded, so a theme could set the
426
+ * resting {@link opacity} floor but not the *difference* between resting and
427
+ * live — which is the part that reads as emphasis.
428
+ */
429
+ readonly emphasisOpacity?: number;
375
430
  }
376
431
  /**
377
432
  * The neutral default theme. `default` / `primary` match the M1 `LineChart`
package/dist/theme.js CHANGED
@@ -109,6 +109,11 @@ export const defaultTheme = {
109
109
  gap: 1,
110
110
  minWidth: 1,
111
111
  outlineWidth: 1.5,
112
+ // The default threshold ladder: the bar's own blue as the in-range band,
113
+ // then amber, then red. Three entries serves the common two-threshold
114
+ // ok/warning/alarm ladder out of the box; a longer `thresholds` needs a
115
+ // longer ladder from the theme or `bandColors`.
116
+ bands: ['#2563eb', '#e8a13c', '#d64545'],
112
117
  },
113
118
  secondary: {
114
119
  fill: '#e8836b',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.56.2",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.56.2",
42
- "pond-ts": "^0.56.2",
41
+ "@pond-ts/react": "^0.57.0",
42
+ "pond-ts": "^0.57.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {