@pond-ts/charts 0.59.0 → 0.61.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.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
@@ -0,0 +1,106 @@
1
+ import type { MouseEvent as ReactMouseEvent } from 'react';
2
+ /**
3
+ * What an axis hands its {@link AxisMouseHandler} — the raw mouse event, plus
4
+ * the **axis coordinate under the pointer**, which is the part a consumer
5
+ * cannot compute for itself (the scale lives inside the container).
6
+ */
7
+ export interface AxisMouseEvent {
8
+ /**
9
+ * The React mouse event, verbatim — `type` says which one fired
10
+ * (`'click'`, `'mousemove'`, `'contextmenu'`, …), and the modifier keys,
11
+ * `button`, `preventDefault()` and `stopPropagation()` are all the ordinary
12
+ * ones. **A single handler receives every mouse event on the strip**, so
13
+ * switch on `event.type` (or ignore the ones you don't want).
14
+ */
15
+ event: ReactMouseEvent<HTMLDivElement>;
16
+ /** Which axis fired — so one handler can serve both. */
17
+ axis: 'x' | 'y';
18
+ /**
19
+ * The axis's `id`, when it has one. A `<YAxis>` always does (it's required —
20
+ * charts link to it); an `<XAxis>` has none, so this is `undefined` there.
21
+ * To tell two stacked x-axes apart, close over the distinction at the call
22
+ * site (`onMouseEvent={(e) => onAxis('delta', e)}`).
23
+ */
24
+ id?: string | undefined;
25
+ /**
26
+ * The axis value under the pointer, **in the axis's own data units** — epoch
27
+ * ms on a time axis, the number on a value axis, the slot value on a
28
+ * categorical one. Read {@link label} for what the axis would *print* there.
29
+ *
30
+ * Not clamped to a tick — it is the continuous inverse of the pixel, so it
31
+ * lands between ticks. The one exception is a **categorical x-axis**, whose
32
+ * scale inverts to the nearest band **centre** (`i + 0.5`); a categorical
33
+ * *row* (horizontal bars on the y-axis) is a plain linear slot scale and does
34
+ * not snap, so `Math.floor(value)` is its slot index.
35
+ *
36
+ * On a `transform`ed x-axis this is the **underlying** value, not the derived
37
+ * unit — apply the same `transform.to` you passed the axis to get the unit
38
+ * its ticks read in.
39
+ */
40
+ value: number;
41
+ /**
42
+ * {@link value} formatted the way this axis reads it — the category name on a
43
+ * categorical axis, the axis's `format` (or the container's shared formatter)
44
+ * elsewhere.
45
+ *
46
+ * Precisely: it is the axis's **readout** channel, the one the cursor pill
47
+ * uses — so it always agrees with the pill at that pixel, and a container
48
+ * `cursorFormat` shapes it exactly as it shapes the pill. That is the
49
+ * documented precedence (`cursorFormat` → axis `format` → container), and it
50
+ * is the one case where `label` can read differently from the tick text: a
51
+ * chart with a precise `cursorFormat` over terse ticks gets the precise form
52
+ * here, which is the readout it asked for.
53
+ */
54
+ label: string;
55
+ }
56
+ /**
57
+ * A single handler for every mouse event on an axis strip — see
58
+ * {@link AxisMouseEvent}. Passed as `onMouseEvent` to `<XAxis>` / `<YAxis>`.
59
+ */
60
+ export type AxisMouseHandler = (info: AxisMouseEvent) => void;
61
+ /** The mouse props an axis strip spreads onto its root element. */
62
+ type AxisMouseProps = {
63
+ onClick?: (e: ReactMouseEvent<HTMLDivElement>) => void;
64
+ onDoubleClick?: (e: ReactMouseEvent<HTMLDivElement>) => void;
65
+ onContextMenu?: (e: ReactMouseEvent<HTMLDivElement>) => void;
66
+ onMouseDown?: (e: ReactMouseEvent<HTMLDivElement>) => void;
67
+ onMouseUp?: (e: ReactMouseEvent<HTMLDivElement>) => void;
68
+ onMouseMove?: (e: ReactMouseEvent<HTMLDivElement>) => void;
69
+ onMouseEnter?: (e: ReactMouseEvent<HTMLDivElement>) => void;
70
+ onMouseLeave?: (e: ReactMouseEvent<HTMLDivElement>) => void;
71
+ };
72
+ /**
73
+ * Build the mouse props for an axis strip: every mouse event routed to the one
74
+ * `onMouseEvent` handler, each carrying the axis coordinate `at` resolves from
75
+ * the pointer.
76
+ *
77
+ * With no handler this returns `{}` — **nothing is attached**, so an axis that
78
+ * doesn't opt in keeps costing nothing (no per-move callback, no listeners).
79
+ *
80
+ * `at` returns `null` when the pointer maps to no value — the transient render
81
+ * before a `<YAxis>` has a resolved scale — and the event is then dropped
82
+ * rather than reported at a made-up coordinate.
83
+ */
84
+ export declare function axisMouseProps(onMouseEvent: AxisMouseHandler | undefined, axis: 'x' | 'y', id: string | undefined, at: (event: ReactMouseEvent<HTMLDivElement>) => {
85
+ value: number;
86
+ label: string;
87
+ } | null): AxisMouseProps;
88
+ /**
89
+ * The pointer's position along an axis strip, in **strip-local pixels** — the
90
+ * coordinate the scale inverts. Read from the strip's own client rect
91
+ * (`currentTarget`, so it is the strip whichever tick label was hit), which is
92
+ * laid out flush with the plot on that dimension: the x strip carries the left
93
+ * gutter as a margin and is exactly `plotWidth` wide, and the y gutter is
94
+ * exactly the row's height.
95
+ *
96
+ * Clamped to **the scale's range, not the strip's box** — the two are not
97
+ * always the same. A row carrying a `labelPlacement="top"` axis reserves a
98
+ * header, so its y scales run `[height, topHeader]` while the gutter box still
99
+ * starts at 0; clamping to the box would invert the header band to values
100
+ * *above* the domain and report a coordinate the axis never draws. Passing the
101
+ * range means a `mouseleave` off the edge — or a press in that header — still
102
+ * reports a value the scale actually holds.
103
+ */
104
+ export declare function axisPointerPx(event: ReactMouseEvent<HTMLDivElement>, axis: 'x' | 'y', range: readonly [number, number]): number;
105
+ export {};
106
+ //# sourceMappingURL=axis-events.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Build the mouse props for an axis strip: every mouse event routed to the one
3
+ * `onMouseEvent` handler, each carrying the axis coordinate `at` resolves from
4
+ * the pointer.
5
+ *
6
+ * With no handler this returns `{}` — **nothing is attached**, so an axis that
7
+ * doesn't opt in keeps costing nothing (no per-move callback, no listeners).
8
+ *
9
+ * `at` returns `null` when the pointer maps to no value — the transient render
10
+ * before a `<YAxis>` has a resolved scale — and the event is then dropped
11
+ * rather than reported at a made-up coordinate.
12
+ */
13
+ export function axisMouseProps(onMouseEvent, axis, id, at) {
14
+ if (onMouseEvent === undefined)
15
+ return {};
16
+ const fire = (event) => {
17
+ const hit = at(event);
18
+ if (hit === null)
19
+ return;
20
+ onMouseEvent({ event, axis, id, value: hit.value, label: hit.label });
21
+ };
22
+ return {
23
+ onClick: fire,
24
+ onDoubleClick: fire,
25
+ onContextMenu: fire,
26
+ onMouseDown: fire,
27
+ onMouseUp: fire,
28
+ onMouseMove: fire,
29
+ onMouseEnter: fire,
30
+ onMouseLeave: fire,
31
+ };
32
+ }
33
+ /**
34
+ * The pointer's position along an axis strip, in **strip-local pixels** — the
35
+ * coordinate the scale inverts. Read from the strip's own client rect
36
+ * (`currentTarget`, so it is the strip whichever tick label was hit), which is
37
+ * laid out flush with the plot on that dimension: the x strip carries the left
38
+ * gutter as a margin and is exactly `plotWidth` wide, and the y gutter is
39
+ * exactly the row's height.
40
+ *
41
+ * Clamped to **the scale's range, not the strip's box** — the two are not
42
+ * always the same. A row carrying a `labelPlacement="top"` axis reserves a
43
+ * header, so its y scales run `[height, topHeader]` while the gutter box still
44
+ * starts at 0; clamping to the box would invert the header band to values
45
+ * *above* the domain and report a coordinate the axis never draws. Passing the
46
+ * range means a `mouseleave` off the edge — or a press in that header — still
47
+ * reports a value the scale actually holds.
48
+ */
49
+ export function axisPointerPx(event, axis, range) {
50
+ const rect = event.currentTarget.getBoundingClientRect();
51
+ const px = axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top;
52
+ const lo = Math.min(range[0], range[1]);
53
+ const hi = Math.max(range[0], range[1]);
54
+ return Math.max(lo, Math.min(hi, px));
55
+ }
56
+ //# sourceMappingURL=axis-events.js.map
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
@@ -31,6 +31,7 @@ export type { YAxisProps } from './YAxis.js';
31
31
  export { XAxis } from './XAxis.js';
32
32
  export type { XAxisProps } from './XAxis.js';
33
33
  export type { AxisTransform } from './derivedTicks.js';
34
+ export type { AxisMouseEvent, AxisMouseHandler } from './axis-events.js';
34
35
  export { TimeAxis } from './TimeAxis.js';
35
36
  export { CategoryAxis } from './CategoryAxis.js';
36
37
  export { HeatMap } from './HeatMap.js';
@@ -64,6 +65,9 @@ export type { LegendProps, LegendPlacement } from './Legend.js';
64
65
  export type { SwatchSpec, LegendItemInput } from './swatch.js';
65
66
  export { useChartLegend } from './useChartLegend.js';
66
67
  export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
68
+ export { useChartFrame } from './useChartFrame.js';
69
+ export type { ChartFrame, ChartFrameRow, ChartBands, ChartBand, } from './useChartFrame.js';
70
+ export type { ChartXScale } from './context.js';
67
71
  export { scaleTradingTime } from './tradingTimeScale.js';
68
72
  export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
69
73
  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