@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/YAxis.js CHANGED
@@ -17,7 +17,7 @@ const DEFAULT_TICK_COUNT = 5;
17
17
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
18
18
  * (default: the first axis).
19
19
  */
20
- export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
20
+ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
21
21
  const container = useContext(ContainerContext);
22
22
  if (container === null) {
23
23
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -29,7 +29,11 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
29
29
  const spec = useMemo(() => ({
30
30
  id,
31
31
  side,
32
- width,
32
+ // A hidden axis reserves no gutter — the row's slot placement reads this
33
+ // width, so zeroing it here is what gives the space back to the plot.
34
+ // Everything else about the spec is unchanged: the domain still resolves
35
+ // and layers still bind to it, which is the whole point of the prop.
36
+ width: hide ? 0 : width,
33
37
  scale,
34
38
  min,
35
39
  max,
@@ -43,6 +47,7 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
43
47
  id,
44
48
  side,
45
49
  width,
50
+ hide,
46
51
  scale,
47
52
  min,
48
53
  max,
@@ -65,6 +70,23 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, fo
65
70
  useEffect(() => {
66
71
  registerAxis(slot, spec);
67
72
  }, [registerAxis, slot, spec]);
73
+ // `hide`: everything above still runs — the axis is registered, so its scale
74
+ // exists and layers bind to it — and everything below (the gutter chrome)
75
+ // does not. Placed after the last hook so the early return can't change hook
76
+ // order when `hide` is toggled at runtime.
77
+ //
78
+ // It renders an **empty box at the reserved slot width**, not nothing. The
79
+ // container reserves each axis *column* at the widest across rows
80
+ // (`maxSlotWidths`), so a hidden axis sharing a column with a visible one in
81
+ // another row is still allotted that column's width — and drawing nothing
82
+ // there slides this row's plot left, out of line with its siblings and with
83
+ // the shared x-axis. When this axis is alone in its column the reservation is
84
+ // its own `width: 0`, the box is zero-wide, and the plot reclaims the space,
85
+ // which is the point of the prop. Both cases fall out of the same expression.
86
+ if (hide) {
87
+ const hiddenSlot = row.axisSlots.get(slot) ?? 0;
88
+ return hiddenSlot > 0 ? (_jsx("div", { "aria-hidden": "true", style: { flex: `0 0 ${hiddenSlot}px`, height: `${row.height}px` } })) : null;
89
+ }
68
90
  const { theme } = container;
69
91
  const yScale = row.yScales.get(id);
70
92
  // The auto-tick count — the row resolves it (explicit `tickCount` else
@@ -161,6 +161,80 @@ export interface BaselineProps {
161
161
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
162
162
  * Its label anchors at the left, at the line's height. */
163
163
  export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, role, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
164
+ export interface ZoneProps {
165
+ /** Lower bound in the linked y-axis's units. */
166
+ from: number;
167
+ /** Upper bound in the linked y-axis's units. `from`/`to` may arrive either way
168
+ * round (they're ordered here), and either may be **infinite** for an
169
+ * open-ended band (`to={Infinity}` — the AQI "Hazardous" tail, a
170
+ * `ZoneTime.openEnded` zone): the rect clamps to the plot. */
171
+ to: number;
172
+ /** Which `<YAxis>` (by id) to measure against; omit for the row's default axis. */
173
+ axis?: string;
174
+ /** Chip label, anchored at the band's vertical centre. **Omit for no label** —
175
+ * unlike `<Region>` a zone does *not* auto-label its bounds, because they're
176
+ * already legible on the y axis it spans (a region's x span isn't). The label
177
+ * worth showing is a **name** — `"Good"`, `"Z4 threshold"` — which only the
178
+ * caller has. */
179
+ label?: string;
180
+ /** Which side of the chart the label chip sits. **Default `left`.** */
181
+ labelSide?: 'left' | 'right';
182
+ /** Stable consumer id — a click reports it via `onSelectAnnotation`. Only
183
+ * meaningful with `selectable`. */
184
+ id?: string;
185
+ /** Controlled selection — brightens to the front (level 1). Ignored unless
186
+ * `selectable`. */
187
+ selected?: boolean;
188
+ /**
189
+ * Whether the band responds to hover + selection. **Default `false`** — the
190
+ * opposite of the rest of the family, and deliberately so: a zone spans the
191
+ * **full plot width**, and a zone *set* tiles the whole y range, so the pointer
192
+ * is always inside one. Interactive by default, they'd light up on every
193
+ * mousemove and their hit rects would swallow the plot's own clicks. A zone is
194
+ * background context first (level 3, pointer-transparent); opt in per band when
195
+ * a band is genuinely a thing to point at.
196
+ */
197
+ selectable?: boolean;
198
+ /** Theme **role** — colours this band from `theme.annotation.roles[role]` (its
199
+ * `color`, optionally `fillOpacity`), keeping the shared depth ramp. This is
200
+ * how a zone set gets its **semantic palette** (`good` green, `moderate`
201
+ * yellow, …): the scale lives in the theme, not at the call site. Omitted /
202
+ * unknown ⇒ the base annotation colour. */
203
+ role?: string;
204
+ /** Controlled hover (OR'd with pointer hover) — lets a legend row light the
205
+ * band remotely. Ignored unless `selectable`. */
206
+ hovered?: boolean;
207
+ /** Draw the horizontal **boundary lines** at `from`/`to`. **Default `false`** —
208
+ * again the opposite of `<Region>`, because zone sets are usually
209
+ * **contiguous**: every interior boundary is shared by two bands, so edges-on
210
+ * draws each one twice at double opacity. `true` outlines an isolated band (a
211
+ * target range). */
212
+ edges?: boolean;
213
+ }
214
+ /**
215
+ * A shaded band between two **y values**, spanning the full plot width — the
216
+ * y-axis counterpart of `<Region>`. The mark for a **classification of the value
217
+ * axis**: US EPA AQI categories, heart-rate / power zones, a control chart's
218
+ * spec limits, an SLO band.
219
+ *
220
+ * Being a `<Layers>` child it lives in its row and is scaled by that row's y
221
+ * axis — pass `axis` to pick one on a dual-axis row. Like every annotation it
222
+ * paints in the SVG overlay **above** the data canvas, so keep the fill light
223
+ * (the register's `fillOpacity`, ~0.1–0.2) and the trace reads cleanly through
224
+ * it. A zone set is a wash of colour behind the story, not a layer competing
225
+ * with it.
226
+ *
227
+ * Zones are **background context by default** (`selectable={false}`,
228
+ * `edges={false}`, no label) because that is what a tiled zone set is; see
229
+ * {@link ZoneProps.selectable} for why the family's usual defaults invert here.
230
+ * Colour comes from the theme's {@link ZoneProps.role | role} map, so a palette
231
+ * is a theme, not six call-site colours.
232
+ *
233
+ * Unlike the other marks a zone has **no `onChange`** — dragging zone edges
234
+ * (a zone editor) is a real feature but has no consumer yet; the band is
235
+ * declarative until one arrives.
236
+ */
237
+ export declare function Zone({ from, to, axis, label, labelSide, id, selected, selectable, hovered, role, edges, }: ZoneProps): import("react/jsx-runtime").JSX.Element | null;
164
238
  export interface RegionProps {
165
239
  /** Start x in axis units (time or value). */
166
240
  from: number;
@@ -6,7 +6,8 @@ import { useSlotKey } from './use-slot-key.js';
6
6
  /**
7
7
  * User-authored **annotations** — marks you place *on* a chart, in a register
8
8
  * deliberately distinct from the data: `<Region>` (a shaded x-span), `<Baseline>`
9
- * (a horizontal value line), and `<Marker>` (a vertical x line). All three render
9
+ * (a horizontal value line), `<Marker>` (a vertical x line), and `<Zone>` (a
10
+ * shaded y-span — `<Region>`'s counterpart on the value axis). All four render
10
11
  * in the theme's turquoise {@link ChartTheme.annotation} register so a placed mark
11
12
  * never reads as data ("the data stays foam; the marks you place are turquoise").
12
13
  *
@@ -101,7 +102,7 @@ const overlayStyle = {
101
102
  * Read the container + row frames an annotation needs (throw if misplaced), and
102
103
  * resolve the mark's annotation style for its optional `role`. A `role` recolors
103
104
  * *this* mark from the theme's `annotation.roles[role]` map (`color`, and
104
- * optionally `fillOpacity`) while keeping the shared depth ramp — so a smile can
105
+ * optionally `fillOpacity` / `dash`) while keeping the shared depth ramp — so a smile can
105
106
  * place a green ATM baseline, a neutral vertical, and a distinct marker at once
106
107
  * without splitting the whole register. An unknown / unset role falls back to
107
108
  * the base `annotation` register (`roles[role] ?? annotation`).
@@ -117,17 +118,23 @@ function useAnnotationFrame(name, role) {
117
118
  }
118
119
  const base = container.theme.annotation ?? DEFAULT_ANNOTATION;
119
120
  const roleStyle = role !== undefined ? base.roles?.[role] : undefined;
120
- // The role overrides only colour (+ optional fill); depth stays the shared
121
- // ramp, so selection / hover / edit levels read identically per role.
121
+ // The role overrides only colour (+ optional fill / dash); depth stays the
122
+ // shared ramp, so selection / hover / edit levels read identically per role.
122
123
  const ann = roleStyle
123
124
  ? {
124
125
  ...base,
125
126
  color: roleStyle.color,
126
127
  fillOpacity: roleStyle.fillOpacity ?? base.fillOpacity,
128
+ dash: roleStyle.dash ?? base.dash,
127
129
  }
128
130
  : base;
129
131
  return { container, row, ann };
130
132
  }
133
+ /** The register's dash as an SVG `stroke-dasharray`, or `undefined` for solid
134
+ * (an empty pattern means solid, matching `LineStyle.dash`). */
135
+ function dashArray(dash) {
136
+ return dash === undefined || dash.length === 0 ? undefined : dash.join(' ');
137
+ }
131
138
  /** Register this annotation with the container (so it can draw the mark's guide on
132
139
  * other rows, order regions, and serve snap targets), keyed by the caller's stable
133
140
  * per-instance slot key; unregister on unmount. `xs` should be memoised by the
@@ -504,7 +511,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
504
511
  // The staff (vertical line) hangs from the top of its flag — so a flag stacked
505
512
  // into a lower lane doesn't leave line poking above it. No label ⇒ full height.
506
513
  const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
507
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
514
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
508
515
  +container.xScale.invert(px)) }))] }), chipLabel && x >= 0 && x <= container.plotWidth && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
509
516
  top: `${FLAG_TOP + lane * LANE_H}px`,
510
517
  ...flagChipX(x, container.plotWidth),
@@ -552,7 +559,7 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
552
559
  const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
553
560
  // Handle pill near the right end (clears the left-anchored label).
554
561
  const handleX = w - 14;
555
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
562
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
556
563
  top: `${y}px`,
557
564
  [labelSide === 'right' ? 'right' : 'left']: '2px',
558
565
  // `center` rides on the line; `above` sits its bottom edge on the line.
@@ -570,6 +577,89 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
570
577
  }, children: fmt ? fmt(value) : String(value) }));
571
578
  })()] }));
572
579
  }
580
+ /**
581
+ * A shaded band between two **y values**, spanning the full plot width — the
582
+ * y-axis counterpart of `<Region>`. The mark for a **classification of the value
583
+ * axis**: US EPA AQI categories, heart-rate / power zones, a control chart's
584
+ * spec limits, an SLO band.
585
+ *
586
+ * Being a `<Layers>` child it lives in its row and is scaled by that row's y
587
+ * axis — pass `axis` to pick one on a dual-axis row. Like every annotation it
588
+ * paints in the SVG overlay **above** the data canvas, so keep the fill light
589
+ * (the register's `fillOpacity`, ~0.1–0.2) and the trace reads cleanly through
590
+ * it. A zone set is a wash of colour behind the story, not a layer competing
591
+ * with it.
592
+ *
593
+ * Zones are **background context by default** (`selectable={false}`,
594
+ * `edges={false}`, no label) because that is what a tiled zone set is; see
595
+ * {@link ZoneProps.selectable} for why the family's usual defaults invert here.
596
+ * Colour comes from the theme's {@link ZoneProps.role | role} map, so a palette
597
+ * is a theme, not six call-site colours.
598
+ *
599
+ * Unlike the other marks a zone has **no `onChange`** — dragging zone edges
600
+ * (a zone editor) is a real feature but has no consumer yet; the band is
601
+ * declarative until one arrives.
602
+ */
603
+ export function Zone({ from, to, axis, label, labelSide = 'left', id, selected = false, selectable = false, hovered, role, edges = false, }) {
604
+ const { container, row, ann } = useAnnotationFrame('Zone', role);
605
+ const selfKey = useSlotKey();
606
+ const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
607
+ // A horizontal band casts no vertical guide (like a baseline) — register with
608
+ // no xs, so it's tracked for ordering but offers no snap target.
609
+ const xs = useMemo(() => [], []);
610
+ useRegisterAnnotation(container, selfKey, id, row.rowKey, 'zone', xs, selected, selectable, false, // zones aren't editable — no single-annotation edit state
611
+ label ?? '', false);
612
+ // No select while a create tool is armed — the chart is in draw mode then.
613
+ const select = id !== undefined && container.creating === null
614
+ ? () => container.onSelectAnnotation?.(id)
615
+ : undefined;
616
+ const axisId = axis ?? row.defaultAxisId;
617
+ const yScale = row.yScales.get(axisId);
618
+ // The axis may not have resolved yet (a layer mounts before its <YAxis>); skip
619
+ // until its scale exists rather than guessing a domain.
620
+ if (yScale === undefined)
621
+ return null;
622
+ const h = row.height;
623
+ const w = container.plotWidth;
624
+ // A NaN bound has no position to draw at — cull rather than let it fall
625
+ // through the finite check below and silently become the domain's low end.
626
+ if (Number.isNaN(from) || Number.isNaN(to))
627
+ return null;
628
+ // Open-ended bounds resolve against the axis **domain** before scaling, not
629
+ // after: d3's interpolator is `a·(1−t) + b·t`, so an infinite `t` yields
630
+ // `0 · Infinity` = NaN rather than an off-plot pixel. Substituting the domain
631
+ // end is also exactly the intent — an open band reaches the plot edge.
632
+ const [d0, d1] = yScale.domain();
633
+ const dLo = Math.min(d0 ?? 0, d1 ?? 0);
634
+ const dHi = Math.max(d0 ?? 0, d1 ?? 0);
635
+ const bound = (v) => (Number.isFinite(v) ? v : v > 0 ? dHi : dLo);
636
+ // Order + clamp to the plot: a band may run past the axis domain (the AQI
637
+ // 151–200 band on an axis topping out at 120). Clamping keeps the rect inside
638
+ // its row; a band entirely outside culls.
639
+ const lo = Math.min(from, to);
640
+ const hi = Math.max(from, to);
641
+ const loY = yScale(bound(lo));
642
+ const hiY = yScale(bound(hi));
643
+ const top = Math.max(Math.min(loY, hiY), 0);
644
+ const bottom = Math.min(Math.max(loY, hiY), h);
645
+ if (bottom <= top)
646
+ return null;
647
+ const bandH = bottom - top;
648
+ // Edit is never on (no onChange) — the levels reduce to selected / hover /
649
+ // resting, and to a flat level 3 when the band is inert background.
650
+ const edgeOpacity = rampAt(ann.depth, lineLevel(selectable, false, hovering, selected));
651
+ const fillOpacity = ann.fillOpacity *
652
+ rampAt(FILL_MULT, bodyLevel(selectable, false, hovering, selected));
653
+ /** A boundary line — drawn only where the band has a *real*, in-plot edge. An
654
+ * open-ended bound has no boundary at all, and a bound the clamp cut off would
655
+ * otherwise draw its line on the plot border, reading as chrome. */
656
+ const boundary = (v, aty) => edges && Number.isFinite(v) && aty >= 0 && aty <= h ? (_jsx("line", { x1: 0, y1: aty, x2: w, y2: aty, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" })) : null;
657
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: h, style: overlayStyle, children: [_jsx("rect", { x: 0, y: top, width: w, height: bandH, fill: ann.color, opacity: fillOpacity }), boundary(lo, loY), boundary(hi, hiY), selectable && (_jsx(DragArea, { x: 0, y: top, w: w, h: bandH, cursor: "inherit", editable: false, onHover: reportHover, onSelect: select, onDrag: () => { } }))] }), label !== undefined && label !== '' && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
658
+ top: `${top + bandH / 2}px`,
659
+ [labelSide === 'right' ? 'right' : 'left']: '2px',
660
+ transform: 'translateY(-50%)',
661
+ }, children: label }))] }));
662
+ }
573
663
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
574
664
  * flies as a flag off the left edge. */
575
665
  export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, role, }) {
@@ -616,7 +706,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
616
706
  // even after the dragged edge crosses it — {@link orderRegion} then re-opens the
617
707
  // region the other way instead of dead-ending at zero width.
618
708
  const edgeRef = useRef(null);
619
- const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
709
+ const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, strokeDasharray: dashArray(ann.dash), shapeRendering: "crispEdges" }));
620
710
  return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: (px) => {
621
711
  dragRef.current = { from, to, startPx: px };
622
712
  }, onDrag: (px) => {
package/dist/bars.d.ts CHANGED
@@ -56,6 +56,84 @@ export declare function resolveBarBaseline(yScale: Scale): number;
56
56
  * separates columns visually without carving a dead channel out of the target.
57
57
  */
58
58
  export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
59
+ /**
60
+ * The value-space span `[lo, hi]` of **threshold band `k`** along a bar running
61
+ * from `base` to `v`, or `null` when the bar doesn't reach that band.
62
+ *
63
+ * A threshold ladder colours one bar **along its length** — neutral up to the
64
+ * first threshold, then warning, then alarm — so a long bar shows how far
65
+ * through the ladder it travelled rather than only which band it ended in. With
66
+ * `thresholds = [t0, t1]` there are three bands: `[0, t0)`, `[t0, t1)`,
67
+ * `[t1, ∞)`. Band `k` spans magnitudes `[thresholds[k-1] ?? 0, thresholds[k] ??
68
+ * ∞)`, each end clipped to the bar's own magnitude — so a bar that stops inside
69
+ * band 1 yields a truncated band 1 and `null` for band 2.
70
+ *
71
+ * **Breakpoints are absolute data values, not offsets from the baseline** — a
72
+ * `thresholds={[1, 2]}` ladder means "warning above 1, alarm above 2" in the
73
+ * axis's own units, which is what a threshold means everywhere else. They are
74
+ * matched on the **magnitude** and applied to whichever side of zero the bar
75
+ * is on, so a bar hanging below the baseline walks the same ladder downward
76
+ * and a ±3.5 diverging scale bands symmetrically without the caller supplying
77
+ * negative breakpoints. (An asymmetric ladder would need signed breakpoints;
78
+ * deferred until a consumer pulls — see [PND-BANDBAR2].)
79
+ *
80
+ * The painted span is then **clipped to what the bar actually draws**, which
81
+ * is what makes a domain that excludes zero behave: with `<YAxis min={10}>` a
82
+ * bar rests on 10, so a `[1, 2]` ladder leaves it entirely in the top band
83
+ * rather than banding at 11 and 12. Measuring the ladder from the *resolved
84
+ * baseline* instead would silently shift every breakpoint by the axis floor —
85
+ * exactly the class of quiet wrongness this feature exists to remove.
86
+ *
87
+ * Note this is **draw-only geometry**. Hit-testing still treats the bar as one
88
+ * target ({@link barSlotRect} / {@link barAt}), which is the whole reason this
89
+ * is a mark rather than the N-layer overpaint recipe it replaces: one bar keeps
90
+ * one hit region, one stable `mark`, and one legend row.
91
+ *
92
+ * `thresholds` is assumed ascending and finite — {@link normalizeThresholds}
93
+ * enforces that once at the prop boundary rather than per bar per frame.
94
+ */
95
+ export declare function bandSpan(base: number, v: number, thresholds: readonly number[], k: number): [lo: number, hi: number] | null;
96
+ /**
97
+ * A resolved threshold ladder: ascending `thresholds` (from
98
+ * {@link normalizeThresholds}) paired with the `colors` each band draws in,
99
+ * `colors[k]` for the band above `thresholds[k - 1]`. Assembled by `BarChart`
100
+ * from `<BarChart bandColors>` → {@link BarStyle.bands}, so — like
101
+ * {@link StackStyle} — the draw layer stays theme-free and unit-testable.
102
+ *
103
+ * `colors` is guaranteed `thresholds.length + 1` long by the time it reaches a
104
+ * draw path; a short ladder is resolved (and warned about) at the boundary.
105
+ */
106
+ export interface BandLadder {
107
+ readonly thresholds: readonly number[];
108
+ readonly colors: readonly string[];
109
+ }
110
+ /**
111
+ * Validate + freeze a caller's threshold ladder once, at the prop boundary.
112
+ * Returns the ascending, strictly-positive, finite breakpoints — or `null` when
113
+ * there is no usable ladder left, so the caller keeps the flat path.
114
+ *
115
+ * Sorting rather than rejecting an out-of-order ladder is deliberate — the
116
+ * bands are defined by their boundaries, so `[2, 1]` and `[1, 2]` describe the
117
+ * same three bands and there is no second reading to guess at.
118
+ *
119
+ * Three kinds of entry are **dropped**:
120
+ *
121
+ * - **non-finite** — would swallow every band above it;
122
+ * - **negative** — the ladder is walked on the *magnitude* and mirrored onto
123
+ * whichever side of zero the bar is on, so a negative breakpoint has no
124
+ * meaning. Left in, `[-2, -1]` silently clipped every lower band away and
125
+ * painted the whole bar in the final colour — a one-colour bar that looks
126
+ * deliberate (Codex adversarial review). Signed breakpoints are the
127
+ * asymmetric-ladder feature deferred in [PND-BANDBAR2], not this;
128
+ * - **zero** — band 0 already starts at zero, so a `0` breakpoint describes an
129
+ * empty band and shifts every colour by one.
130
+ *
131
+ * Dropping rather than throwing matches how the rest of this prop behaves
132
+ * (a short colour ladder degrades, it doesn't fail), and `BarChart` dev-warns
133
+ * whenever normalization removed anything — a silently-ignored breakpoint is
134
+ * the failure mode this whole feature exists to avoid.
135
+ */
136
+ export declare function normalizeThresholds(thresholds: readonly number[] | undefined): readonly number[] | null;
59
137
  /**
60
138
  * The narrowed selection / hover identity a **single-series** bar matches
61
139
  * against: the layer's series `id`, the sample's `key` (its `begin`), and — when
@@ -120,7 +198,7 @@ export interface BarMark {
120
198
  * repaint them one flat colour; per-bar-coloured layers draw every visible bar.
121
199
  * Returns {@link LayerDrawStats} for `onDrawStats`.
122
200
  */
123
- export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[]): LayerDrawStats;
201
+ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[], banding?: BandLadder): LayerDrawStats;
124
202
  /**
125
203
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
126
204
  * **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
@@ -222,6 +300,41 @@ export interface StackStyle {
222
300
  * falls back to the group fill.
223
301
  */
224
302
  readonly binFills?: readonly (string | undefined)[];
303
+ /**
304
+ * The **selected** segment's fill, and `hover` the pointer-over one — the
305
+ * three-step `fill → hover → highlight` emphasis {@link BarStyle} has always
306
+ * carried and this path used to ignore ([PND-CATEMPH]).
307
+ *
308
+ * **Only applied when there is no meaning-carrying colour to destroy**, i.e.
309
+ * when {@link binFills} is unset. A per-bin-coloured bar keeps its own colour
310
+ * and pops {@link emphasisOpacity} instead — swapping a zone-coloured or
311
+ * direction-coloured bar to one highlight hue would erase what the colour
312
+ * encodes, which is the one *design* exclusion rather than a path accident.
313
+ *
314
+ * The friction this closes wasn't the behaviour, which is defensible: it was
315
+ * that `theme.bar.hover` / `.highlight` were typed, settable, documented as
316
+ * the emphasis channel, and silently did nothing on the most common
317
+ * categorical chart. A theme author set them, saw no change, and had no way
318
+ * to tell whether they were wrong about the colour or about the mechanism.
319
+ */
320
+ readonly highlight?: string;
321
+ /** See {@link highlight}. Falls back to `highlight` when unset. */
322
+ readonly hover?: string;
323
+ /**
324
+ * Stroke for the selected segment's outline. Defaults to the segment's own
325
+ * resolved fill (the shipped behaviour). Set it to give the category path a
326
+ * themed selection cue that works even where the fill can't change — the
327
+ * `binFills` case, where the alpha pop is otherwise the only signal.
328
+ */
329
+ readonly selectedOutline?: string;
330
+ /**
331
+ * The alpha a hovered / selected segment pops to. **Default `1`** (the
332
+ * shipped behaviour). Lower it for a subtler emphasis on a dense stack —
333
+ * previously the pop was hard-coded and the only tunable was the resting
334
+ * {@link opacity}, so a theme could not adjust the *difference* between
335
+ * resting and live, only the floor.
336
+ */
337
+ readonly emphasisOpacity?: number;
225
338
  }
226
339
  /** The narrowed selection / hover identity a stacked segment matches against:
227
340
  * the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
@@ -235,14 +348,20 @@ export interface StackMark {
235
348
  }
236
349
  /**
237
350
  * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
238
- * stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
239
- * non-negative segments. For a **single-group** series (`G === 1` — the plain /
351
+ * stack it is `[minNegTotal, maxPosTotal]` each bin's positive segments summed
352
+ * upward and its negative segments summed downward, tracked separately
353
+ * ([PND-SIGNSTACK]). For a **single-group** series (`G === 1` — the plain /
240
354
  * categorical bar case) it spans the values' own `[min, max]`, so a **negative**
241
355
  * bar's floor is in the domain (segments below the baseline stay visible). `0` is
242
356
  * always pulled in so the bars rest on a visible baseline (the bar analog of
243
357
  * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
244
358
  * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
245
359
  * auto-fit for a horizontal one.
360
+ *
361
+ * The negative half is new: this used to sum only positives, matching a draw
362
+ * path that dropped negative segments outright. Both halves changed together —
363
+ * an extent that stopped at `0` below would clip the very segments the draw
364
+ * path now emits.
246
365
  */
247
366
  export declare function stackValueExtent(ss: StackedBarSeries): [number, number];
248
367
  /**
@@ -299,7 +418,7 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
299
418
  *
300
419
  * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
301
420
  */
302
- export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: StackMark | null, hover: StackMark | null): void;
421
+ export declare function drawStacks(ctx: CanvasRenderingContext2D, ss: StackedBarSeries, orientation: Orientation, xScale: Scale, yScale: Scale, style: StackStyle, gapPx: number, minSpanPx: number, seriesId: string | undefined, selection: StackMark | null, hover: StackMark | null, banding?: BandLadder): void;
303
422
  /**
304
423
  * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
305
424
  * segment whose rect contains the point, or `null`. The geometry is