@pond-ts/charts 0.58.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/range.d.ts CHANGED
@@ -10,6 +10,19 @@
10
10
  * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
11
  * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
12
  * stays visible and the bar never flips inside-out.
13
+ *
14
+ * `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
15
+ * slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
16
+ * is a *relative* inset, so on its own bar width is always `slot - gap` and
17
+ * fattens with the slot. Two independent sizes — slots spreading to fill the
18
+ * plot, ink pinned to N px — is what makes a measure comparable **between**
19
+ * panes, since a bar that widens with its pane reads as a different weight of
20
+ * the same thing. Expressing that with the relative knob alone requires
21
+ * predicting the slot width and back-solving the gap, which re-derives this
22
+ * function's arithmetic in consumer code.
23
+ *
24
+ * `minWidthPx` still wins: a cap below the floor yields the floor, so the two
25
+ * bounds can never invert the rect.
13
26
  */
14
- export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number): [number, number];
27
+ export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number, maxWidthPx?: number): [number, number];
15
28
  //# sourceMappingURL=range.d.ts.map
package/dist/range.js CHANGED
@@ -10,15 +10,36 @@
10
10
  * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
11
  * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
12
  * stays visible and the bar never flips inside-out.
13
+ *
14
+ * `maxWidthPx` caps the **ink** and is applied *after* the inset, centred in the
15
+ * slot ([PND-BARWIDTH]). It is the missing half of the width vocabulary: `gapPx`
16
+ * is a *relative* inset, so on its own bar width is always `slot - gap` and
17
+ * fattens with the slot. Two independent sizes — slots spreading to fill the
18
+ * plot, ink pinned to N px — is what makes a measure comparable **between**
19
+ * panes, since a bar that widens with its pane reads as a different weight of
20
+ * the same thing. Expressing that with the relative knob alone requires
21
+ * predicting the slot width and back-solving the gap, which re-derives this
22
+ * function's arithmetic in consumer code.
23
+ *
24
+ * `minWidthPx` still wins: a cap below the floor yields the floor, so the two
25
+ * bounds can never invert the rect.
13
26
  */
14
- export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1) {
27
+ export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1, maxWidthPx) {
15
28
  const a = xScale(beginMs);
16
29
  const b = xScale(endMs);
17
30
  const lo = Math.min(a, b);
18
31
  const hi = Math.max(a, b);
19
32
  const inset = gapPx / 2;
20
- const x0 = lo + inset;
21
- const x1 = hi - inset;
33
+ let x0 = lo + inset;
34
+ let x1 = hi - inset;
35
+ // The cap reads on the INSET span, not the raw slot, so `gap` keeps its
36
+ // meaning as the minimum breathing room: a bar is never wider than the gap
37
+ // allows, and never wider than the cap, whichever binds first.
38
+ if (maxWidthPx !== undefined && maxWidthPx > 0 && x1 - x0 > maxWidthPx) {
39
+ const mid = (lo + hi) / 2;
40
+ x0 = mid - maxWidthPx / 2;
41
+ x1 = mid + maxWidthPx / 2;
42
+ }
22
43
  if (x1 - x0 >= minWidthPx)
23
44
  return [x0, x1];
24
45
  const mid = (lo + hi) / 2;
package/dist/theme.d.ts CHANGED
@@ -296,10 +296,14 @@ export interface ChartTheme {
296
296
  *
297
297
  * So the two values here are the ones with no canvas counterpart: the row
298
298
  * **band** tints. Everything else resolves from tokens that already exist
299
- * and are per-metric where they should be — a selected fill takes
300
- * {@link BarStyle.highlight}, a dimmed one {@link BarStyle.dimmed} — so a
301
- * consumer who themes their bars gets a coherent list without theming it
302
- * twice.
299
+ * and are per-metric where they should be — a selected glyph fill takes
300
+ * `bar[as].highlight` and a dimmed one `bar[as].dimmed` — so a consumer who
301
+ * themes their bars gets a coherent list without theming it twice.
302
+ *
303
+ * **To be unambiguous, because this reads as a list of fields on this
304
+ * block:** `highlight` / `dimmed` are **`BarStyle`** tokens, resolved through
305
+ * `theme.bar[as]`. They are not fields of `theme.list` and there is no
306
+ * `list.dimmed` — this register carries exactly the five values below.
303
307
  *
304
308
  * **The rail is deliberately NOT per-metric.** There is one rail per row
305
309
  * and a row may carry several metrics, so it cannot resolve through
@@ -716,6 +720,28 @@ export interface AreaStyle {
716
720
  /** Ink for a swept window's emphasised portion (edge + fill).
717
721
  * **Omitted ⇒ the area keeps its own colours and only strengthens.** */
718
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[];
719
745
  }
720
746
  /**
721
747
  * A resolved bar style: the flat `fill` (scaled by `opacity`, 0–1) plus the
@@ -730,6 +756,28 @@ export interface BarStyle {
730
756
  readonly highlight: string;
731
757
  readonly gap: number;
732
758
  readonly minWidth: number;
759
+ /**
760
+ * Cap on a bar's **ink** width in px, applied after the `gap` inset and
761
+ * centred in the slot ([PND-BARWIDTH]). **Omitted ⇒ uncapped** — a bar is
762
+ * `slot - gap` wide, as it always was.
763
+ *
764
+ * It is the missing half of the width vocabulary, and the reason it cannot be
765
+ * spelled with `gap` alone: `gap` is a *relative* inset, so bar width tracks
766
+ * the slot and fattens as the plot widens. A **fixed** ink width is what makes
767
+ * a measure comparable *between* panes — bars that widen with their pane read
768
+ * as different weights of the same thing. Wanting both (slots spreading to
769
+ * fill, ink pinned) needs two independent knobs; with one, a consumer has to
770
+ * predict the slot width and back-solve the gap, re-deriving pond's own layout
771
+ * arithmetic in their code.
772
+ *
773
+ * Pairs with `<ChartContainer maxBandWidth>`, which caps the **slot**: that
774
+ * one decides how far the bars spread, this one how wide the ink is inside
775
+ * whatever slot results. `minWidth` still wins if the two would invert.
776
+ *
777
+ * `<BarChart maxBarWidth>` overrides this per layer, the same relationship
778
+ * `gap` has.
779
+ */
780
+ readonly maxWidth?: number;
733
781
  readonly outlineWidth: number;
734
782
  /**
735
783
  * Optional distinct **hover** fill, so a bar can read a three-step emphasis —
@@ -757,6 +805,11 @@ export interface BarStyle {
757
805
  * both states so a red/green volume bar keeps its meaning while live —
758
806
  * the one *design* exclusion rather than a path consequence.
759
807
  *
808
+ * **Scope note: that `binColors` exclusion is about the LIVE states only.**
809
+ * It does not carry over to {@link dimmed}, which *replaces* a per-bar fill
810
+ * on an unselected bar — see that token, which spells out the asymmetry and
811
+ * why emphasis preserves a per-bar colour while recession suppresses it.
812
+ *
760
813
  * The **decimated** dense-bar pass also draws the flat fill only, as it
761
814
  * already did for `highlight`.
762
815
  */
@@ -859,6 +912,29 @@ export interface BarStyle {
859
912
  * component, and drifted immediately — one consumer had three charts using
860
913
  * `color-mix` at 22%, 28% and 30% for the same concept, in the same week, for
861
914
  * no reason. One theme value fixes that permanently.
915
+ *
916
+ * **It OVERRIDES a per-bar fill, unlike the live states.** This is the one
917
+ * place `dimmed` and {@link hover} deliberately disagree, and the asymmetry
918
+ * is easy to read the wrong way round:
919
+ *
920
+ * - **{@link binColors} / {@link binFills}:** an unselected bar paints
921
+ * `dimmed`, discarding its own colour. (Hover and selection do the
922
+ * opposite — they keep the per-bar colour and pop the alpha, so a
923
+ * red/green volume bar stays red/green while live.)
924
+ * - **{@link bands} / thresholds:** an unselected banded bar draws **flat**
925
+ * in `dimmed`, discarding the ladder entirely rather than dimming each
926
+ * band.
927
+ * - **A multi-group stack** resolves per group through
928
+ * {@link StackStyle.dimmedFills} first, falling back to this flat value —
929
+ * a stack dimmed to one colour stops reading as a stack.
930
+ *
931
+ * The rule behind all three: a per-bar or per-band colour encodes *what the
932
+ * value is*, and a receded bar's whole job is to stop competing over that.
933
+ * Emphasis preserves meaning; recession suppresses it. So a chart that keeps
934
+ * `binColors` or `thresholds` for reasons unrelated to selection still gets
935
+ * a visible de-emphasis for free, and does **not** need to dim inside its own
936
+ * colour arrays. (Asked by a consumer who reasonably generalized `hover`'s
937
+ * `binColors` exclusion to this token; the exclusion is live-states-only.)
862
938
  */
863
939
  readonly dimmed?: string;
864
940
  }
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
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `useChartFrame()` — **the resolved plot geometry, published**.
3
+ *
4
+ * A consumer whose chrome has to line up with the plot (a per-slot header
5
+ * table above it, a column summary strip below it, a card pinned over one
6
+ * band, a colour ramp keyed to the plot's own scale) needs the numbers the
7
+ * container already resolved: where the plot starts, how wide it is, and the
8
+ * scales that map data to pixels inside it. Before this hook none of that was
9
+ * reachable, so consumers re-derived it — pin every axis gutter to a fixed
10
+ * width so it stops depending on label content, measure the outer box,
11
+ * subtract, and re-implement the band packing.
12
+ *
13
+ * **That duplicate is not merely verbose, it is wrong over time.** It holds
14
+ * only until the library changes how a gutter is sized or how bands are
15
+ * packed, at which point the consumer's chrome slides out of alignment with
16
+ * the plot it labels — with no type error and no failing test. Reading the
17
+ * frame converts a silent drift hazard into a version-checked API.
18
+ *
19
+ * ## The x / y split is the library's own
20
+ *
21
+ * The shape mirrors the architecture rather than flattening it: **the
22
+ * container owns x** (one shared scale, so every row's plot left-aligns under
23
+ * one time axis) and **rows own y** (row-local data, one scale per axis id).
24
+ * So {@link ChartFrame.plot} carries x only, and y lives on
25
+ * {@link ChartFrame.row} — which is `null` when the hook is called outside a
26
+ * `<ChartRow>`.
27
+ *
28
+ * That `null` is the point. The common case (a header strip above the plot,
29
+ * a sibling of the rows) genuinely has no y geometry, and the alternative —
30
+ * reporting `height: 0` — is the same silent-misalignment failure this hook
31
+ * exists to remove. A consumer that needs y must be inside a row, and the
32
+ * type says so.
33
+ *
34
+ * ## Scope follows placement
35
+ *
36
+ * Exactly as {@link useChartLegend} does: at the container level you get the
37
+ * shared x frame and `row: null`; inside a `<ChartRow>` you additionally get
38
+ * that row's y scales. No prop selects the scope — placement does.
39
+ *
40
+ * ## Pixel origins
41
+ *
42
+ * Two different boxes, because the DOM has two:
43
+ *
44
+ * - `plot.x` / `plot.width` are relative to the **container's** box, so a
45
+ * `<div>` sibling of the rows pads by `plot.x` to align.
46
+ * - `xScale(v)` and `bands.at(i)` are relative to the **plot**, i.e. `0 …
47
+ * plot.width` — the coordinate system the canvas draws in. Add `plot.x` to
48
+ * place DOM chrome in container space.
49
+ * - `row.topInset` / `row.height` are relative to the **row's** box.
50
+ *
51
+ * @example Align a per-slot header strip above a categorical plot
52
+ * ```tsx
53
+ * function SlotHeader() {
54
+ * const { plot, bands } = useChartFrame();
55
+ * if (bands === null) return null;
56
+ * return (
57
+ * <div style={{ position: 'relative', height: 22, marginLeft: plot.x, width: plot.width }}>
58
+ * {bands.labels.map((label, i) => {
59
+ * const b = bands.at(i)!;
60
+ * return (
61
+ * <div key={label} style={{ position: 'absolute', left: b.x0, width: b.x1 - b.x0 }}>
62
+ * {label}
63
+ * </div>
64
+ * );
65
+ * })}
66
+ * </div>
67
+ * );
68
+ * }
69
+ *
70
+ * <ChartContainer categories={tickers} width="auto">
71
+ * <SlotHeader />
72
+ * <ChartRow height={200}>…</ChartRow>
73
+ * </ChartContainer>
74
+ * ```
75
+ *
76
+ * @packageDocumentation
77
+ */
78
+ import { useContext, useMemo } from 'react';
79
+ import { ContainerContext, RowContext, } from './context.js';
80
+ /** Whether the container resolved an ordinal scale (which alone carries `label`). */
81
+ function asBandScale(scale, kind) {
82
+ return kind === 'category' ? scale : null;
83
+ }
84
+ /**
85
+ * Read the container's resolved plot geometry — the plot rect, the axis
86
+ * gutters, the shared x scale, the row's y scales, and (on a category axis)
87
+ * the ordinal slot edges. See the module docblock for the x/y split, the
88
+ * placement-scoped `row` half, and which box each pixel value is relative to.
89
+ *
90
+ * Must be called under a `<ChartContainer>`; throws otherwise. To render the
91
+ * markup *outside* the chart's box, portal it out (`createPortal`) — context
92
+ * flows through portals.
93
+ */
94
+ export function useChartFrame() {
95
+ const container = useContext(ContainerContext);
96
+ if (container === null) {
97
+ throw new Error('useChartFrame() must be used inside a <ChartContainer>');
98
+ }
99
+ const row = useContext(RowContext);
100
+ const { leftGutter, rightGutter, plotWidth, xScale, xKind } = container;
101
+ const bands = useMemo(() => {
102
+ const band = asBandScale(xScale, xKind);
103
+ if (band === null)
104
+ return null;
105
+ // The slot count is the scale's own domain width, not the label count:
106
+ // `scaleBand` is built with `domain([0, n])` from the container's resolved
107
+ // category list, so the domain is the authority on geometry (the labels
108
+ // ride alongside for naming). They agree today; reading the domain means
109
+ // they cannot disagree here if that ever stops being true.
110
+ const [d0, d1] = band.domain();
111
+ const count = Math.max(0, Math.round(d1 - d0));
112
+ const pitch = band.step();
113
+ // Slot `i` is the domain value `d0 + i`; the container always sets
114
+ // `domain([0, n])` so `d0` is 0 today, but every read goes through it so
115
+ // an offset domain could never silently shift the labels off the slots.
116
+ const labels = [];
117
+ for (let i = 0; i < count; i++)
118
+ labels.push(band.label(d0 + i + 0.5));
119
+ return {
120
+ count,
121
+ pitch,
122
+ labels,
123
+ at(index) {
124
+ if (!Number.isInteger(index) || index < 0 || index >= count) {
125
+ return null;
126
+ }
127
+ return {
128
+ x0: band(d0 + index),
129
+ x1: band(d0 + index + 1),
130
+ center: band(d0 + index + 0.5),
131
+ label: labels[index] ?? '',
132
+ };
133
+ },
134
+ };
135
+ }, [xScale, xKind]);
136
+ const rowHalf = useMemo(() => {
137
+ if (row === null)
138
+ return null;
139
+ return {
140
+ topInset: row.topInset,
141
+ height: Math.max(0, row.height - row.topInset),
142
+ yScales: row.yScales,
143
+ axisSides: row.axisSides,
144
+ };
145
+ }, [row]);
146
+ return useMemo(() => ({
147
+ plot: { x: leftGutter, width: plotWidth },
148
+ gutters: { left: leftGutter, right: rightGutter },
149
+ xScale,
150
+ xKind,
151
+ bands,
152
+ row: rowHalf,
153
+ }), [leftGutter, rightGutter, plotWidth, xScale, xKind, bands, rowHalf]);
154
+ }
155
+ //# sourceMappingURL=useChartFrame.js.map
@@ -32,6 +32,14 @@ export interface ChartLegend {
32
32
  * inset from the chart box on each side. A custom legend laid out above /
33
33
  * below the chart pads by `gutters.left` (and `gutters.right`) to align
34
34
  * with the plot instead of the y-axis column.
35
+ *
36
+ * The same two numbers {@link useChartFrame} publishes as
37
+ * `ChartFrame.gutters`, kept here so a legend needs one hook rather than
38
+ * two. **Reach for `useChartFrame()` instead** when the chrome is not a
39
+ * legend, or when aligning needs more than the gutters — the plot width,
40
+ * the x scale, the per-slot band edges, or a row's y scales. This field
41
+ * predates that hook and is the reason it exists: the geometry was
42
+ * published for exactly one consumer, on a hook named for something else.
35
43
  */
36
44
  readonly gutters: {
37
45
  readonly left: number;
@@ -24,13 +24,46 @@ export type TimeRange = readonly [number, number];
24
24
  * range untouched) so a mis-specified extent can't collapse the view.
25
25
  */
26
26
  export declare function clampToBounds(range: TimeRange, bounds: TimeRange): [number, number];
27
+ /**
28
+ * How a viewport gesture should treat the domain it is moving.
29
+ *
30
+ * Both flags exist because {@link roundRange} was written for a **millisecond**
31
+ * axis and silently assumed every axis was one.
32
+ */
33
+ export interface ViewportOptions {
34
+ /**
35
+ * Snap the result to whole integers. **Default `true`** — right for a time
36
+ * axis, where a fractional domain is meaningless and the 1 ms floor is the
37
+ * finest real view.
38
+ *
39
+ * Pass `false` on a **value** axis, where the units are not milliseconds and
40
+ * the fractions are the data: a power–duration curve over `[0.5, 10800]`
41
+ * seconds would otherwise snap its floor to `0`, and a `[0.001, 1]` domain
42
+ * would collapse to `[0, 1]`.
43
+ *
44
+ * The axis kind is a caller's fact, not something to infer from magnitude — a
45
+ * 0.2 ms span and a 0.2-unit value span are indistinguishable by size, and
46
+ * guessing breaks whichever one you guessed against.
47
+ */
48
+ readonly snap?: boolean;
49
+ /**
50
+ * Do the arithmetic in **log space**, where a log axis is linear. Implies
51
+ * `snap: false`.
52
+ *
53
+ * Without it a log axis zooms additively, which drags the value under the
54
+ * cursor sideways — the one thing zoom must never do — and pans by an offset,
55
+ * so a drag near the low end walks off the plot while the same drag near the
56
+ * high end barely moves.
57
+ */
58
+ readonly log?: boolean;
59
+ }
27
60
  /**
28
61
  * Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
29
62
  * dragging the plot right reveals earlier data, i.e. a negative `dt`. The result
30
63
  * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
31
64
  * delta through `xScale.invert()`, so it is fractional by construction.
32
65
  */
33
- export declare function panRange(range: TimeRange, dt: number): [number, number];
66
+ export declare function panRange(range: TimeRange, dt: number, options?: ViewportOptions): [number, number];
34
67
  /**
35
68
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
36
69
  * the pivot held fixed (the time under the cursor stays put). Clamped so the
@@ -43,7 +76,7 @@ export declare function panRange(range: TimeRange, dt: number): [number, number]
43
76
  * lands on the 1 ms floor the snap guarantees, which is the finest view this
44
77
  * model has.
45
78
  */
46
- export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number): [number, number];
79
+ export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number, options?: ViewportOptions): [number, number];
47
80
  /**
48
81
  * The slice of a discontinuity provider the trading-time viewport math needs —
49
82
  * a structural subset of the charts `DiscontinuityProvider` (so `viewport.ts`