@pond-ts/charts 0.41.0 → 0.43.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,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.41.0",
3
+ "version": "0.43.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.41.0",
42
- "pond-ts": "^0.41.0",
41
+ "@pond-ts/react": "^0.43.0",
42
+ "pond-ts": "^0.43.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {