@pond-ts/charts 0.71.0 → 0.73.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,9 +1,12 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
- import { baselinePxFromScale, plotExtentOf, strokeAffinePolyline, TRACE_HIT_PX, } from './line.js';
2
+ import { baselinePxFromScale, decimatedSessionRuns, plotExtentOf, sessionRuns, 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';
6
6
  import { affineOf } from './affine.js';
7
+ /** Shared empty boundary list — the no-breaks default (a stable reference, so
8
+ * the decimation cache keyed on it keeps hitting). */
9
+ const EMPTY_BOUNDARIES = [];
7
10
  /**
8
11
  * Per-buffer cache of a column's finite `[min, max]` value extent ([PND-GRADX]).
9
12
  * The area fill gradient spans the **full** series' vertical pixel extent (so a
@@ -21,7 +24,7 @@ import { affineOf } from './affine.js';
21
24
  * length); a `subarray` view is never the cache key here (the gradient reads the
22
25
  * pre-cull full series).
23
26
  *
24
- * NaN (the gap signal) is ignored — matching {@link areaExtent} / `yExtent` — so
27
+ * NaN (the gap signal) is ignored — matching the layer's `yExtent` — so
25
28
  * a coast doesn't drag the span. `null` when nothing is finite (the caller then
26
29
  * falls back to a flat fill).
27
30
  */
@@ -85,41 +88,6 @@ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
85
88
  }
86
89
  }
87
90
  }
88
- /**
89
- * The `[min, max]` vertical extent an area occupies — the finite values of
90
- * `cs.y` widened to include `baseline`, since the fill spans from each value to
91
- * the baseline (so the baseline must be in-domain or the fill clips). `null` if
92
- * no value is finite. When `baseline` is `undefined` (`<AreaChart
93
- * baseline="floor">`) the area rests on the axis's own lower bound (resolved
94
- * later), so only the values constrain the
95
- * domain — matching {@link yExtent}.
96
- *
97
- * NaN values (the gap signal) are ignored, so a coast doesn't drag the domain.
98
- */
99
- export function areaExtent(cs, baseline) {
100
- let min = Infinity;
101
- let max = -Infinity;
102
- for (let i = 0; i < cs.length; i += 1) {
103
- const v = cs.y[i];
104
- if (Number.isFinite(v)) {
105
- if (v < min)
106
- min = v;
107
- if (v > max)
108
- max = v;
109
- }
110
- }
111
- if (min === Infinity)
112
- return null;
113
- // The fill reaches the baseline, so it must be inside the domain (an
114
- // above/below-axis area with baseline 0 has to show the zero line).
115
- if (baseline !== undefined) {
116
- if (baseline < min)
117
- min = baseline;
118
- if (baseline > max)
119
- max = baseline;
120
- }
121
- return [min, max];
122
- }
123
91
  /**
124
92
  * Fill the area between `cs`'s value line and a horizontal `baseline`, with a
125
93
  * vertical gradient (most opaque at the line, fading to transparent at the
@@ -156,7 +124,7 @@ export function areaExtent(cs, baseline) {
156
124
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
157
125
  * are collected by one O(N) walk ({@link collectGapEdges}).
158
126
  */
159
- export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true, banding) {
127
+ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true, banding, boundaries = EMPTY_BOUNDARIES) {
160
128
  const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
161
129
  const baselinePx = yScale(baselineValue);
162
130
  // The fill gradient's vertical extent is computed from the **full** series (a
@@ -191,7 +159,7 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
191
159
  let decimated = false;
192
160
  if (decimate !== false && curve === curveLinear) {
193
161
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
194
- const r = decimateM4Cached(source, xScale, ctx, k);
162
+ const r = decimateM4Cached(source, xScale, ctx, k, boundaries);
195
163
  cs = r.series;
196
164
  decimated = r.decimated;
197
165
  }
@@ -207,16 +175,41 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
207
175
  const scaledY = gapUnscalable(cs.y, cs.length, yScale);
208
176
  if (scaledY !== cs.y)
209
177
  cs = { ...cs, y: scaledY };
210
- // `none` interpolates interior gaps so the fill + outline bridge them; every
211
- // other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
212
- // a separate overlay pass below).
213
- const ys = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
178
+ // Split into independent index runs at each session break, exactly as
179
+ // `drawLine` does: no boundary inside the data ⇒ one run over the whole series
180
+ // (the hot path — no slicing, byte-identical to the pre-boundary draw). When
181
+ // decimated, the breaks are already baked in as `NaN` points at the break
182
+ // instants, and {@link decimatedSessionRuns} cuts there instead.
183
+ const runs = decimated
184
+ ? decimatedSessionRuns(cs.x, cs.y, cs.length, boundaries)
185
+ : sessionRuns(cs.x, cs.length, boundaries);
186
+ const singleRun = runs.length === 1;
187
+ const xsOf = (s, e) => singleRun ? cs.x : cs.x.subarray(s, e);
188
+ // `none` interpolates interior gaps so the fill + outline bridge them — but
189
+ // only *within* a run (a session break is not a dropout to interpolate over);
190
+ // every other mode keeps NaN so d3 breaks both (the inferred line bridge, if
191
+ // any, is a separate overlay pass below).
192
+ const ysRuns = runs.map(([s, e]) => {
193
+ const seg = singleRun ? cs.y : cs.y.subarray(s, e);
194
+ return gaps === 'none' ? bridgeGaps(seg, e - s) : seg;
195
+ });
214
196
  // [PND-AFFINE] fast path: with a linear curve and both scales affine, draw the
215
197
  // fill polygon + outline with inline multiply-add over the typed arrays, past
216
198
  // the per-point d3-scale + d3-shape closures (finding 1/2). A smoothing curve
217
199
  // or a non-affine (real-gap trading) x scale keeps the exact d3-area path.
218
200
  const ax = curve === curveLinear ? affineOf(xScale) : null;
219
201
  const ay = ax !== null ? affineOf(yScale) : null;
202
+ // The d3-area generator for a run (slow path only) — also the source of that
203
+ // run's outline line. Each run's generator opens its own subpath and closes
204
+ // its own polygon, so a run boundary is a clean break in the fill: the shade
205
+ // ends at the close and re-starts at the open.
206
+ const areaGen = (s) => d3area()
207
+ .defined((v) => Number.isFinite(v))
208
+ .x((_, j) => xScale(cs.x[s + j]))
209
+ .y0(() => baselinePx)
210
+ .y1((v) => yScale(v))
211
+ .curve(curve)
212
+ .context(ctx);
220
213
  ctx.save();
221
214
  // The fill: a vertical gradient anchored at the baseline pixel, opaque at the
222
215
  // line and transparent at the baseline (see buildGradient — handles both the
@@ -225,44 +218,47 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
225
218
  ctx.fillStyle = fill;
226
219
  ctx.globalAlpha = style.fillOpacity;
227
220
  ctx.beginPath();
228
- // The d3-area generator (slow path only) — also the source of the outline line.
229
- let outline = null;
230
- if (ax !== null && ay !== null) {
231
- fillAffineArea(ctx, cs.x, ys, baselinePx, ax, ay);
232
- }
233
- else {
234
- const gen = d3area()
235
- .defined((v) => Number.isFinite(v))
236
- .x((_, i) => xScale(cs.x[i]))
237
- .y0(() => baselinePx)
238
- .y1((v) => yScale(v))
239
- .curve(curve)
240
- .context(ctx);
241
- gen(ys);
242
- outline = gen.lineY1();
243
- }
221
+ runs.forEach(([s, e], r) => {
222
+ if (ax !== null && ay !== null) {
223
+ fillAffineArea(ctx, xsOf(s, e), ysRuns[r], baselinePx, ax, ay);
224
+ }
225
+ else {
226
+ areaGen(s)(ysRuns[r]);
227
+ }
228
+ });
244
229
  ctx.fill();
245
230
  ctx.restore();
246
231
  // The outline on top: the area's top edge as a line (breaks at the same gaps
247
- // as the fill), at full opacity over the graded fill. Banded, it strokes with
248
- // the same hard-stop gradient the fill used, so the line switches hue exactly
249
- // where it crosses a threshold — the whole point of the ladder is that the
250
- // reader sees *where* the value sits, and the edge is the value.
232
+ // and session breaks as the fill), at full opacity over the graded fill.
233
+ // Banded, it strokes with the same hard-stop gradient the fill used, so the
234
+ // line switches hue exactly where it crosses a threshold — the whole point of
235
+ // the ladder is that the reader sees *where* the value sits, and the edge is
236
+ // the value.
251
237
  ctx.save();
252
238
  ctx.beginPath();
253
- if (outline !== null)
254
- outline(ys);
255
- else
256
- strokeAffinePolyline(ctx, cs.x, ys, ax, ay);
239
+ runs.forEach(([s, e], r) => {
240
+ if (ax !== null && ay !== null) {
241
+ strokeAffinePolyline(ctx, xsOf(s, e), ysRuns[r], ax, ay);
242
+ }
243
+ else {
244
+ areaGen(s).lineY1()(ysRuns[r]);
245
+ }
246
+ });
257
247
  ctx.strokeStyle = banding !== undefined ? fill : style.color;
258
248
  ctx.lineWidth = style.width;
259
249
  ctx.stroke();
260
250
  ctx.restore();
261
251
  // Inferred bridges for the line edge (fill stays broken). `dashed` / `step`
262
252
  // are faint dashed connectors (gapConnectorOpacity); only `fade` drops to the
263
- // area's own baseline pixel (the fill floor).
253
+ // area's own baseline pixel (the fill floor). Edges are collected **per run**
254
+ // so no connector spans a session break.
264
255
  if (gaps === 'dashed' || gaps === 'step' || gaps === 'fade') {
265
- const edges = collectGapEdges(cs.length, cs.x, (i) => cs.y[i], xScale, (i) => yScale(cs.y[i]));
256
+ const edges = [];
257
+ for (const [s, e] of runs) {
258
+ const runEdges = collectGapEdges(e - s, xsOf(s, e), (i) => cs.y[s + i], xScale, (i) => yScale(cs.y[s + i]));
259
+ for (const ed of runEdges)
260
+ edges.push(ed);
261
+ }
266
262
  if (gaps === 'dashed') {
267
263
  drawGapBridges(ctx, edges, style.color, style.width, gapConnectorOpacity);
268
264
  }
package/dist/bars.d.ts CHANGED
@@ -17,12 +17,13 @@ export type Orientation = 'vertical' | 'horizontal';
17
17
  * and the baseline must be in-domain or the bar clips. `null` if no value is
18
18
  * finite.
19
19
  *
20
- * Including `0` is the bar analog of {@link areaExtent} pulling a fixed baseline
21
- * into the domain: an all-positive series auto-fits to `[0, max]` so the bars
22
- * rest on a visible floor (the zero line), and a series that straddles zero
23
- * shows the zero line both above and below it. An explicit `<YAxis min>` still
24
- * wins — `resolveBarBaseline` rests the bars on that floor instead. NaN values
25
- * (the gap signal) are ignored, so a sparse bucket doesn't drag the domain.
20
+ * Including `0` is where a bar differs from an area, whose baseline never
21
+ * widens the axis: a bar's length is its value, so an all-positive series
22
+ * auto-fits to `[0, max]` and the bars rest on a visible floor (the zero line),
23
+ * and a series that straddles zero shows the zero line both above and below
24
+ * it. An explicit `<YAxis min>` still wins — `resolveBarBaseline` rests the
25
+ * bars on that floor instead. NaN values (the gap signal) are ignored, so a
26
+ * sparse bucket doesn't drag the domain.
26
27
  */
27
28
  export declare function barExtent(cs: BarSeries): [number, number] | null;
28
29
  /**
package/dist/bars.js CHANGED
@@ -8,12 +8,13 @@ import { NO_SPANS, spanMatchesAny } from './span.js';
8
8
  * and the baseline must be in-domain or the bar clips. `null` if no value is
9
9
  * finite.
10
10
  *
11
- * Including `0` is the bar analog of {@link areaExtent} pulling a fixed baseline
12
- * into the domain: an all-positive series auto-fits to `[0, max]` so the bars
13
- * rest on a visible floor (the zero line), and a series that straddles zero
14
- * shows the zero line both above and below it. An explicit `<YAxis min>` still
15
- * wins — `resolveBarBaseline` rests the bars on that floor instead. NaN values
16
- * (the gap signal) are ignored, so a sparse bucket doesn't drag the domain.
11
+ * Including `0` is where a bar differs from an area, whose baseline never
12
+ * widens the axis: a bar's length is its value, so an all-positive series
13
+ * auto-fits to `[0, max]` and the bars rest on a visible floor (the zero line),
14
+ * and a series that straddles zero shows the zero line both above and below
15
+ * it. An explicit `<YAxis min>` still wins — `resolveBarBaseline` rests the
16
+ * bars on that floor instead. NaN values (the gap signal) are ignored, so a
17
+ * sparse bucket doesn't drag the domain.
17
18
  */
18
19
  export function barExtent(cs) {
19
20
  let min = Infinity;
package/dist/context.d.ts CHANGED
@@ -716,13 +716,8 @@ export interface RowLayer {
716
716
  * when the layer was given no `as`.
717
717
  */
718
718
  readonly as?: string | undefined;
719
- /**
720
- * This layer's finite-value `[min, max]`, or `null` if it has none. `scale`
721
- * is the kind of the axis the extent is being fitted to, for a layer whose
722
- * extent depends on it (an area's zero baseline has no position on a log
723
- * axis, so it must not be pulled into one).
724
- */
725
- yExtent(scale?: YScaleKind): [number, number] | null;
719
+ /** This layer's finite-value `[min, max]`, or `null` if it has none. */
720
+ yExtent(): [number, number] | null;
726
721
  /**
727
722
  * The **kind of x axis** this layer's data lives on — `'time'` for a
728
723
  * `TimeSeries`, `'value'` for a `ValueSeries`, `'category'` for a categorical
package/dist/line.d.ts CHANGED
@@ -82,6 +82,20 @@ export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries,
82
82
  * negligible next to the row sweep.
83
83
  */
84
84
  export declare function sessionRuns(x: Float64Array, length: number, boundaries: readonly number[]): Array<[number, number]>;
85
+ /**
86
+ * {@link sessionRuns} for a **decimated** series. `decimateM4` bakes each
87
+ * session break in as a `NaN` point at the break instant, with the closing
88
+ * column's last point and the opening column's first point **both** at that
89
+ * instant — so `sessionRuns`'s `(x[i-1], x[i]]` cut would hand the close's last
90
+ * point to the next session. Instead, cut at every non-finite run whose finite
91
+ * neighbours bracket a boundary (`lastX ≤ b ≤ nextX`), starting the new run at
92
+ * the run's first `NaN`. The break then sits as a **leading** gap of the next
93
+ * session: never interior, so `gaps="none"` does not interpolate across it and
94
+ * no inferred connector (dashed / step / fade) spans it — left in one run it
95
+ * read as an ordinary dropout and got both. A data gap that straddles a break
96
+ * is cut the same way, matching the full-resolution path. Pure + O(N).
97
+ */
98
+ export declare function decimatedSessionRuns(x: Float64Array, y: Float64Array, length: number, boundaries: readonly number[]): Array<[number, number]>;
85
99
  /** How near the pointer must come to a trace's path to count as a hit, in px.
86
100
  * Generous on purpose: a 1.5px stroke is not a target anyone can hit, and a
87
101
  * trace has no fat mark to aim at the way a bar does. */
package/dist/line.js CHANGED
@@ -3,9 +3,6 @@ import { cullChartSeries } from './culling.js';
3
3
  import { decimateM4Cached } from './decimate.js';
4
4
  import { affineOf } from './affine.js';
5
5
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
6
- /** Shared empty boundary list — passed to `sessionRuns` when a decimated series
7
- * already carries its session breaks as baked-in `NaN` points. */
8
- const EMPTY_BOUNDARIES = [];
9
6
  /**
10
7
  * Stroke one run of `ys` (aligned index-for-index with `xs`) through the affine
11
8
  * pixel maps `ax` / `ay` — the [PND-AFFINE] fast path. Replicates d3-shape's
@@ -144,10 +141,12 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
144
141
  // data ⇒ one run over the whole series (the hot path — no slicing, so the draw
145
142
  // is byte-identical to the pre-boundary single pass). When the series was
146
143
  // decimated, `decimateM4` already baked the session breaks in as `NaN` points
147
- // (aligned to the break instants), so re-cutting here with `boundaries` would
148
- // mis-attribute the boundary points — pass `[]` and let the baked-in breaks split
149
- // the sessions.
150
- const runs = sessionRuns(cs.x, cs.length, decimated ? EMPTY_BOUNDARIES : boundaries);
144
+ // at the break instants, where `sessionRuns` would mis-attribute the boundary
145
+ // points — {@link decimatedSessionRuns} cuts at those baked gaps instead, so a
146
+ // break is never interior to a run (no `none` bridge or connector across it).
147
+ const runs = decimated
148
+ ? decimatedSessionRuns(cs.x, cs.y, cs.length, boundaries)
149
+ : sessionRuns(cs.x, cs.length, boundaries);
151
150
  const singleRun = runs.length === 1;
152
151
  // [PND-AFFINE] fast path: when the curve is linear and **both** scales are
153
152
  // affine (every y axis is `scaleLinear`; x is `scaleLinear` / `scaleTime` /
@@ -260,6 +259,47 @@ export function sessionRuns(x, length, boundaries) {
260
259
  runs.push([start, length]);
261
260
  return runs;
262
261
  }
262
+ /**
263
+ * {@link sessionRuns} for a **decimated** series. `decimateM4` bakes each
264
+ * session break in as a `NaN` point at the break instant, with the closing
265
+ * column's last point and the opening column's first point **both** at that
266
+ * instant — so `sessionRuns`'s `(x[i-1], x[i]]` cut would hand the close's last
267
+ * point to the next session. Instead, cut at every non-finite run whose finite
268
+ * neighbours bracket a boundary (`lastX ≤ b ≤ nextX`), starting the new run at
269
+ * the run's first `NaN`. The break then sits as a **leading** gap of the next
270
+ * session: never interior, so `gaps="none"` does not interpolate across it and
271
+ * no inferred connector (dashed / step / fade) spans it — left in one run it
272
+ * read as an ordinary dropout and got both. A data gap that straddles a break
273
+ * is cut the same way, matching the full-resolution path. Pure + O(N).
274
+ */
275
+ export function decimatedSessionRuns(x, y, length, boundaries) {
276
+ if (boundaries.length === 0 || length === 0)
277
+ return [[0, length]];
278
+ const bounds = boundaries.length > 1 ? [...boundaries].sort((a, b) => a - b) : boundaries;
279
+ const runs = [];
280
+ let start = 0;
281
+ let lastFinite = -1;
282
+ let bi = 0;
283
+ for (let i = 0; i < length; i += 1) {
284
+ if (!Number.isFinite(y[i]))
285
+ continue;
286
+ if (lastFinite >= 0 && i - lastFinite > 1) {
287
+ const a = x[lastFinite];
288
+ const b = x[i];
289
+ while (bi < bounds.length && bounds[bi] < a)
290
+ bi += 1;
291
+ if (bi < bounds.length && bounds[bi] <= b) {
292
+ runs.push([start, lastFinite + 1]);
293
+ start = lastFinite + 1;
294
+ while (bi < bounds.length && bounds[bi] <= b)
295
+ bi += 1;
296
+ }
297
+ }
298
+ lastFinite = i;
299
+ }
300
+ runs.push([start, length]);
301
+ return runs;
302
+ }
263
303
  /** How near the pointer must come to a trace's path to count as a hit, in px.
264
304
  * Generous on purpose: a 1.5px stroke is not a target anyone can hit, and a
265
305
  * trace has no fat mark to aim at the way a bar does. */
@@ -130,8 +130,14 @@ export interface TradingTimeScale {
130
130
  * formats with the d3 multi-scale default, like {@link tickFormat}; the
131
131
  * cursor readout itself uses {@link readoutFormat} (grain-aware), so ticks
132
132
  * read terse while the crosshair reads an unambiguous date/clock.
133
+ *
134
+ * `drawn` — the subset of {@link ticks}`(count)` actually rendered, when an
135
+ * axis drops ticks whose labels would collide. Promotions then walk only the
136
+ * drawn ticks, so a dropped tick's period turn carries to the next drawn one
137
+ * (drop a stray `Sep 29` session open and the real open beside it reads
138
+ * `Sep 29`, not `15:30`). Defaults to every tick.
133
139
  */
134
- flatFormat(count?: number): (value: number) => string;
140
+ flatFormat(count?: number, drawn?: readonly number[]): (value: number) => string;
135
141
  /**
136
142
  * The boundary-row label for the **domain start** — the reader's left-edge
137
143
  * context (`Jan 01` over an intraday axis, the year over a month axis),
@@ -209,7 +209,7 @@ export function scaleTradingTime(provider, options = {}) {
209
209
  }
210
210
  return (value) => labelled.get(value);
211
211
  };
212
- scale.flatFormat = (count = 10) => {
212
+ scale.flatFormat = (count = 10, drawn) => {
213
213
  // A non-tick instant always uses the d3 multi-scale default — same as
214
214
  // tickFormat. (The cursor readout doesn't route through here; it uses
215
215
  // readoutFormat, grain-aware.) Without a calendar there are no ladder
@@ -217,7 +217,8 @@ export function scaleTradingTime(provider, options = {}) {
217
217
  const defFmt = fmt(count);
218
218
  if (!hasCalendar())
219
219
  return (value) => defFmt(new Date(value));
220
- const { ticks, granularity } = resolved(count);
220
+ const { ticks: all, granularity } = resolved(count);
221
+ const ticks = drawn ?? all;
221
222
  // Seed the promotion walk from the previous **live** instant before the
222
223
  // domain start (clampDown; identity on a continuous axis): a domain that
223
224
  // opens exactly on a month's first session then promotes that tick to the
@@ -0,0 +1,20 @@
1
+ import { ValueSeries } from 'pond-ts';
2
+ import type { DurationInput, SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ /**
4
+ * A layer's `xOffset`: how far after its key each row is **drawn** —
5
+ * milliseconds or a duration string (`'1m'`, `'-30s'`). See
6
+ * `LineChartCommon.xOffset`.
7
+ */
8
+ export type XOffset = DurationInput;
9
+ /**
10
+ * The series a layer draws, moved by `xOffset`. Every downstream read — the
11
+ * drawn x, the auto-fit extent, session breaks, the tracker's nearest row and
12
+ * dot, the sweep bounds — then sees one consistent axis. The move is pond's
13
+ * `offsetTime`: one pass over the key, value columns shared.
14
+ *
15
+ * `undefined` / `0` return the source unchanged (same reference, so memo deps
16
+ * downstream stay stable). A value series has no time to move, so a non-zero
17
+ * offset on one throws.
18
+ */
19
+ export declare function useXOffset<Sr extends TimeSeries<SeriesSchema> | ValueSeries<ValueSeriesSchema>>(series: Sr, xOffset: XOffset | undefined, layer: string): Sr;
20
+ //# sourceMappingURL=x-offset.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { useMemo } from 'react';
2
+ import { ValueSeries } from 'pond-ts';
3
+ /**
4
+ * The series a layer draws, moved by `xOffset`. Every downstream read — the
5
+ * drawn x, the auto-fit extent, session breaks, the tracker's nearest row and
6
+ * dot, the sweep bounds — then sees one consistent axis. The move is pond's
7
+ * `offsetTime`: one pass over the key, value columns shared.
8
+ *
9
+ * `undefined` / `0` return the source unchanged (same reference, so memo deps
10
+ * downstream stay stable). A value series has no time to move, so a non-zero
11
+ * offset on one throws.
12
+ */
13
+ export function useXOffset(series, xOffset, layer) {
14
+ return useMemo(() => {
15
+ if (xOffset === undefined || xOffset === 0)
16
+ return series;
17
+ if (series instanceof ValueSeries) {
18
+ throw new TypeError(`<${layer}> xOffset moves a time axis; a ValueSeries has none`);
19
+ }
20
+ return series.offsetTime(xOffset);
21
+ }, [series, xOffset, layer]);
22
+ }
23
+ //# sourceMappingURL=x-offset.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first React time-series charts for pond-ts: line, area, band, bar, scatter, box, candlestick, with cursors, selection and annotations",
6
6
  "keywords": [
@@ -62,8 +62,8 @@
62
62
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
63
63
  },
64
64
  "peerDependencies": {
65
- "@pond-ts/react": "^0.71.0",
66
- "pond-ts": "^0.71.0",
65
+ "@pond-ts/react": "^0.73.0",
66
+ "pond-ts": "^0.73.0",
67
67
  "react": "^18.0.0 || ^19.0.0"
68
68
  },
69
69
  "devDependencies": {