@pond-ts/charts 0.59.0 → 0.60.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/area.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { ChartSeries } from './data.js';
3
3
  import { type Scale, type TraceState } from './line.js';
4
+ import type { BandLadder } from './bars.js';
4
5
  import type { AreaStyle } from './theme.js';
5
6
  import type { LayerDrawStats } from './context.js';
6
7
  import { type GapMode } from './gaps.js';
@@ -69,7 +70,48 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
69
70
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
70
71
  * are collected by one O(N) walk ({@link collectGapEdges}).
71
72
  */
72
- export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): LayerDrawStats;
73
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption, banding?: BandLadder): LayerDrawStats;
74
+ /**
75
+ * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
76
+ * vertical **hard-stop gradient in pixel space**, a colour switch at every
77
+ * threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
78
+ * magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
79
+ * as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
80
+ * same currency `drawBars` takes.
81
+ *
82
+ * A gradient rather than one clipped redraw per band, and that is the
83
+ * load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
84
+ * themselves at every boundary with an antialiased seam, where a gradient
85
+ * draws the identical single path once and costs O(K) colour stops. It also
86
+ * bands the **outline for free** — `strokeStyle` takes the same gradient, so
87
+ * the value line switches hue exactly at a crossing, which no per-band clip
88
+ * can do without shearing the stroke.
89
+ *
90
+ * The ladder is walked on the **magnitude** and mirrored below zero, exactly
91
+ * as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
92
+ * zero side) from band `k + 1` (the away side). Whether "away from zero" is up
93
+ * or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
94
+ * positive and finite by construction), so a flipped axis bands correctly. A
95
+ * boundary with **no position** on the scale contributes no crossing — on a
96
+ * log axis the negative mirrors (and zero) simply don't exist, which is the
97
+ * right reading. A crossing **off the plot** clamps to the gradient's ends
98
+ * (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
99
+ * zoomed-in view honest: with every visible pixel inside one band, the clamp
100
+ * degenerates the other stops and the whole plot paints that band's colour.
101
+ *
102
+ * Falls back to the top band's flat colour when there is nothing to anchor on
103
+ * (no plot height, or no boundary with a position at all) — reachable only
104
+ * with a degenerate scale stub, since every real axis positions a positive
105
+ * finite value; any flat colour is equally (in)correct there, and the top
106
+ * band's is at least stable.
107
+ *
108
+ * Like the bar ladder, breakpoints are **absolute data values**, so the
109
+ * baseline plays no part here: an area resting on a non-zero floor still bands
110
+ * at the same heights as its neighbours — measuring from the resolved baseline
111
+ * instead would silently shift every breakpoint by the floor, the quiet
112
+ * wrongness [PND-BANDBAR2] exists to remove.
113
+ */
114
+ export declare function buildBandGradient(ctx: CanvasRenderingContext2D, yScale: Scale, plotHeight: number, banding: BandLadder): CanvasGradient | string;
73
115
  /**
74
116
  * **Is the pointer inside this area?** The filled-region counterpart of
75
117
  * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
package/dist/area.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
- import { baselinePxFromScale, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
2
+ import { baselinePxFromScale, plotExtentOf, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
3
3
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
4
4
  import { cullChartSeries } from './culling.js';
5
5
  import { decimateM4Cached } from './decimate.js';
@@ -155,7 +155,7 @@ export function areaExtent(cs, baseline) {
155
155
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
156
156
  * are collected by one O(N) walk ({@link collectGapEdges}).
157
157
  */
158
- export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true) {
158
+ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true, banding) {
159
159
  const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
160
160
  const baselinePx = yScale(baselineValue);
161
161
  // The fill gradient's vertical extent is computed from the **full** series (a
@@ -168,7 +168,13 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
168
168
  // instead of re-walking O(N) — the mountain@1M ceiling the bench profile
169
169
  // flagged. A `'none'` bridge only fills interior gaps with interpolated values
170
170
  // that stay within the finite extent, so the plain extent is exact for it too.
171
- const fill = buildGradient(ctx, columnFiniteExtent(cs.y, cs.length), yScale, baselinePx, style);
171
+ //
172
+ // **Banded** ([PND-BANDAREA]): one hard-stop pixel-space gradient carries the
173
+ // whole ladder for the fill AND the outline — see {@link buildBandGradient}
174
+ // for why a gradient rather than one clipped redraw per band.
175
+ const fill = banding !== undefined
176
+ ? buildBandGradient(ctx, yScale, plotExtentOf(ctx, xScale, yScale).height, banding)
177
+ : buildGradient(ctx, columnFiniteExtent(cs.y, cs.length), yScale, baselinePx, style);
172
178
  // Clip `cs` to what draws. **Decimated** (linear curve, `decimate !== false`):
173
179
  // cull to the visible slice, then the same {@link decimateM4} pre-pass shrinks
174
180
  // the fill + outline + gap-bridge work to O(plot width) once dense (the §2.2
@@ -237,14 +243,17 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
237
243
  ctx.fill();
238
244
  ctx.restore();
239
245
  // The outline on top: the area's top edge as a line (breaks at the same gaps
240
- // as the fill), at full opacity over the graded fill.
246
+ // as the fill), at full opacity over the graded fill. Banded, it strokes with
247
+ // the same hard-stop gradient the fill used, so the line switches hue exactly
248
+ // where it crosses a threshold — the whole point of the ladder is that the
249
+ // reader sees *where* the value sits, and the edge is the value.
241
250
  ctx.save();
242
251
  ctx.beginPath();
243
252
  if (outline !== null)
244
253
  outline(ys);
245
254
  else
246
255
  strokeAffinePolyline(ctx, cs.x, ys, ax, ay);
247
- ctx.strokeStyle = style.color;
256
+ ctx.strokeStyle = banding !== undefined ? fill : style.color;
248
257
  ctx.lineWidth = style.width;
249
258
  ctx.stroke();
250
259
  ctx.restore();
@@ -345,6 +354,114 @@ function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
345
354
  }
346
355
  return grad;
347
356
  }
357
+ /**
358
+ * The banded fill + stroke for `<AreaChart thresholds>` ([PND-BANDAREA]): one
359
+ * vertical **hard-stop gradient in pixel space**, a colour switch at every
360
+ * threshold crossing — `colors[0]` between `-t0` and `+t0`, `colors[k]` over
361
+ * magnitudes `[t(k-1), tk)` on both sides of zero. `thresholds`/`colors` arrive
362
+ * as a resolved {@link BandLadder} (ascending, positive, `n + 1` colours), the
363
+ * same currency `drawBars` takes.
364
+ *
365
+ * A gradient rather than one clipped redraw per band, and that is the
366
+ * load-bearing choice: K + 1 clipped passes walk the path K + 1 times and meet
367
+ * themselves at every boundary with an antialiased seam, where a gradient
368
+ * draws the identical single path once and costs O(K) colour stops. It also
369
+ * bands the **outline for free** — `strokeStyle` takes the same gradient, so
370
+ * the value line switches hue exactly at a crossing, which no per-band clip
371
+ * can do without shearing the stroke.
372
+ *
373
+ * The ladder is walked on the **magnitude** and mirrored below zero, exactly
374
+ * as `bandSpan` does for a bar: the boundary at `±tk` separates band `k` (the
375
+ * zero side) from band `k + 1` (the away side). Whether "away from zero" is up
376
+ * or down the canvas is probed from the scale itself (`t0` vs `t0 + 1`, both
377
+ * positive and finite by construction), so a flipped axis bands correctly. A
378
+ * boundary with **no position** on the scale contributes no crossing — on a
379
+ * log axis the negative mirrors (and zero) simply don't exist, which is the
380
+ * right reading. A crossing **off the plot** clamps to the gradient's ends
381
+ * (a real canvas throws on stops outside `[0, 1]`), which is also what makes a
382
+ * zoomed-in view honest: with every visible pixel inside one band, the clamp
383
+ * degenerates the other stops and the whole plot paints that band's colour.
384
+ *
385
+ * Falls back to the top band's flat colour when there is nothing to anchor on
386
+ * (no plot height, or no boundary with a position at all) — reachable only
387
+ * with a degenerate scale stub, since every real axis positions a positive
388
+ * finite value; any flat colour is equally (in)correct there, and the top
389
+ * band's is at least stable.
390
+ *
391
+ * Like the bar ladder, breakpoints are **absolute data values**, so the
392
+ * baseline plays no part here: an area resting on a non-zero floor still bands
393
+ * at the same heights as its neighbours — measuring from the resolved baseline
394
+ * instead would silently shift every breakpoint by the floor, the quiet
395
+ * wrongness [PND-BANDBAR2] exists to remove.
396
+ */
397
+ export function buildBandGradient(ctx, yScale, plotHeight, banding) {
398
+ const { thresholds, colors } = banding;
399
+ const fallback = colors[colors.length - 1];
400
+ // Guards NaN too — `!(x > 0)`, not `x <= 0`.
401
+ if (!(plotHeight > 0))
402
+ return fallback;
403
+ // Axis direction: does value increase toward smaller pixels (the canvas
404
+ // norm)? Probed on the ladder's own first breakpoint — positive and finite
405
+ // by construction, so it has a position on every axis kind (linear, log,
406
+ // symlog). Non-finite or equal probes default to the norm.
407
+ const pA = yScale(thresholds[0]);
408
+ const pB = yScale(thresholds[0] + 1);
409
+ const higherValueAtSmallerPx = !(Number.isFinite(pA) &&
410
+ Number.isFinite(pB) &&
411
+ pB > pA);
412
+ const crossings = [];
413
+ for (let k = 0; k < thresholds.length; k += 1) {
414
+ const zeroSide = colors[k];
415
+ const awaySide = colors[k + 1];
416
+ for (const sign of [1, -1]) {
417
+ const v = sign * thresholds[k];
418
+ const px = yScale(v);
419
+ if (!Number.isFinite(px))
420
+ continue; // no position — no crossing
421
+ const awayAbove = sign > 0 === higherValueAtSmallerPx;
422
+ crossings.push(awayAbove
423
+ ? { px, above: awaySide, below: zeroSide, k, sign }
424
+ : { px, above: zeroSide, below: awaySide, k, sign });
425
+ }
426
+ }
427
+ if (crossings.length === 0)
428
+ return fallback;
429
+ // Sort by pixel; same-pixel same-sign crossings (a duplicate breakpoint's
430
+ // empty band, or distinct breakpoints collapsed by an extreme zoom) order
431
+ // **away-band-outermost**: the away side's colour first coming from the
432
+ // away direction, the zero side's first coming from zero. That makes the
433
+ // walk below telescope — the seed reads the true outermost band and the
434
+ // last stop at the pixel is the true inner colour, with the skipped bands
435
+ // as zero-width ghosts in between — instead of seeding one band short and
436
+ // blending across the region below. Bars skip an empty band the same way
437
+ // (`bandSpanInto` clips it to nothing); a same-pixel *opposite-sign* pair
438
+ // (a folded scale) has no defined order and keeps insertion order.
439
+ crossings.sort((a, b) => {
440
+ if (a.px !== b.px)
441
+ return a.px - b.px;
442
+ if (a.sign !== b.sign)
443
+ return 0;
444
+ const awayFirst = a.sign > 0 === higherValueAtSmallerPx;
445
+ return awayFirst ? b.k - a.k : a.k - b.k;
446
+ });
447
+ const grad = ctx.createLinearGradient(0, 0, 0, plotHeight);
448
+ const offsetOf = (px) => {
449
+ const o = px / plotHeight;
450
+ return o < 0 ? 0 : o > 1 ? 1 : o;
451
+ };
452
+ // Each crossing is a hard stop: two stops at one offset, old colour then
453
+ // new. The region above the first crossing seeds the walk; clamped
454
+ // off-plot crossings collapse to zero-height regions at the ends, leaving
455
+ // the visible span in the band it actually occupies.
456
+ grad.addColorStop(0, crossings[0].above);
457
+ for (const c of crossings) {
458
+ const off = offsetOf(c.px);
459
+ grad.addColorStop(off, c.above);
460
+ grad.addColorStop(off, c.below);
461
+ }
462
+ grad.addColorStop(1, crossings[crossings.length - 1].below);
463
+ return grad;
464
+ }
348
465
  /**
349
466
  * **Is the pointer inside this area?** The filled-region counterpart of
350
467
  * `traceHitIndex` ([PND-TRACESEL]) — returns the nearest sample's index as
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { ScaleContinuousNumeric, ScaleLinear, ScaleTime } from 'd3-scale';
2
+ import type { ScaleContinuousNumeric, ScaleLinear, ScaleLogarithmic, ScaleSymLog, ScaleTime } from 'd3-scale';
3
3
  import type { ChartTheme } from './theme.js';
4
4
  import type { AxisFormat, CursorFormat } from './format.js';
5
5
  import type { LegendItemSpec } from './swatch.js';
@@ -25,6 +25,19 @@ export interface LabelPlacement {
25
25
  readonly lane: number;
26
26
  readonly label: string | null;
27
27
  }
28
+ /**
29
+ * The container's shared x→pixel scale, in every kind it can resolve to. All
30
+ * five are callable (`value → px`) and expose `invert` / `ticks` /
31
+ * `tickFormat`, which is the whole surface the library — and a consumer
32
+ * reading the frame ({@link ChartFrame.xScale}) — uses; that shared shape is
33
+ * what lets a trading-time or ordinal axis drop in where a linear one went.
34
+ *
35
+ * Named (rather than written inline on {@link ContainerFrame.xScale}) because
36
+ * {@link useChartFrame} publishes it: a consumer positioning DOM chrome over
37
+ * the plot needs to be able to *write down the type* of the scale it maps
38
+ * through.
39
+ */
40
+ export type ChartXScale = ScaleTime<number, number> | ScaleLinear<number, number> | ScaleLogarithmic<number, number> | ScaleSymLog<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
28
41
  export interface ContainerFrame {
29
42
  readonly timeRange: readonly [number, number];
30
43
  readonly width: number;
@@ -355,7 +368,16 @@ export interface ContainerFrame {
355
368
  * (axis labels, gridlines, cursor pill) reads durations without any consumer
356
369
  * knowing about the mode.
357
370
  */
358
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
371
+ readonly xScale: ChartXScale;
372
+ /**
373
+ * Is the x scale **logarithmic** (`log` or `symlog`)?
374
+ *
375
+ * Detected structurally rather than threaded from the prop: `base()` exists
376
+ * on d3's log scales and on no other continuous scale, which is the same test
377
+ * `yticks.ts` already uses on the y side. Read by the viewport gestures,
378
+ * which must do their arithmetic in log space (see `ViewportOptions`).
379
+ */
380
+ readonly xIsLog: boolean;
359
381
  /**
360
382
  * The discontinuity provider backing a **trading-time** x axis, if one was
361
383
  * supplied to the container — closed-market time (weekends, holidays,
@@ -1655,6 +1677,17 @@ export interface AxisSpec {
1655
1677
  */
1656
1678
  export interface RowFrame {
1657
1679
  readonly height: number;
1680
+ /**
1681
+ * The plot's **top inset** within the row's box, in px — the header band
1682
+ * reserved when any axis in the row draws a `labelPlacement="top"` title,
1683
+ * and `0` when none does. The y-scales' range is `[height, topInset]`, so
1684
+ * the drawable plot is `topInset … height`.
1685
+ *
1686
+ * Carried on the frame (rather than staying a local in `ChartRow`) because
1687
+ * {@link useChartFrame} publishes it — without it a consumer placing an
1688
+ * overlay inside the plot would silently sit under a top axis title.
1689
+ */
1690
+ readonly topInset: number;
1658
1691
  readonly yScales: ReadonlyMap<string, YScale>;
1659
1692
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
1660
1693
  * against its scale) — used by both the tick labels and the cursor readout, so
package/dist/format.d.ts CHANGED
@@ -77,7 +77,7 @@ interface Tickable {
77
77
  * render blank for almost every real number. A linear scale's `tickFormat`
78
78
  * applies the specifier to whatever it is handed, which is what every consumer
79
79
  * of this function actually wants; the axis's own tick *thinning* is handled by
80
- * `yTickValues`, not here.
80
+ * `tickValues`, not here.
81
81
  *
82
82
  * **A symlog scale's precision comes from its knee, not its span** ([PND-SYMLOG]).
83
83
  * `scaleSymlog.tickFormat` is `linearish`, so it derives precision from the
package/dist/format.js CHANGED
@@ -24,7 +24,7 @@ import { scaleLinear } from 'd3-scale';
24
24
  * render blank for almost every real number. A linear scale's `tickFormat`
25
25
  * applies the specifier to whatever it is handed, which is what every consumer
26
26
  * of this function actually wants; the axis's own tick *thinning* is handled by
27
- * `yTickValues`, not here.
27
+ * `tickValues`, not here.
28
28
  *
29
29
  * **A symlog scale's precision comes from its knee, not its span** ([PND-SYMLOG]).
30
30
  * `scaleSymlog.tickFormat` is `linearish`, so it derives precision from the
package/dist/index.d.ts CHANGED
@@ -64,6 +64,9 @@ export type { LegendProps, LegendPlacement } from './Legend.js';
64
64
  export type { SwatchSpec, LegendItemInput } from './swatch.js';
65
65
  export { useChartLegend } from './useChartLegend.js';
66
66
  export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
67
+ export { useChartFrame } from './useChartFrame.js';
68
+ export type { ChartFrame, ChartFrameRow, ChartBands, ChartBand, } from './useChartFrame.js';
69
+ export type { ChartXScale } from './context.js';
67
70
  export { scaleTradingTime } from './tradingTimeScale.js';
68
71
  export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
69
72
  export { scaleBand } from './bandScale.js';
package/dist/index.js CHANGED
@@ -52,6 +52,12 @@ export { Legend } from './Legend.js';
52
52
  // consumers who design their own key (horizontal strips, ticker-compare,
53
53
  // values-in-the-legend).
54
54
  export { useChartLegend } from './useChartLegend.js';
55
+ // The resolved plot geometry — the plot rect, the axis gutters, the shared x
56
+ // scale, a row's y scales, and (on a category axis) the ordinal slot edges.
57
+ // What a consumer aligning DOM chrome to the plot would otherwise re-derive
58
+ // by mirroring the library's own gutter arithmetic — a duplicate that drifts
59
+ // silently the moment the library changes how a gutter is sized.
60
+ export { useChartFrame } from './useChartFrame.js';
55
61
  export { scaleTradingTime } from './tradingTimeScale.js';
56
62
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
57
63
  export { scaleBand } from './bandScale.js';
package/dist/theme.d.ts CHANGED
@@ -720,6 +720,28 @@ export interface AreaStyle {
720
720
  /** Ink for a swept window's emphasised portion (edge + fill).
721
721
  * **Omitted ⇒ the area keeps its own colours and only strengthens.** */
722
722
  readonly spanColor?: string;
723
+ /**
724
+ * The **threshold-band ladder** — ordered fills for an area coloured *along
725
+ * its height* against `<AreaChart thresholds>`: `bands[0]` up to the first
726
+ * threshold, `bands[1]` between the first and second, and so on. A ladder of
727
+ * `n` thresholds reads `n + 1` entries. The fill **and** the outline take
728
+ * the band hues (one hard-stop gradient in pixel space), and the grade to
729
+ * transparent is dropped: the fade encoded distance-from-the-baseline, which
730
+ * is exactly what the ladder now states discretely — two encodings of one
731
+ * thing would fight.
732
+ *
733
+ * Lives on `AreaStyle` for {@link BarStyle.bands}' reason: `theme.area` is a
734
+ * semantic **map**, so a top-level key would collide with a role of that
735
+ * name — and per-role is the more useful shape (`area.default.bands` and a
736
+ * capacity role's ladder can differ).
737
+ *
738
+ * **Overridden by `<AreaChart bandColors>`** at the call site. If neither
739
+ * resolves enough entries for the ladder, the shortfall falls back to the
740
+ * flat {@link fill} and (in dev) warns — the same contract as the bar
741
+ * ladder, because a silently-unbanded chart is the failure mode the feature
742
+ * exists to remove.
743
+ */
744
+ readonly bands?: readonly string[];
723
745
  }
724
746
  /**
725
747
  * A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
package/dist/theme.js CHANGED
@@ -78,6 +78,9 @@ export const defaultTheme = {
78
78
  selectedFillOpacity: 0.55,
79
79
  dimmedOpacity: 0.32,
80
80
  spanColor: '#3F5BE0',
81
+ // The same ok → warning → alarm ladder `bar.default.bands` carries, so a
82
+ // banded area and a banded bar over one dataset read as one system.
83
+ bands: ['#2A9D8F', '#e8a13c', '#d64545'],
81
84
  },
82
85
  in: { color: '#0284c7', width: 1.5, fill: '#0284c7', fillOpacity: 0.3 },
83
86
  out: { color: '#e8836b', width: 1.5, fill: '#e8836b', fillOpacity: 0.3 },
@@ -0,0 +1,30 @@
1
+ import { type BandLadder } from './bars.js';
2
+ /**
3
+ * Resolve a component's `thresholds` / `bandColors` props against its theme
4
+ * role's band ramp into a {@link BandLadder} — or `undefined` when there is no
5
+ * usable ladder, so the caller keeps its flat path.
6
+ *
7
+ * Extracted from `<BarChart>`'s [PND-BANDBAR2] block verbatim when
8
+ * `<AreaChart thresholds>` arrived ([PND-BANDAREA]): the resolution rules and
9
+ * every dev warning are one contract across banded marks, differing only in
10
+ * the component named by the warning text.
11
+ *
12
+ * Resolved once here rather than per mark per frame: normalize the breakpoints
13
+ * (sort, drop non-finite / non-positive), then pair them with `bandColors` →
14
+ * the role's `bands`. Everything that can go wrong with the pairing is a
15
+ * *silent* wrong-looking chart, so each case dev-warns — this feature exists
16
+ * because a quietly-unbanded mark was the workaround's failure mode.
17
+ *
18
+ * The two array props are **value-compared** rather than identity-compared:
19
+ * `thresholds={[1, 2]}` inline is the documented usage and the shape every
20
+ * story and doc example uses — and a fresh array each render would rebuild
21
+ * the ladder, hence the caller's layer entry, hence a `registerLayer` call
22
+ * **every render**. That is a repaint treadmill, not just a noisy warning.
23
+ * The same value-compare-on-registration reasoning `<YAxis ticks>` applies.
24
+ *
25
+ * A short colour supply pads with `styleFill` (the role's flat fill) so the
26
+ * draw path can index freely; `undefined` comes back only when there are no
27
+ * usable breakpoints or no colours at all.
28
+ */
29
+ export declare function useBandLadder(component: 'BarChart' | 'AreaChart', thresholds: readonly number[] | undefined, bandColors: readonly string[] | undefined, styleBands: readonly string[] | undefined, styleFill: string): BandLadder | undefined;
30
+ //# sourceMappingURL=use-band-ladder.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { useMemo } from 'react';
2
+ import { normalizeThresholds } from './bars.js';
3
+ import { isDev } from './dev.js';
4
+ /**
5
+ * Resolve a component's `thresholds` / `bandColors` props against its theme
6
+ * role's band ramp into a {@link BandLadder} — or `undefined` when there is no
7
+ * usable ladder, so the caller keeps its flat path.
8
+ *
9
+ * Extracted from `<BarChart>`'s [PND-BANDBAR2] block verbatim when
10
+ * `<AreaChart thresholds>` arrived ([PND-BANDAREA]): the resolution rules and
11
+ * every dev warning are one contract across banded marks, differing only in
12
+ * the component named by the warning text.
13
+ *
14
+ * Resolved once here rather than per mark per frame: normalize the breakpoints
15
+ * (sort, drop non-finite / non-positive), then pair them with `bandColors` →
16
+ * the role's `bands`. Everything that can go wrong with the pairing is a
17
+ * *silent* wrong-looking chart, so each case dev-warns — this feature exists
18
+ * because a quietly-unbanded mark was the workaround's failure mode.
19
+ *
20
+ * The two array props are **value-compared** rather than identity-compared:
21
+ * `thresholds={[1, 2]}` inline is the documented usage and the shape every
22
+ * story and doc example uses — and a fresh array each render would rebuild
23
+ * the ladder, hence the caller's layer entry, hence a `registerLayer` call
24
+ * **every render**. That is a repaint treadmill, not just a noisy warning.
25
+ * The same value-compare-on-registration reasoning `<YAxis ticks>` applies.
26
+ *
27
+ * A short colour supply pads with `styleFill` (the role's flat fill) so the
28
+ * draw path can index freely; `undefined` comes back only when there are no
29
+ * usable breakpoints or no colours at all.
30
+ */
31
+ export function useBandLadder(component, thresholds, bandColors, styleBands, styleFill) {
32
+ const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
33
+ const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
34
+ return useMemo(() => {
35
+ const steps = normalizeThresholds(thresholds);
36
+ if (steps === null) {
37
+ if (isDev && thresholds !== undefined && thresholds.length > 0) {
38
+ console.warn(`<${component} thresholds>: no usable breakpoints, so no banding ` +
39
+ 'was applied — each must be finite and greater than zero. The ' +
40
+ 'chart draws in the flat fill.');
41
+ }
42
+ return undefined;
43
+ }
44
+ // Some, but not all, entries dropped. Silently banding on a subset of what
45
+ // the caller wrote is exactly the class of quiet wrongness this feature is
46
+ // meant to remove, so say so.
47
+ if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
48
+ console.warn(`<${component} thresholds>: dropped ${thresholds.length - steps.length} ` +
49
+ 'breakpoint(s) that were not finite and greater than zero. The ' +
50
+ 'ladder is walked on the magnitude and mirrored onto whichever side ' +
51
+ `of zero the value is on, so a negative breakpoint has no meaning; ` +
52
+ `banding on [${steps.join(', ')}].`);
53
+ }
54
+ const want = steps.length + 1;
55
+ const supplied = bandColors ?? styleBands;
56
+ if (supplied === undefined || supplied.length === 0) {
57
+ if (isDev) {
58
+ console.warn(`<${component} thresholds>: ${steps.length} breakpoint(s) need ` +
59
+ `${want} band colours, but neither \`bandColors\` nor the theme ` +
60
+ `role’s \`bands\` supplies any. The chart draws in the flat fill.`);
61
+ }
62
+ return undefined;
63
+ }
64
+ if (supplied.length < want && isDev) {
65
+ console.warn(`<${component} thresholds>: ${steps.length} breakpoint(s) need ` +
66
+ `${want} band colours but only ${supplied.length} were supplied; ` +
67
+ 'bands above the last colour fall back to the flat fill.');
68
+ }
69
+ // Pad a short ladder with the flat fill so the draw path can index freely.
70
+ const resolved = supplied.length >= want
71
+ ? supplied.slice(0, want)
72
+ : [
73
+ ...supplied,
74
+ ...Array.from({ length: want - supplied.length }, () => styleFill),
75
+ ];
76
+ return { thresholds: steps, colors: resolved };
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
78
+ // `bandColorKey` are the value-compared stand-ins for the array props.
79
+ }, [component, thresholdKey, bandColorKey, styleBands, styleFill]);
80
+ }
81
+ //# sourceMappingURL=use-band-ladder.js.map
@@ -0,0 +1,122 @@
1
+ import { type ChartXScale, type YScale } from './context.js';
2
+ /**
3
+ * One ordinal slot on a category axis — its pixel span within the plot, its
4
+ * centre (where a mark is drawn and a tick is labelled), and its name.
5
+ *
6
+ * `x0`/`x1`/`center` are **plot-relative** (`0 … plot.width`); add
7
+ * {@link ChartFrame.plot}`.x` for container-relative DOM placement.
8
+ */
9
+ export interface ChartBand {
10
+ /** The slot's left edge in px. */
11
+ readonly x0: number;
12
+ /** The slot's right edge in px. `x1 - x0` is {@link ChartBands.pitch}. */
13
+ readonly x1: number;
14
+ /** The slot's centre in px — where a bar centres and a tick labels. */
15
+ readonly center: number;
16
+ /** The category name at this slot. */
17
+ readonly label: string;
18
+ }
19
+ /**
20
+ * The ordinal slot geometry of a `'category'` x axis — `null` on a `'time'`
21
+ * or `'value'` axis, which has no slots.
22
+ *
23
+ * **The pitch is not `plot.width / count`.** `<ChartContainer maxBandWidth>`
24
+ * caps it and `bandAlign` places the resulting narrower block within the
25
+ * plot, so the packed band run can be inset from both plot edges. Reading
26
+ * `pitch` and `at(i)` rather than recomputing is the difference between
27
+ * chrome that tracks that packing and chrome that ignores it.
28
+ */
29
+ export interface ChartBands {
30
+ /** Number of slots — the category count. */
31
+ readonly count: number;
32
+ /** One slot's width in px (the pitch; slots are contiguous and equal). */
33
+ readonly pitch: number;
34
+ /** The ordered category names, index-aligned with the slots. */
35
+ readonly labels: readonly string[];
36
+ /** The slot at `index`, or `null` when `index` is not a real slot. */
37
+ at(index: number): ChartBand | null;
38
+ }
39
+ /**
40
+ * The **row-scoped** half of the frame: y geometry and the row's value
41
+ * scales. `null` on {@link ChartFrame.row} when the hook is called outside a
42
+ * `<ChartRow>`.
43
+ */
44
+ export interface ChartFrameRow {
45
+ /**
46
+ * The plot's top inset within the row's box in px — the band reserved by a
47
+ * `labelPlacement="top"` axis title, `0` when no axis draws one. An overlay
48
+ * that ignores it sits under the title.
49
+ */
50
+ readonly topInset: number;
51
+ /**
52
+ * The plot's drawable height in px, below {@link topInset}. The row's own
53
+ * `height` prop is `topInset + height`, and the y-scales' pixel range is
54
+ * `[topInset + height, topInset]` (inverted — pixels grow downward).
55
+ */
56
+ readonly height: number;
57
+ /**
58
+ * One value→pixel scale per `<YAxis id>`, each mapping into
59
+ * `[topInset + height, topInset]`. A row with no explicit `<YAxis>` has one
60
+ * entry under the implicit default id.
61
+ */
62
+ readonly yScales: ReadonlyMap<string, YScale>;
63
+ /** Which gutter each axis id sits in — so chrome hugs the right edge. */
64
+ readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
65
+ }
66
+ /** The resolved geometry of a chart, as published by {@link useChartFrame}. */
67
+ export interface ChartFrame {
68
+ /**
69
+ * The plot's **x** geometry in px, relative to the container's own box:
70
+ * `x` is the left gutter (where the plot starts) and `width` is the plot's
71
+ * width after both gutters. Shared by every row.
72
+ */
73
+ readonly plot: {
74
+ readonly x: number;
75
+ readonly width: number;
76
+ };
77
+ /**
78
+ * The reserved axis gutters in px — how far the plot is inset on each side.
79
+ * `left` equals {@link plot}`.x`; both are published because chrome above
80
+ * the plot pads by `left` while chrome sized to the container subtracts
81
+ * both.
82
+ */
83
+ readonly gutters: {
84
+ readonly left: number;
85
+ readonly right: number;
86
+ };
87
+ /**
88
+ * The shared x→pixel scale, mapping into `[0, plot.width]`. Callable
89
+ * (`value → px`) with `invert` / `ticks` / `tickFormat`, whichever kind the
90
+ * container resolved (see {@link ChartXScale}).
91
+ *
92
+ * **Not the same thing as `<ChartContainer xScale>`**, which is a string
93
+ * naming how a *value* axis spaces itself (`'linear' | 'log' | 'symlog'`).
94
+ * This is the built scale object that choice — along with the data's kind,
95
+ * `origin`, `discontinuities` and `categories` — resolves to.
96
+ */
97
+ readonly xScale: ChartXScale;
98
+ /**
99
+ * Which **kind** of x axis resolved: `'time'`, `'value'` or `'category'`.
100
+ * `'category'` is exactly when {@link bands} is non-null.
101
+ *
102
+ * Distinct from `<ChartContainer xScale>` again: that picks the spacing
103
+ * *within* a value axis, this says whether the axis is a value axis at all.
104
+ */
105
+ readonly xKind: 'time' | 'value' | 'category';
106
+ /** Ordinal slot geometry on a `'category'` axis; `null` otherwise. */
107
+ readonly bands: ChartBands | null;
108
+ /** Row-scoped y geometry — `null` outside a `<ChartRow>`. */
109
+ readonly row: ChartFrameRow | null;
110
+ }
111
+ /**
112
+ * Read the container's resolved plot geometry — the plot rect, the axis
113
+ * gutters, the shared x scale, the row's y scales, and (on a category axis)
114
+ * the ordinal slot edges. See the module docblock for the x/y split, the
115
+ * placement-scoped `row` half, and which box each pixel value is relative to.
116
+ *
117
+ * Must be called under a `<ChartContainer>`; throws otherwise. To render the
118
+ * markup *outside* the chart's box, portal it out (`createPortal`) — context
119
+ * flows through portals.
120
+ */
121
+ export declare function useChartFrame(): ChartFrame;
122
+ //# sourceMappingURL=useChartFrame.d.ts.map