@pond-ts/charts 0.41.0 → 0.43.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/XAxis.js CHANGED
@@ -8,6 +8,36 @@ const TICK_STRIP = 22;
8
8
  /** Extra height reserved for an axis `label` line. */
9
9
  const LABEL_STRIP = 16;
10
10
  const TICK_COUNT = 5;
11
+ /**
12
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
13
+ * every `stride`-th label (so a kept label has room), and ellipsize one that still
14
+ * overruns its space. `stride` grows with the longest label vs the per-category
15
+ * slot width, so a few short categories keep every full label and many long ones
16
+ * decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
17
+ * for placement; the exact metric is the browser's. Rotation is a later option.
18
+ */
19
+ function thinCategoryLabels(ticks, plotWidth, fontSize) {
20
+ const n = ticks.length;
21
+ const slot = plotWidth / n; // per-category width in px
22
+ // Before first layout `plotWidth` is 0 → `slot` is 0 and the stride/room math
23
+ // below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
24
+ // the ticks through untouched until a real width arrives.
25
+ if (!(slot > 0))
26
+ return [...ticks];
27
+ const charW = fontSize * 0.62; // ~average glyph advance
28
+ const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
29
+ const stride = Math.max(1, Math.ceil((longest * charW) / slot));
30
+ const room = Math.max(1, Math.floor((slot * stride) / charW));
31
+ const out = [];
32
+ for (let i = 0; i < n; i += stride) {
33
+ const s = ticks[i].label;
34
+ out.push({
35
+ x: ticks[i].x,
36
+ label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
37
+ });
38
+ }
39
+ return out;
40
+ }
11
41
  /**
12
42
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
13
43
  * child of {@link ChartContainer}, rendered as DOM chrome (crisp text,
@@ -39,7 +69,11 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
39
69
  // (a time specifier through the time scale, a number specifier through the
40
70
  // value scale); otherwise the container's shared formatter — the one the
41
71
  // cursor readout uses, so a tick and the cursor read identically.
42
- const fmt = format === undefined
72
+ const fmt =
73
+ // A category axis labels by name (the container's `formatTime` = the band
74
+ // scale's label lookup); a d3 number/time `format` can't name a category, so
75
+ // it's ignored here (customize the labels in the `categories` data instead).
76
+ format === undefined || xKind === 'category'
43
77
  ? formatTime
44
78
  : xKind === 'time'
45
79
  ? resolveTimeFormat(xScale, TICK_COUNT, format)
@@ -90,12 +124,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
90
124
  markerLanes.set(t.id, lane);
91
125
  }
92
126
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
93
- const placed = customTicks
127
+ const rawTicks = customTicks
94
128
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
95
129
  : xScale.ticks(TICK_COUNT).map((d) => ({
96
130
  x: xScale(d),
97
131
  label: fmt(+d),
98
132
  }));
133
+ // A category axis ticks once per category; thin + truncate its labels when they
134
+ // crowd (an explicit `customTicks` axis keeps its labels verbatim).
135
+ const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
136
+ ? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
137
+ : rawTicks;
99
138
  const onTop = side === 'top';
100
139
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
101
140
  // they line up with their tick-label neighbours (matches `labelOffset` below).
@@ -1,4 +1,4 @@
1
- import { type AnnotationSpec, type LabelPlacement } from './context.js';
1
+ import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from './context.js';
2
2
  /**
3
3
  * Lane placement for the **top-flag** labels (markers + regions). Returns, per
4
4
  * slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
@@ -16,6 +16,21 @@ import { type AnnotationSpec, type LabelPlacement } from './context.js';
16
16
  * label) so the static labels hold their lanes as it crosses them.
17
17
  */
18
18
  export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
19
+ /**
20
+ * Snap a dragged plot-pixel `px` to the nearest **guideline** within
21
+ * {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
22
+ * boundary** (a session collapse point). Returns the **axis** value to snap to,
23
+ * or `null` if none is near (the caller keeps the raw position). Excludes the
24
+ * dragging mark's own `key`, and reads the same registry the guides draw from,
25
+ * so a drag visibly clicks onto the lines you can see.
26
+ *
27
+ * At a disjoint boundary the close and the next open share a pixel, so the value
28
+ * depends on which side of it the pointer is on (see below). Nearest-pixel wins
29
+ * across both kinds of target, so an annotation sitting *exactly* on a boundary
30
+ * open ties and — processed first — takes it (its own guideline), which is the
31
+ * same instant the right-side heuristic would pick anyway.
32
+ */
33
+ export declare function snapToGuides(container: ContainerFrame, selfKey: symbol, px: number): number | null;
19
34
  /**
20
35
  * Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
21
36
  * the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
@@ -28,6 +43,27 @@ export declare function orderRegion(v: number, pivot: number): {
28
43
  from: number;
29
44
  to: number;
30
45
  };
46
+ /** The slice of a scale a rigid pixel-move needs: value → pixel and back.
47
+ * `invert` may return a `Date` (a d3 `scaleTime`) — the move coerces with `+`. */
48
+ interface InvertibleScale {
49
+ (value: number): number;
50
+ invert(pixel: number): number | Date;
51
+ }
52
+ /**
53
+ * Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
54
+ * the box moves rigidly in *pixel* space — each edge's pixel position shifts by
55
+ * the same `dpx`, then inverts back to an axis value.
56
+ *
57
+ * This is the move that stays correct on a **discontinuous** (trading-time) axis:
58
+ * a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
59
+ * when they sit in different gap-contexts, distorting the box as it crosses a
60
+ * collapsed gap. On a continuous (affine) scale it is identical to the value-delta
61
+ * move, so this is a no-op there.
62
+ */
63
+ export declare function moveRegionByPixels(scale: InvertibleScale, from: number, to: number, dpx: number): {
64
+ from: number;
65
+ to: number;
66
+ };
31
67
  export interface MarkerProps {
32
68
  /** x position in axis units — epoch ms on a time axis, the value on a value
33
69
  * axis. (The generalisation of the mockup's "time line": a mark at an x, time
@@ -157,4 +193,5 @@ export interface RegionProps {
157
193
  /** A shaded span over an x range — a lap, a zone, a selected interval. Its label
158
194
  * flies as a flag off the left edge. */
159
195
  export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
196
+ export {};
160
197
  //# sourceMappingURL=annotations.d.ts.map
@@ -257,13 +257,20 @@ function useAnnotationHover(container, id, hovered) {
257
257
  /** Pixel radius within which a drag snaps to a guideline (another mark's x). */
258
258
  const SNAP_PX = 6;
259
259
  /**
260
- * Snap a dragged plot-pixel `px` to the nearest **guideline** — another
261
- * annotation's x — within {@link SNAP_PX}. Returns that guideline's **axis** value
262
- * to snap to, or `null` if none is near (the caller keeps the raw position).
263
- * Excludes the dragging mark's own `key`, and reads the same registry the guides
264
- * draw from, so a drag visibly clicks onto the lines you can see.
260
+ * Snap a dragged plot-pixel `px` to the nearest **guideline** within
261
+ * {@link SNAP_PX} another annotation's x, **or** a trading-axis **disjoint
262
+ * boundary** (a session collapse point). Returns the **axis** value to snap to,
263
+ * or `null` if none is near (the caller keeps the raw position). Excludes the
264
+ * dragging mark's own `key`, and reads the same registry the guides draw from,
265
+ * so a drag visibly clicks onto the lines you can see.
266
+ *
267
+ * At a disjoint boundary the close and the next open share a pixel, so the value
268
+ * depends on which side of it the pointer is on (see below). Nearest-pixel wins
269
+ * across both kinds of target, so an annotation sitting *exactly* on a boundary
270
+ * open ties and — processed first — takes it (its own guideline), which is the
271
+ * same instant the right-side heuristic would pick anyway.
265
272
  */
266
- function snapToGuides(container, selfKey, px) {
273
+ export function snapToGuides(container, selfKey, px) {
267
274
  // The container's snap toggle gates guideline snapping — off ⇒ the drag keeps
268
275
  // its raw position (no clicking onto neighbours).
269
276
  if (!container.snap)
@@ -281,6 +288,23 @@ function snapToGuides(container, selfKey, px) {
281
288
  }
282
289
  }
283
290
  }
291
+ // Disjoint boundaries: on a trading-time axis a session close and the next
292
+ // open collapse to the **same pixel**, so a boundary is one snap target with
293
+ // two possible instants. Snap to the one on the side of the boundary the
294
+ // pointer is on — left of it → the pre-gap edge (the previous session's
295
+ // *close*, `clampDown` out of the gap); at/right of it → the post-gap *open*.
296
+ const disc = container.discontinuities;
297
+ if (disc?.boundaries) {
298
+ const [d0, d1] = container.timeRange;
299
+ for (const open of disc.boundaries(d0, d1)) {
300
+ const bpx = container.xScale(open);
301
+ const d = Math.abs(bpx - px);
302
+ if (d < bestDist) {
303
+ bestDist = d;
304
+ best = px < bpx ? disc.clampDown(open - 1) : open;
305
+ }
306
+ }
307
+ }
284
308
  return best;
285
309
  }
286
310
  /**
@@ -294,6 +318,23 @@ function snapToGuides(container, selfKey, px) {
294
318
  export function orderRegion(v, pivot) {
295
319
  return v <= pivot ? { from: v, to: pivot } : { from: pivot, to: v };
296
320
  }
321
+ /**
322
+ * Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
323
+ * the box moves rigidly in *pixel* space — each edge's pixel position shifts by
324
+ * the same `dpx`, then inverts back to an axis value.
325
+ *
326
+ * This is the move that stays correct on a **discontinuous** (trading-time) axis:
327
+ * a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
328
+ * when they sit in different gap-contexts, distorting the box as it crosses a
329
+ * collapsed gap. On a continuous (affine) scale it is identical to the value-delta
330
+ * move, so this is a no-op there.
331
+ */
332
+ export function moveRegionByPixels(scale, from, to, dpx) {
333
+ return {
334
+ from: +scale.invert(scale(from) + dpx),
335
+ to: +scale.invert(scale(to) + dpx),
336
+ };
337
+ }
297
338
  /** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
298
339
  * filled, no outline) with text in the annotation register. */
299
340
  function Chip({ theme, color, style, children, }) {
@@ -550,25 +591,27 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
550
591
  const s = dragRef.current;
551
592
  if (s === null)
552
593
  return;
553
- // Raw position = start + TOTAL pointer delta (snap-independent),
554
- // so dragging past SNAP_PX escapes a snapped edge.
555
- const delta = +container.xScale.invert(px) -
556
- +container.xScale.invert(s.startPx);
557
- let nf = s.from + delta;
558
- let nt = s.to + delta;
559
- // Snap whichever edge lands near a guideline, keeping the width —
560
- // output only, so the raw drift above can pull free of it.
561
- const sf = snapToGuides(container, selfKey, container.xScale(nf));
562
- const st = snapToGuides(container, selfKey, container.xScale(nt));
563
- if (sf !== null) {
564
- nt += sf - nf;
565
- nf = sf;
566
- }
567
- else if (st !== null) {
568
- nf += st - nt;
569
- nt = st;
570
- }
571
- onChange?.({ from: nf, to: nt });
594
+ // Rigid move by the TOTAL pointer *pixel* delta from the press
595
+ // origin each edge shifts the same pixels through the scale, so
596
+ // the box holds its shape even across a collapsed gap (a shared
597
+ // value-delta would drift the edges apart there).
598
+ const moved = moveRegionByPixels(container.xScale, s.from, s.to, px - s.startPx);
599
+ // Snap either edge to a guideline, shifting BOTH by the same pixel
600
+ // correction so the box keeps its width; snap-independent, so a
601
+ // drag past SNAP_PX releases cleanly.
602
+ const fpx = container.xScale(moved.from);
603
+ const tpx = container.xScale(moved.to);
604
+ const sf = snapToGuides(container, selfKey, fpx);
605
+ const st = snapToGuides(container, selfKey, tpx);
606
+ const d = sf !== null
607
+ ? container.xScale(sf) - fpx
608
+ : st !== null
609
+ ? container.xScale(st) - tpx
610
+ : 0;
611
+ onChange?.({
612
+ from: +container.xScale.invert(fpx + d),
613
+ to: +container.xScale.invert(tpx + d),
614
+ });
572
615
  } }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
573
616
  edgeRef.current = to; // the fixed pivot = the far edge
574
617
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A d3-scale-shaped **ordinal band scale** for a categorical x-axis — the
3
+ * transpose view's "columns on x" (categorical-axis RFC, Phase 1). It exposes the
4
+ * slice of the d3 scale surface `@pond-ts/charts` actually uses, so it drops in
5
+ * wherever the container's `xScale` goes (the same trick {@link TradingTimeScale}
6
+ * uses for a discontinuous time axis).
7
+ *
8
+ * **Numeric slot-index domain (the load-bearing choice).** The domain is
9
+ * `[0, n]` — one unit slot per category, slot `i` occupying `[i, i+1]` — *not* a
10
+ * `string[]`. So the pixel **mapping stays linear** and the container's numeric
11
+ * domain / auto-fit / `range` pipeline is untouched; a bar layer draws each
12
+ * category with the ordinary `barSpanPx(i, i+1, …)`. The category-ness lives in
13
+ * three methods only:
14
+ *
15
+ * - {@link ScaleBand.ticks} → the band **centres** (`i + 0.5`), one per category;
16
+ * - {@link ScaleBand.invert} → snaps a pixel to the nearest slot's centre (the
17
+ * categorical crosshair UX, and it keeps the `+xScale.invert` call sites happy);
18
+ * - {@link ScaleBand.label} → the category name at a slot (the axis formatter).
19
+ *
20
+ * The category labels are carried alongside for {@link ScaleBand.label}; the
21
+ * numeric domain is authoritative for geometry.
22
+ */
23
+ export interface ScaleBand {
24
+ /** Slot value (`i` = left edge, `i + 0.5` = centre) → pixel. Linear. */
25
+ (value: number): number;
26
+ /** Pixel → the nearest slot's **centre** value (`i + 0.5`), clamped to a real slot. */
27
+ invert(pixel: number): number;
28
+ /** One tick per category, at its band **centre** (`i + 0.5`). */
29
+ ticks(count?: number): number[];
30
+ /**
31
+ * A formatter mapping a slot value → the category name (the numeric `specifier`
32
+ * is ignored — a category axis labels by name, not a number format). Present so
33
+ * the scale is a safe drop-in wherever the container resolves a `tickFormat`.
34
+ */
35
+ tickFormat(count?: number, specifier?: string): (value: number) => string;
36
+ /** One slot's width in pixels (`|range| / slots`). The bar's `gap` insets within it. */
37
+ bandwidth(): number;
38
+ /** Slot pitch in pixels — same as {@link bandwidth} (padding is the bar's `gap`). */
39
+ step(): number;
40
+ /** The category name at slot value `v` (`categories[floor(v)]`), or `''`. */
41
+ label(value: number): string;
42
+ domain(): [number, number];
43
+ domain(next: readonly [number, number]): ScaleBand;
44
+ range(): [number, number];
45
+ range(next: readonly [number, number]): ScaleBand;
46
+ copy(): ScaleBand;
47
+ }
48
+ /**
49
+ * Build a {@link ScaleBand} over an ordered list of category names. Configure like
50
+ * a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
51
+ * container sets `domain([0, n])` from the layer's slot extent and `range([0,
52
+ * plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
53
+ * so the two must agree on count (`categories.length === n`), which they do when
54
+ * both come from the same layer's `xCategories()` / slot extent.
55
+ */
56
+ export declare function scaleBand(categories: readonly string[]): ScaleBand;
57
+ //# sourceMappingURL=bandScale.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { scaleLinear } from 'd3-scale';
2
+ /**
3
+ * Build a {@link ScaleBand} over an ordered list of category names. Configure like
4
+ * a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
5
+ * container sets `domain([0, n])` from the layer's slot extent and `range([0,
6
+ * plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
7
+ * so the two must agree on count (`categories.length === n`), which they do when
8
+ * both come from the same layer's `xCategories()` / slot extent.
9
+ */
10
+ export function scaleBand(categories) {
11
+ let domain = [0, Math.max(1, categories.length)];
12
+ let range = [0, 1];
13
+ const lin = scaleLinear();
14
+ const sync = () => lin.domain(domain).range(range);
15
+ /** Slot count = the domain width (the container sets `[0, n]`). */
16
+ const slots = () => Math.max(0, Math.round(domain[1] - domain[0]));
17
+ const scale = ((value) => {
18
+ sync();
19
+ return lin(value);
20
+ });
21
+ scale.invert = (pixel) => {
22
+ sync();
23
+ const v = lin.invert(pixel);
24
+ const n = slots();
25
+ if (n === 0)
26
+ return domain[0];
27
+ // Snap to the nearest slot's centre, clamped to a real slot.
28
+ const i = Math.min(n - 1, Math.max(0, Math.floor(v - domain[0])));
29
+ return domain[0] + i + 0.5;
30
+ };
31
+ scale.ticks = () => {
32
+ const n = slots();
33
+ const out = [];
34
+ for (let i = 0; i < n; i += 1)
35
+ out.push(domain[0] + i + 0.5);
36
+ return out;
37
+ };
38
+ scale.bandwidth = () => {
39
+ const n = slots();
40
+ if (n === 0)
41
+ return 0;
42
+ return Math.abs((range[1] - range[0]) / n);
43
+ };
44
+ scale.step = scale.bandwidth;
45
+ scale.label = (value) => {
46
+ const i = Math.floor(value - domain[0]);
47
+ return i >= 0 && i < categories.length ? categories[i] : '';
48
+ };
49
+ scale.tickFormat = () => scale.label;
50
+ function domainFn(next) {
51
+ if (next === undefined)
52
+ return [domain[0], domain[1]];
53
+ domain = [next[0], next[1]];
54
+ return scale;
55
+ }
56
+ scale.domain = domainFn;
57
+ function rangeFn(next) {
58
+ if (next === undefined)
59
+ return [range[0], range[1]];
60
+ range = [next[0], next[1]];
61
+ return scale;
62
+ }
63
+ scale.range = rangeFn;
64
+ scale.copy = () => scaleBand(categories).domain(domain).range(range);
65
+ return scale;
66
+ }
67
+ //# sourceMappingURL=bandScale.js.map
package/dist/bars.d.ts CHANGED
@@ -1,6 +1,14 @@
1
- import type { BarSeries } from './data.js';
1
+ import type { BarSeries, StackedBarSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BarStyle } from './theme.js';
4
+ /**
5
+ * Bar growth direction — the histogram orientation. `'vertical'` bars grow **up**
6
+ * from a value baseline, bins on the x axis (the column / time-bucket look);
7
+ * `'horizontal'` bars grow **right**, bins on the y axis (the band look, e.g.
8
+ * heart-rate zones). The stacked geometry below transposes on this alone — the
9
+ * {@link StackedBarSeries} data is identical for both.
10
+ */
11
+ export type Orientation = 'vertical' | 'horizontal';
4
12
  /**
5
13
  * The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
6
14
  * **widened to include `0`**, since a bar spans from its value to the baseline
@@ -49,7 +57,8 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
49
57
  * (inset by `gapPx`) from the resolved `baseline` to the value.
50
58
  *
51
59
  * A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
52
- * matching the current `selection` (same `begin` **and** the layer's own `label`)
60
+ * matching the current `selection` (same sample `key` **and** the layer's own
61
+ * series `id` — `seriesId`; a no-id layer passes `undefined` and never matches)
53
62
  * draws in the style's `highlight` colour **and outlined**, so a click reads back
54
63
  * on the canvas; a bar matching `hovered` draws in `highlight` **without** the
55
64
  * outline (a lighter "this bar is live" on pointer-over); all others use the flat
@@ -59,12 +68,12 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
59
68
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
60
69
  * allocation beyond the rect tuple.
61
70
  */
62
- export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, label: string, selection: {
71
+ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: {
63
72
  key: number;
64
- label: string;
73
+ id: string;
65
74
  } | null, hovered: {
66
75
  key: number;
67
- label: string;
76
+ id: string;
68
77
  } | null): void;
69
78
  /**
70
79
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
@@ -93,4 +102,103 @@ export declare function barIndexAtTime(cs: BarSeries, time: number): number;
93
102
  * series, so "first match" is unambiguous in practice.
94
103
  */
95
104
  export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
105
+ /**
106
+ * A resolved per-group stack style: `fills` aligned index-for-index to
107
+ * {@link StackedBarSeries.groups} (segment `g` uses `fills[g]`), plus the shared
108
+ * `opacity` (applied to every resting segment) and `outlineWidth` (the selected
109
+ * segment's stroke). Assembled by `BarChart` from the theme's `bar` style + the
110
+ * `colors` override, so the draw layer stays theme-free (unit-testable).
111
+ *
112
+ * There is no separate highlight colour: a hovered / selected segment pops by
113
+ * drawing its **own** `fill` at full opacity (and, when selected, an outline in
114
+ * that same colour). Colour-agnostic, so it reads correctly whatever palette the
115
+ * `colors` override supplies.
116
+ */
117
+ export interface StackStyle {
118
+ readonly fills: readonly string[];
119
+ readonly opacity: number;
120
+ readonly outlineWidth: number;
121
+ /**
122
+ * Optional **per-bin** fill override, aligned index-for-index to the bins
123
+ * (bin `b` uses `binFills[b]`), taking precedence over the per-group
124
+ * {@link fills} for that whole bin. This is the single-series band case —
125
+ * colour each bar by its category (heart-rate / power zones, value bands) —
126
+ * so it's normally paired with a `G === 1` stack. A `null`/`undefined` entry
127
+ * falls back to the group fill.
128
+ */
129
+ readonly binFills?: readonly (string | undefined)[];
130
+ }
131
+ /** The narrowed selection / hover identity a stacked segment matches against:
132
+ * the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
133
+ * When the series carries `marks` (the categorical axis), the match keys on the
134
+ * stable `mark` (the column name) instead of the `key` slot index. */
135
+ export interface StackMark {
136
+ readonly id: string;
137
+ readonly key: number;
138
+ readonly label: string;
139
+ readonly mark?: string;
140
+ }
141
+ /**
142
+ * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
143
+ * stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
144
+ * non-negative segments. For a **single-group** series (`G === 1` — the plain /
145
+ * categorical bar case) it spans the values' own `[min, max]`, so a **negative**
146
+ * bar's floor is in the domain (segments below the baseline stay visible). `0` is
147
+ * always pulled in so the bars rest on a visible baseline (the bar analog of
148
+ * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
149
+ * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
150
+ * auto-fit for a horizontal one.
151
+ */
152
+ export declare function stackValueExtent(ss: StackedBarSeries): [number, number];
153
+ /**
154
+ * The `[min, max]` extent of the **bin axis** — the first bin's `begin` to the
155
+ * last bin's `end` (the slots are ascending). `null` for an empty series. Feeds
156
+ * the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
157
+ */
158
+ export declare function stackBinExtent(ss: StackedBarSeries): [number, number] | null;
159
+ /**
160
+ * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
161
+ * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
162
+ * segments below it, in value units). `null` for a gap (see below). Transposes on
163
+ * `orientation`:
164
+ *
165
+ * - **vertical** — the bin span is horizontal (`barSpanPx` on `xScale`); the
166
+ * segment runs vertically from `yScale(cumBefore)` to `yScale(cumBefore + v)`.
167
+ * - **horizontal** — the bin span is vertical (`barSpanPx` on `yScale`); the
168
+ * segment runs horizontally from `xScale(cumBefore)` to `xScale(cumBefore + v)`.
169
+ *
170
+ * `null` for a **gap** — a non-finite, negative, **or zero** value: none of them
171
+ * draw (a zero segment has no extent), and each contributes nothing to the running
172
+ * total. `minSpanPx` floors the **bin** span (bar thickness); the value direction
173
+ * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
174
+ * and the hit rect are identical.
175
+ */
176
+ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number, orientation: Orientation, xScale: Scale, yScale: Scale, cumBefore: number, gapPx: number, minSpanPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
177
+ /**
178
+ * Fill every segment of every bin in `ss`, stacking each bin's groups from the
179
+ * value baseline outward (bottom → top vertical, left → right horizontal). A gap
180
+ * (non-finite, or a negative segment of a true multi-group stack) is skipped and
181
+ * adds nothing to the running total, so the segments above it close the space; a
182
+ * single-group series draws its negative bars below the baseline (see
183
+ * {@link segmentRect}). A segment matching the current
184
+ * `selection` (same series `id`, bin `key` **and** group `label`) draws in its
185
+ * group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
186
+ * without the outline; all others use the flat `fill`. `globalAlpha` carries the
187
+ * shared opacity and is restored.
188
+ *
189
+ * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
190
+ */
191
+ 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;
192
+ /**
193
+ * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
194
+ * segment whose rect contains the point, or `null`. The geometry is
195
+ * {@link segmentRect}, so the hit rect is exactly the drawn rect. The returned
196
+ * tuple is `[bin, group, begin, groupName, value]` for the chart to assemble a
197
+ * `SelectInfo` (it owns the colour). Orientation-agnostic — it reads `(px, py)`,
198
+ * so a horizontal histogram hit-tests the same way a vertical one does.
199
+ *
200
+ * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
201
+ * small; click / hover are cheap events).
202
+ */
203
+ export declare function stackAt(ss: StackedBarSeries, px: number, py: number, orientation: Orientation, xScale: Scale, yScale: Scale, gapPx: number, minSpanPx: number): [bin: number, group: number, begin: number, name: string, value: number] | null;
96
204
  //# sourceMappingURL=bars.d.ts.map