@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.
@@ -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`
package/dist/viewport.js CHANGED
@@ -70,8 +70,26 @@ function roundRange(lo, hi) {
70
70
  * is snapped to whole milliseconds ({@link roundRange}) — `dt` comes from a pixel
71
71
  * delta through `xScale.invert()`, so it is fractional by construction.
72
72
  */
73
- export function panRange(range, dt) {
74
- return roundRange(range[0] + dt, range[1] + dt);
73
+ export function panRange(range, dt, options = {}) {
74
+ const { log = false, snap = !log } = options;
75
+ if (log) {
76
+ // On a log axis a pixel drag is a RATIO, not an offset. `dt` arrives as a
77
+ // domain delta from `xScale.invert`, which on a log scale is meaningless as
78
+ // an addend: adding 100s near the 1s end walks off the plot, and near the
79
+ // 3h end barely moves. Convert it to the fraction of the visible decades it
80
+ // represents and shift by that instead, so a drag of N pixels moves the
81
+ // same visual distance wherever it starts.
82
+ const [lo, hi] = range;
83
+ if (!(lo > 0) || !(hi > lo))
84
+ return [lo, hi];
85
+ const span = hi - lo;
86
+ const f = span > 0 ? dt / span : 0; // fraction of the window dragged
87
+ const k = Math.exp(Math.log(hi / lo) * f); // …as a ratio over the decades
88
+ return [lo * k, hi * k];
89
+ }
90
+ const lo = range[0] + dt;
91
+ const hi = range[1] + dt;
92
+ return snap ? roundRange(lo, hi) : [lo, hi];
75
93
  }
76
94
  /**
77
95
  * Zoom `range` around `pivot` (ms) by `factor` — `< 1` zooms in, `> 1` out, with
@@ -85,15 +103,44 @@ export function panRange(range, dt) {
85
103
  * lands on the 1 ms floor the snap guarantees, which is the finest view this
86
104
  * model has.
87
105
  */
88
- export function zoomRange(range, pivot, factor, minDuration = 1) {
106
+ export function zoomRange(range, pivot, factor, minDuration = 1, options = {}) {
107
+ const { log = false, snap = !log } = options;
108
+ if (log) {
109
+ // The same arithmetic, done in log space — which is where a log axis is
110
+ // linear. Zooming a log domain multiplicatively is what keeps the pivot
111
+ // under the cursor; doing it additively (as the linear branch does) drags
112
+ // the value under the pointer sideways, which is the one thing zoom must
113
+ // never do.
114
+ //
115
+ // `minDuration` is read as a minimum RATIO between the ends rather than a
116
+ // minimum difference, because a span on a log axis is a number of decades.
117
+ const [lo0, hi0] = range;
118
+ if (!(lo0 > 0) || !(hi0 > lo0) || !(pivot > 0))
119
+ return [lo0, hi0];
120
+ const L = Math.log(lo0);
121
+ const H = Math.log(hi0);
122
+ const P = Math.min(H, Math.max(L, Math.log(pivot)));
123
+ let l = P - (P - L) * factor;
124
+ let h = P + (H - P) * factor;
125
+ const floor = Math.log(Math.max(minDuration, 1 + 1e-9)); // ratio → decades
126
+ if (h - l < floor) {
127
+ const frac = H - L > 0 ? (P - L) / (H - L) : 0.5;
128
+ l = P - floor * frac;
129
+ h = P + floor * (1 - frac);
130
+ }
131
+ return [Math.exp(l), Math.exp(h)];
132
+ }
89
133
  const lo = pivot - (pivot - range[0]) * factor;
90
134
  const hi = pivot + (range[1] - pivot) * factor;
91
- if (hi - lo >= minDuration)
92
- return roundRange(lo, hi);
135
+ if (hi - lo >= minDuration) {
136
+ return snap ? roundRange(lo, hi) : [lo, hi];
137
+ }
93
138
  // Floor reached: hold the pivot's fractional position, set span = minDuration.
94
139
  const span = range[1] - range[0];
95
140
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
96
- return roundRange(pivot - minDuration * frac, pivot + minDuration * (1 - frac));
141
+ const flo = pivot - minDuration * frac;
142
+ const fhi = pivot + minDuration * (1 - frac);
143
+ return snap ? roundRange(flo, fhi) : [flo, fhi];
97
144
  }
98
145
  /**
99
146
  * Pan a range on a **trading-time** axis: shift both endpoints by the same
package/dist/yticks.d.ts CHANGED
@@ -57,11 +57,15 @@ interface TickableScale {
57
57
  * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
58
58
  * behaved — so that case defers to the scale.
59
59
  *
60
+ * Used by **both** axes. Nothing in here was ever y-specific — it detects log
61
+ * and symlog structurally and defers to the scale for everything else — and
62
+ * `<ChartContainer xScale="log">` made that concrete ([PND-XLOG]).
63
+ *
60
64
  * Log detection is structural: `base()` exists on `scaleLog` and on no other
61
65
  * continuous scale. The alternative is threading the axis kind down to the
62
66
  * gridline site, which has no `AxisSpec` in scope — the same localized-shape
63
67
  * approach `resolveBarBaseline` takes to read `.domain()`.
64
68
  */
65
- export declare function yTickValues(scale: TickableScale, count: number): number[];
69
+ export declare function tickValues(scale: TickableScale, count: number): number[];
66
70
  export {};
67
71
  //# sourceMappingURL=yticks.d.ts.map
package/dist/yticks.js CHANGED
@@ -55,12 +55,16 @@ export function resolveYTickCount(height, explicit) {
55
55
  * d3's within-decade selection (2,3,…9 × 10ⁿ) is the right answer and is well
56
56
  * behaved — so that case defers to the scale.
57
57
  *
58
+ * Used by **both** axes. Nothing in here was ever y-specific — it detects log
59
+ * and symlog structurally and defers to the scale for everything else — and
60
+ * `<ChartContainer xScale="log">` made that concrete ([PND-XLOG]).
61
+ *
58
62
  * Log detection is structural: `base()` exists on `scaleLog` and on no other
59
63
  * continuous scale. The alternative is threading the axis kind down to the
60
64
  * gridline site, which has no `AxisSpec` in scope — the same localized-shape
61
65
  * approach `resolveBarBaseline` takes to read `.domain()`.
62
66
  */
63
- export function yTickValues(scale, count) {
67
+ export function tickValues(scale, count) {
64
68
  if (typeof scale.constant === 'function')
65
69
  return symlogTickValues(scale, count);
66
70
  if (typeof scale.base !== 'function')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -39,8 +39,8 @@
39
39
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@pond-ts/react": "^0.59.0",
43
- "pond-ts": "^0.59.0",
42
+ "@pond-ts/react": "^0.60.0",
43
+ "pond-ts": "^0.60.0",
44
44
  "react": "^18.0.0 || ^19.0.0"
45
45
  },
46
46
  "devDependencies": {