@pond-ts/charts 0.40.0 → 0.42.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/scatter.js CHANGED
@@ -109,13 +109,16 @@ export function scatterExtent(cs) {
109
109
  * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
110
110
  * @param font `theme.font` (family + size) for label text.
111
111
  * @param selected the container's current selection (or `null`).
112
- * @param seriesLabel this layer's series identity (`as` ?? column) — the
113
- * `label` half of the selection match.
112
+ * @param seriesId this layer's stable series identity (its `id` prop, or
113
+ * `undefined` when the layer isn't selectable) — the series half
114
+ * of the selection match. A point lights only when the selection's
115
+ * `id` matches, keyed to the sample by its `key`.
114
116
  */
115
- export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesLabel) {
117
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
116
118
  ctx.save();
117
119
  // The selection only lights up a point of *this* series; resolve the key once.
118
- const selectedKey = selected !== null && selected.label === seriesLabel ? selected.key : null;
120
+ // A no-id (non-selectable) layer passes `undefined` and never matches.
121
+ const selectedKey = selected !== null && selected.id === seriesId ? selected.key : null;
119
122
  let selPx = 0;
120
123
  let selPy = 0;
121
124
  let selR = 0;
@@ -183,14 +186,14 @@ const LABEL_GAP = 4;
183
186
  *
184
187
  * A point's hit radius is its drawn radius (data-driven or base) — clicking the
185
188
  * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
186
- * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
187
- * `begin`), the encoded fill colour (so the readout swatch matches the mark),
188
- * and the series `label`.
189
+ * Returns the point's {@link SelectInfo} with the series `id` (the selection
190
+ * identity), `key = keyAt(i)` (its event `begin` click provenance), the encoded
191
+ * fill colour (so the readout swatch matches the mark), and the display `label`.
189
192
  *
190
193
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
191
194
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
192
195
  */
193
- export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seriesLabel) {
196
+ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
194
197
  for (let i = cs.length - 1; i >= 0; i -= 1) {
195
198
  if (!isPoint(cs, i))
196
199
  continue;
@@ -201,6 +204,7 @@ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seri
201
204
  const dy = qy - py;
202
205
  if (dx * dx + dy * dy <= r * r) {
203
206
  return {
207
+ id,
204
208
  key: keyAt(i),
205
209
  value: cs.y[i],
206
210
  color: encoding.colorAt(i),
package/dist/theme.d.ts CHANGED
@@ -73,6 +73,22 @@ export interface ChartTheme {
73
73
  readonly default: BoxStyle;
74
74
  readonly [semantic: string]: BoxStyle;
75
75
  };
76
+ /**
77
+ * Map from a candle's semantic identifier to its style — a first-class OHLC
78
+ * mark ({@link Candlestick}), the financial sibling of the box. Unlike the
79
+ * other slots a {@link CandleStyle} carries a *pair* (`rising`/`falling`, plus
80
+ * an optional `neutral` doji): direction colouring is intrinsic to the mark, so
81
+ * one colour can't express it. `default` is the fallback; a chart tags each
82
+ * series with a role (`<Candlestick as="AAPL" />`) resolving `candle[semantic]
83
+ * ?? candle.default`. The default pair is **neutral / unbranded** (a
84
+ * distinguishable up/down, *not* market green/red) — a consumer supplies its
85
+ * own palette via `cssVarTheme`; the library owns the type + a renderable
86
+ * default, never a brand.
87
+ */
88
+ readonly candle: {
89
+ readonly default: CandleStyle;
90
+ readonly [semantic: string]: CandleStyle;
91
+ };
76
92
  /**
77
93
  * Map from a bar's semantic identifier to its style — the fill, the
78
94
  * selected-bar highlight, and the slot gap / minimum width ({@link BarChart}).
@@ -88,6 +104,13 @@ export interface ChartTheme {
88
104
  readonly grid: string;
89
105
  /** Gridline dash pattern (px on/off pairs); `[]` for solid. */
90
106
  readonly gridDash: readonly number[];
107
+ /**
108
+ * Stroke for **session dividers** — the solid verticals a trading-time axis
109
+ * draws at each collapsed gap (session/day open). Optional; falls back to
110
+ * {@link grid}. Set it a touch stronger than the gridlines so a session
111
+ * boundary reads as structural.
112
+ */
113
+ readonly sessionDivider?: string;
91
114
  /**
92
115
  * Typography for the axis **title** — the rotated y-axis unit strip and the
93
116
  * x-axis label (distinct from the per-tick `label` colour above). Omit a
@@ -204,6 +227,42 @@ export interface BoxStyle {
204
227
  readonly whisker: string;
205
228
  readonly whiskerWidth: number;
206
229
  }
230
+ /**
231
+ * A resolved candlestick style ({@link Candlestick}). A candle is unreadable in
232
+ * one colour — rising vs falling *must* differ to mean anything — so the style
233
+ * is a **pair**: `rising` (close > open) and `falling` (close < open), each a
234
+ * `body` (the open→close rectangle / the OHLC bar) and a `wick` (the high–low
235
+ * line / the bar's stem). `neutral` styles a **doji** (open === close); it falls
236
+ * back to `rising` when unset. `bodyWidth` is the body's fraction of the candle
237
+ * slot (0–1; the wick always sits at the slot centre) — omitted ⇒ `0.8`.
238
+ * `wickWidth` is the wick / bar stroke width in px.
239
+ *
240
+ * With `colorBy='series'` the direction split is bypassed and every candle draws
241
+ * in the `rising` colours (one colour = one series, for a candle sitting beside
242
+ * coloured lines).
243
+ */
244
+ export interface CandleStyle {
245
+ /** Rising candle (close > open) — body + wick colours. Also the single colour
246
+ * under `colorBy='series'`. */
247
+ readonly rising: {
248
+ readonly body: string;
249
+ readonly wick: string;
250
+ };
251
+ /** Falling candle (close < open) — body + wick colours. */
252
+ readonly falling: {
253
+ readonly body: string;
254
+ readonly wick: string;
255
+ };
256
+ /** Doji (open === close) — body + wick colours; falls back to `rising` if unset. */
257
+ readonly neutral?: {
258
+ readonly body: string;
259
+ readonly wick: string;
260
+ };
261
+ /** Body width as a fraction of the candle slot (0–1). Omitted ⇒ `0.8`. */
262
+ readonly bodyWidth?: number;
263
+ /** Wick / OHLC-bar stroke width in px. */
264
+ readonly wickWidth: number;
265
+ }
207
266
  /**
208
267
  * A resolved area style: an outline stroke plus a graded fill. `color`/`width`
209
268
  * stroke the value line on top; `fill` is the gradient base colour, opaque
package/dist/theme.js CHANGED
@@ -75,6 +75,18 @@ export const defaultTheme = {
75
75
  whiskerWidth: 1,
76
76
  },
77
77
  },
78
+ candle: {
79
+ // Neutral / unbranded up-down pair — *not* market green/red (a consumer
80
+ // supplies that via cssVarTheme). Rising reuses the brand blue; falling the
81
+ // warm secondary accent — distinguishable at a glance on the light ground.
82
+ default: {
83
+ rising: { body: '#2563eb', wick: '#1e3a8a' },
84
+ falling: { body: '#e8836b', wick: '#b4442a' },
85
+ neutral: { body: '#94a3b8', wick: '#64748b' },
86
+ bodyWidth: 0.7,
87
+ wickWidth: 1,
88
+ },
89
+ },
78
90
  bar: {
79
91
  // Flat blue fill; the selected bar brightens + outlines. `secondary` reuses
80
92
  // the line's warm accent for a second series.
@@ -99,6 +111,7 @@ export const defaultTheme = {
99
111
  label: '#64748b',
100
112
  grid: '#e2e8f0',
101
113
  gridDash: [2, 2],
114
+ sessionDivider: '#cbd5e1', // slate-300 — a step stronger than the gridlines
102
115
  },
103
116
  font: {
104
117
  family: 'system-ui, -apple-system, sans-serif',
@@ -202,6 +215,18 @@ export const estelaTheme = {
202
215
  whiskerWidth: 1.5,
203
216
  },
204
217
  },
218
+ candle: {
219
+ // On the dark ground: brand teal rising, warm filament falling — the estela
220
+ // palette's own up/down, still *not* literal green/red (a financial consumer
221
+ // like Tidal overlays its market palette via cssVarTheme).
222
+ default: {
223
+ rising: { body: '#15B3A6', wick: '#0E7D74' }, // --es-estela
224
+ falling: { body: '#E0B36A', wick: '#B4863F' }, // --es-filament
225
+ neutral: { body: '#4E6B6B', wick: '#DBEAE8' }, // --es-slate / --es-mist
226
+ bodyWidth: 0.7,
227
+ wickWidth: 1.5,
228
+ },
229
+ },
205
230
  bar: {
206
231
  // Brand-teal fill on the dark ground; the selected bar lifts to the bright
207
232
  // reef + an outline. `secondary` is the warm filament accent.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The structural discontinuity-provider surface `scaleTradingTime` consumes to
3
+ * collapse closed-market time. Charts declares this **shape** itself and never
4
+ * imports `@pond-ts/financial` — a `TradingCalendar.discontinuities()` provider
5
+ * satisfies it structurally, so the packages stay decoupled (trading-calendar
6
+ * RFC §6.1). Domain values are epoch-milliseconds.
7
+ */
8
+ export interface DiscontinuityProvider {
9
+ /** A value in a removed gap → its next live instant; a live value unchanged. */
10
+ clampUp(value: number): number;
11
+ /** A value in a removed gap → its previous live instant; a live value unchanged. */
12
+ clampDown(value: number): number;
13
+ /** Signed live (non-gap) distance from `from` to `to`. */
14
+ distance(from: number, to: number): number;
15
+ /** Advance `value` by `amount` live-ms, skipping gaps (inverse of {@link distance}). */
16
+ offset(value: number, amount: number): number;
17
+ copy(): DiscontinuityProvider;
18
+ /**
19
+ * Optional: the domain positions of collapsed gaps strictly inside `(from,
20
+ * to)` — session/day opens where closed time was removed. The container draws
21
+ * a **session divider** at each; a provider that omits it just collapses the
22
+ * axis silently. (A `TradingCalendar.discontinuities()` provider supplies it.)
23
+ */
24
+ boundaries?(from: number, to: number): number[];
25
+ }
26
+ /**
27
+ * The high-level counterpart to a bare {@link DiscontinuityProvider}: anything
28
+ * that can *produce* one from an optional `spacing` choice. A
29
+ * `@pond-ts/financial` `TradingCalendar` satisfies this structurally (its
30
+ * `discontinuities` accepts a superset of these options), so a consumer can
31
+ * hand `<ChartContainer calendar={cal} spacing="uniform" />` instead of calling
32
+ * `cal.discontinuities({ spacing })` themselves — and charts still never imports
33
+ * the financial package (RFC §6.1). For the full option matrix (a bar `period`,
34
+ * a scoped `range`) build the provider yourself and pass the low-level
35
+ * `discontinuities` prop.
36
+ */
37
+ export interface TradingCalendarLike {
38
+ discontinuities(options?: {
39
+ spacing?: 'proportional' | 'uniform';
40
+ }): DiscontinuityProvider;
41
+ }
42
+ /**
43
+ * A d3-scale-shaped time scale whose pixel mapping runs through **trading time**
44
+ * — closed-market gaps (weekends, holidays, overnight, lunch breaks) collapse to
45
+ * nothing while time stays proportional within each session. Exposes the slice
46
+ * of the d3 `ScaleTime` surface `@pond-ts/charts` actually uses, so it drops in
47
+ * wherever the container's `xScale` goes.
48
+ *
49
+ * Ticks are **calendar-aware** when the provider enumerates its gaps: `.ticks`
50
+ * returns the session opens thinned to a calendar grain (week / month / year
51
+ * starts, whichever fits ~`count`), and `.tickFormat` labels each anchor with a
52
+ * **date** (`%b %d`) — or the **year** (`%Y`) when ticks are a year apart —
53
+ * while formatting any other instant (a mid-session tick, the cursor readout)
54
+ * with the d3 multi-scale default. Without a provider `boundaries` method it
55
+ * falls back to interior even-spaced time ticks.
56
+ *
57
+ * **Out-of-domain behavior.** Within the calendar the scale extrapolates like a
58
+ * normal scale — a live instant *before* the domain start maps to a negative
59
+ * pixel (so off-plot marks are still culled). Instants outside the calendar
60
+ * entirely (before the first session / after the last) have no trading-time
61
+ * position and **clamp** to the near edge rather than extrapolating into
62
+ * meaningless space. In practice marks are always in-calendar, so this only
63
+ * affects data beyond the calendar's absolute extremes.
64
+ */
65
+ export interface TradingTimeScale {
66
+ (value: number): number;
67
+ invert(pixel: number): number;
68
+ ticks(count?: number): number[];
69
+ tickFormat(count?: number, specifier?: string): (date: Date) => string;
70
+ domain(): [number, number];
71
+ domain(next: readonly [number, number]): TradingTimeScale;
72
+ range(): [number, number];
73
+ range(next: readonly [number, number]): TradingTimeScale;
74
+ copy(): TradingTimeScale;
75
+ }
76
+ /** The calendar grain a run of session opens is bucketed to for axis ticks. */
77
+ type TickGranularity = 'session' | 'week' | 'month' | 'quarter' | 'year';
78
+ /**
79
+ * Thin an ascending run of **session opens** down to about `count` axis ticks by
80
+ * **calendar grain** — the trading-terminal habit of labelling week / month /
81
+ * year starts rather than an arbitrary every-nth session. Picks the finest grain
82
+ * on the ladder (session → week → month → quarter → year) that yields at most
83
+ * `count` buckets and returns the first open in each; beyond yearly it decimates
84
+ * every-nth so the axis never crowds. Exported so the container can draw session
85
+ * dividers at the same instants the axis labels.
86
+ */
87
+ export declare function coarsenCalendar(opens: readonly number[], count: number): {
88
+ ticks: number[];
89
+ granularity: TickGranularity;
90
+ };
91
+ /**
92
+ * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
93
+ * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
94
+ */
95
+ export declare function scaleTradingTime(provider: DiscontinuityProvider): TradingTimeScale;
96
+ export {};
97
+ //# sourceMappingURL=tradingTimeScale.d.ts.map
@@ -0,0 +1,152 @@
1
+ import { scaleTime } from 'd3-scale';
2
+ /**
3
+ * The local-time bucket key for `t` at grain `g` — two instants in the same
4
+ * week / month / quarter / year share a key. Local time (not UTC) so it agrees
5
+ * with the local `scaleTime` label formatter; the exchange's own time zone is
6
+ * unknown to the scale (the deferred refinement), and a session open sits well
7
+ * inside its local day, so runtime-local grouping matches the exchange day in
8
+ * every ordinary case.
9
+ */
10
+ function bucketKey(t, g) {
11
+ if (g === 'session')
12
+ return t; // every open its own bucket
13
+ const d = new Date(t);
14
+ switch (g) {
15
+ case 'week': {
16
+ const dow = (d.getDay() + 6) % 7; // 0 = Monday
17
+ // Local midnight of this week's Monday (Date normalizes a negative date).
18
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
19
+ }
20
+ case 'month':
21
+ return d.getFullYear() * 12 + d.getMonth();
22
+ case 'quarter':
23
+ return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
24
+ case 'year':
25
+ return d.getFullYear();
26
+ }
27
+ }
28
+ /** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
29
+ function firstOfEachBucket(opens, g) {
30
+ const out = [];
31
+ let prev;
32
+ for (const t of opens) {
33
+ const k = bucketKey(t, g);
34
+ if (k !== prev) {
35
+ out.push(t);
36
+ prev = k;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ const COARSENING_LADDER = [
42
+ 'week',
43
+ 'month',
44
+ 'quarter',
45
+ 'year',
46
+ ];
47
+ /**
48
+ * Thin an ascending run of **session opens** down to about `count` axis ticks by
49
+ * **calendar grain** — the trading-terminal habit of labelling week / month /
50
+ * year starts rather than an arbitrary every-nth session. Picks the finest grain
51
+ * on the ladder (session → week → month → quarter → year) that yields at most
52
+ * `count` buckets and returns the first open in each; beyond yearly it decimates
53
+ * every-nth so the axis never crowds. Exported so the container can draw session
54
+ * dividers at the same instants the axis labels.
55
+ */
56
+ export function coarsenCalendar(opens, count) {
57
+ if (opens.length <= count)
58
+ return { ticks: [...opens], granularity: 'session' };
59
+ for (const g of COARSENING_LADDER) {
60
+ const ticks = firstOfEachBucket(opens, g);
61
+ if (ticks.length <= count)
62
+ return { ticks, granularity: g };
63
+ }
64
+ // Coarser than yearly isn't a calendar grain — decimate the year starts.
65
+ const yearly = firstOfEachBucket(opens, 'year');
66
+ const step = Math.ceil(yearly.length / count);
67
+ return {
68
+ ticks: yearly.filter((_, i) => i % step === 0),
69
+ granularity: 'year',
70
+ };
71
+ }
72
+ /**
73
+ * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
74
+ * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
75
+ */
76
+ export function scaleTradingTime(provider) {
77
+ let domain = [0, 1];
78
+ let range = [0, 1];
79
+ // A private d3 time scale, kept in sync with the domain, purely for tickFormat.
80
+ const base = scaleTime();
81
+ const totalLive = () => provider.distance(domain[0], domain[1]);
82
+ const scale = ((value) => {
83
+ const live = totalLive();
84
+ const span = range[1] - range[0];
85
+ if (live === 0)
86
+ return range[0];
87
+ return range[0] + (provider.distance(domain[0], value) / live) * span;
88
+ });
89
+ scale.invert = (pixel) => {
90
+ const span = range[1] - range[0];
91
+ const frac = span === 0 ? 0 : (pixel - range[0]) / span;
92
+ return provider.offset(domain[0], frac * totalLive());
93
+ };
94
+ /** The session-open instants in the domain — the first session's open (the
95
+ * left edge) plus each collapsed-gap boundary — the axis's date anchors. */
96
+ const sessionOpens = () => {
97
+ const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
98
+ return [domain[0], ...bounds];
99
+ };
100
+ scale.ticks = (count = 10) => {
101
+ const live = totalLive();
102
+ if (live <= 0 || count < 1)
103
+ return [domain[0]];
104
+ const opens = sessionOpens();
105
+ // Calendar-aware: label the session opens (each a new day), thinned to
106
+ // week / month / year starts by grain rather than an arbitrary every-nth
107
+ // session — the trading-terminal look.
108
+ if (opens.length > 1)
109
+ return coarsenCalendar(opens, count).ticks;
110
+ // No boundaries (a single session / no calendar): fall back to interior
111
+ // even-spaced ticks — endpoints excluded so none sits on the plot edge.
112
+ const out = [];
113
+ for (let i = 1; i < count; i++) {
114
+ out.push(provider.offset(domain[0], (i / count) * live));
115
+ }
116
+ return out;
117
+ };
118
+ scale.tickFormat = (count = 10, specifier) => {
119
+ if (specifier !== undefined)
120
+ return base.tickFormat(count, specifier);
121
+ const opens = sessionOpens();
122
+ const defFmt = base.tickFormat(count);
123
+ if (opens.length <= 1)
124
+ return defFmt; // no calendar → d3 multi-scale default
125
+ // Anchor label at each coarsened session-open tick: a **date** (`%b %d`), or
126
+ // the **year** when ticks are a year apart (a plain date would drop the year
127
+ // the reader needs). Any other instant — a cursor readout — uses the d3
128
+ // multi-scale default. Same grain as {@link ticks}, so labels and the
129
+ // dividers drawn at these instants agree.
130
+ const { ticks, granularity } = coarsenCalendar(opens, count);
131
+ const anchors = new Set(ticks);
132
+ const anchorFmt = base.tickFormat(count, granularity === 'year' ? '%Y' : '%b %d');
133
+ return (d) => (anchors.has(+d) ? anchorFmt(d) : defFmt(d));
134
+ };
135
+ function domainFn(next) {
136
+ if (next === undefined)
137
+ return [domain[0], domain[1]];
138
+ domain = [next[0], next[1]];
139
+ return scale;
140
+ }
141
+ scale.domain = domainFn;
142
+ function rangeFn(next) {
143
+ if (next === undefined)
144
+ return [range[0], range[1]];
145
+ range = [next[0], next[1]];
146
+ return scale;
147
+ }
148
+ scale.range = rangeFn;
149
+ scale.copy = () => scaleTradingTime(provider.copy()).domain(domain).range(range);
150
+ return scale;
151
+ }
152
+ //# sourceMappingURL=tradingTimeScale.js.map
@@ -17,4 +17,27 @@ export declare function panRange(range: TimeRange, dt: number): [number, number]
17
17
  * pivot keeps its fractional position in the window.
18
18
  */
19
19
  export declare function zoomRange(range: TimeRange, pivot: number, factor: number, minDuration?: number): [number, number];
20
+ /**
21
+ * The slice of a discontinuity provider the trading-time viewport math needs —
22
+ * a structural subset of the charts `DiscontinuityProvider` (so `viewport.ts`
23
+ * stays free of any provider dependency).
24
+ */
25
+ export interface ViewportDiscontinuity {
26
+ distance(from: number, to: number): number;
27
+ offset(value: number, amount: number): number;
28
+ }
29
+ /**
30
+ * Pan a range on a **trading-time** axis: shift both endpoints by the same
31
+ * amount of *trading* time, so the pan feels uniform on screen even across
32
+ * collapsed gaps (a raw-ms shift would jump at each weekend/holiday). `fraction`
33
+ * is the signed share of the plot width dragged — the caller passes `-dx/plotWidth`
34
+ * (drag right → reveal earlier data → negative).
35
+ */
36
+ export declare function panRangeTrading(range: TimeRange, fraction: number, provider: ViewportDiscontinuity): [number, number];
37
+ /**
38
+ * Zoom a **trading-time** range around `pivot` by `factor` (`< 1` in, `> 1` out),
39
+ * scaling the *trading* distance from the pivot to each endpoint so the pivot's
40
+ * on-screen position holds. Floors the visible trading time at `minLive`.
41
+ */
42
+ export declare function zoomRangeTrading(range: TimeRange, pivot: number, factor: number, provider: ViewportDiscontinuity, minLive?: number): [number, number];
20
43
  //# sourceMappingURL=viewport.d.ts.map
package/dist/viewport.js CHANGED
@@ -27,4 +27,55 @@ export function zoomRange(range, pivot, factor, minDuration = 1) {
27
27
  const frac = span > 0 ? (pivot - range[0]) / span : 0.5;
28
28
  return [pivot - minDuration * frac, pivot + minDuration * (1 - frac)];
29
29
  }
30
+ /**
31
+ * Pan a range on a **trading-time** axis: shift both endpoints by the same
32
+ * amount of *trading* time, so the pan feels uniform on screen even across
33
+ * collapsed gaps (a raw-ms shift would jump at each weekend/holiday). `fraction`
34
+ * is the signed share of the plot width dragged — the caller passes `-dx/plotWidth`
35
+ * (drag right → reveal earlier data → negative).
36
+ */
37
+ export function panRangeTrading(range, fraction, provider) {
38
+ const span = provider.distance(range[0], range[1]);
39
+ const shift = fraction * span;
40
+ // Anchor on the endpoint being pushed toward its boundary and rebuild the
41
+ // other from the preserved span, so panning into *either* calendar edge stops
42
+ // (the window holds its trading width) rather than shrinking or collapsing.
43
+ if (shift <= 0) {
44
+ const start = provider.offset(range[0], shift);
45
+ return [start, provider.offset(start, span)];
46
+ }
47
+ const end = provider.offset(range[1], shift);
48
+ return [provider.offset(end, -span), end];
49
+ }
50
+ /**
51
+ * Zoom a **trading-time** range around `pivot` by `factor` (`< 1` in, `> 1` out),
52
+ * scaling the *trading* distance from the pivot to each endpoint so the pivot's
53
+ * on-screen position holds. Floors the visible trading time at `minLive`.
54
+ */
55
+ export function zoomRangeTrading(range, pivot, factor, provider, minLive = 1) {
56
+ const left = provider.distance(range[0], pivot); // trading-ms d0 → pivot (≥ 0)
57
+ const right = provider.distance(pivot, range[1]); // trading-ms pivot → d1 (≥ 0)
58
+ let nl = left * factor;
59
+ let nr = right * factor;
60
+ if (nl + nr < minLive) {
61
+ const total = left + right;
62
+ const frac = total > 0 ? left / total : 0.5;
63
+ nl = minLive * frac;
64
+ nr = minLive * (1 - frac);
65
+ }
66
+ let d0 = provider.offset(pivot, -nl);
67
+ let d1 = provider.offset(pivot, nr);
68
+ // If one side clamped at a calendar edge (couldn't extend as far as asked),
69
+ // give the shortfall to the other side so the visible trading span — and the
70
+ // `minLive` floor — is preserved. (The pivot's *fraction* can then drift at
71
+ // the edge: there is no trading time before the first / after the last session
72
+ // to hold it against.)
73
+ const shortLeft = nl - provider.distance(d0, pivot);
74
+ const shortRight = nr - provider.distance(pivot, d1);
75
+ if (shortLeft > 0)
76
+ d1 = provider.offset(pivot, nr + shortLeft);
77
+ else if (shortRight > 0)
78
+ d0 = provider.offset(pivot, -(nl + shortRight));
79
+ return [d0, d1];
80
+ }
30
81
  //# sourceMappingURL=viewport.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.40.0",
42
- "pond-ts": "^0.40.0",
41
+ "@pond-ts/react": "^0.42.0",
42
+ "pond-ts": "^0.42.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {