@pond-ts/charts 0.44.1 → 0.46.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,286 @@
1
+ const SEC_MS = 1_000;
2
+ const MIN_MS = 60_000;
3
+ const HOUR_MS = 3_600_000;
4
+ /** The sub-day rungs, finest first, with their clock step — the 1/5/15/30
5
+ * second and minute steps terminals use, then the hour steps. */
6
+ const SUB_DAY_GRAINS = [
7
+ { g: 'second1', step: 1 * SEC_MS },
8
+ { g: 'second5', step: 5 * SEC_MS },
9
+ { g: 'second15', step: 15 * SEC_MS },
10
+ { g: 'second30', step: 30 * SEC_MS },
11
+ { g: 'minute1', step: 1 * MIN_MS },
12
+ { g: 'minute5', step: 5 * MIN_MS },
13
+ { g: 'minute15', step: 15 * MIN_MS },
14
+ { g: 'minute30', step: 30 * MIN_MS },
15
+ { g: 'hour1', step: 1 * HOUR_MS },
16
+ { g: 'hour3', step: 3 * HOUR_MS },
17
+ { g: 'hour6', step: 6 * HOUR_MS },
18
+ { g: 'hour12', step: 12 * HOUR_MS },
19
+ ];
20
+ /** Whether `g` is one of the sub-day (clock-step) rungs. */
21
+ function isSubDay(g) {
22
+ return g !== 'day' && SUB_DAY_GRAINS.some((r) => r.g === g);
23
+ }
24
+ /**
25
+ * The local-time bucket key for `t` at grain `g` — two instants in the same
26
+ * day / week / month / quarter / year share a key. Local time (not UTC) so it
27
+ * agrees with the local `scaleTime` label formatter; the exchange's own time
28
+ * zone is unknown to the scale (the deferred refinement), and a session open
29
+ * sits well inside its local day, so runtime-local grouping matches the
30
+ * exchange day in every ordinary case. Hour grains are never bucketed (each
31
+ * anchor is its own tick), so they key by identity.
32
+ */
33
+ export function bucketKey(t, g) {
34
+ if (isSubDay(g))
35
+ return t;
36
+ const d = new Date(t);
37
+ switch (g) {
38
+ case 'day':
39
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
40
+ case 'week': {
41
+ const dow = (d.getDay() + 6) % 7; // 0 = Monday
42
+ // Local midnight of this week's Monday (Date normalizes a negative date).
43
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
44
+ }
45
+ case 'month':
46
+ return d.getFullYear() * 12 + d.getMonth();
47
+ case 'quarter':
48
+ return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
49
+ case 'year':
50
+ return d.getFullYear();
51
+ default:
52
+ return t;
53
+ }
54
+ }
55
+ /** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
56
+ function firstOfEachBucket(opens, g) {
57
+ const out = [];
58
+ let prev;
59
+ for (const t of opens) {
60
+ const k = bucketKey(t, g);
61
+ if (k !== prev) {
62
+ out.push(t);
63
+ prev = k;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ const COARSENING_LADDER = [
69
+ 'week',
70
+ 'month',
71
+ 'quarter',
72
+ 'year',
73
+ ];
74
+ /**
75
+ * Thin an ascending run of **session opens** down to about `count` axis ticks by
76
+ * **calendar grain** — the trading-terminal habit of labelling week / month /
77
+ * year starts rather than an arbitrary every-nth session. Picks the finest grain
78
+ * on the ladder (day → week → month → quarter → year) that yields at most
79
+ * `count` buckets and returns the first open in each; beyond yearly it decimates
80
+ * every-nth so the axis never crowds. Exported so the container can draw session
81
+ * dividers at the same instants the axis labels.
82
+ *
83
+ * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
84
+ * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
85
+ * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
86
+ * ticks). Callers size the cap to the room the labels have — the container
87
+ * derives it from plot width — rather than passing a small constant.
88
+ *
89
+ * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
90
+ * sub-day rungs.
91
+ */
92
+ export function coarsenCalendar(opens, count) {
93
+ if (opens.length <= count)
94
+ return { ticks: [...opens], granularity: 'day' };
95
+ for (const g of COARSENING_LADDER) {
96
+ const ticks = firstOfEachBucket(opens, g);
97
+ if (ticks.length <= count)
98
+ return { ticks, granularity: g };
99
+ }
100
+ // Coarser than yearly isn't a calendar grain — decimate the year starts.
101
+ const yearly = firstOfEachBucket(opens, 'year');
102
+ const step = Math.ceil(yearly.length / count);
103
+ return {
104
+ ticks: yearly.filter((_, i) => i % step === 0),
105
+ granularity: 'year',
106
+ };
107
+ }
108
+ /** The first clock-aligned `stepMs` multiple at or after `t`, relative to `t`'s
109
+ * own local midnight — so a 3-hour step lands on 00:00 / 03:00 / 06:00 local,
110
+ * whatever the session open was. Fixed-ms stepping from midnight, so on a DST
111
+ * transition day the later anchors drift off the wall-clock grid by the shift
112
+ * (labels stay truthful — they format the real instant); exchange-tz grain is
113
+ * the already-deferred refinement. */
114
+ function nextAligned(t, stepMs) {
115
+ const d = new Date(t);
116
+ const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
117
+ return midnight + Math.ceil((t - midnight) / stepMs) * stepMs;
118
+ }
119
+ /**
120
+ * The sub-day anchors at `stepMs`: each session open, plus each clock-aligned
121
+ * step instant strictly inside that session's **live** span. (The caller may
122
+ * still drop the very first anchor as a cramped lead — see {@link buildTicks}.) An instant is
123
+ * in-session iff live distance-then-offset round-trips it — so a lunch-break
124
+ * gap, an early close, or a collapsed overnight never gets an anchor, and no
125
+ * new provider surface is needed. Bails once `cap` is exceeded (the caller
126
+ * only needs to know the grain doesn't fit).
127
+ */
128
+ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
129
+ const out = [];
130
+ for (let i = 0; i < opens.length; i++) {
131
+ const open = opens[i];
132
+ const end = i + 1 < opens.length ? opens[i + 1] : domainEnd;
133
+ out.push(open);
134
+ for (let t = nextAligned(open + 1, stepMs); t < end; t += stepMs) {
135
+ if (provider.offset(open, provider.distance(open, t)) === t) {
136
+ out.push(t);
137
+ if (out.length > cap)
138
+ return out;
139
+ }
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * The full-ladder grain selection: given the provider, the domain, and the
146
+ * width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
147
+ * then day → week → month → quarter → year (then decimate) and return the
148
+ * first rung that fits.
149
+ * `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
150
+ * caller already has. Sub-day rungs are only reachable when the opens
151
+ * themselves fit — a year of daily sessions never wastes time generating hour
152
+ * anchors.
153
+ */
154
+ export function buildTicks(provider, opens, domainEnd, cap) {
155
+ const result = (() => {
156
+ if (opens.length <= cap) {
157
+ // Pick the clock rung from the **live-span estimate**, not the
158
+ // enumerated anchor count. On a live chart the domain slides every
159
+ // frame, and the number of aligned marks inside a sliding window
160
+ // oscillates ±1 with its phase — an exact count sitting at the cap
161
+ // flips the grain back and forth for single frames (the LiveSine
162
+ // flicker). The span is constant while sliding, so the estimate is
163
+ // stable; the enumerated count may then exceed the cap by a tick or
164
+ // two at some phases, which the per-tick pixel budget absorbs.
165
+ const liveSpan = provider.distance(opens[0], domainEnd);
166
+ for (const { g, step } of SUB_DAY_GRAINS) {
167
+ if (opens.length + Math.floor(liveSpan / step) > cap)
168
+ continue;
169
+ const ticks = stepAnchors(provider, opens, domainEnd, step, cap + 4);
170
+ // A clock rung must earn its labels: if it adds no intraday anchor
171
+ // beyond the opens themselves, it's really day grain (a row of
172
+ // "09:30"s under every session is a worse day axis, not a clock
173
+ // axis) — and every coarser rung would earn even less.
174
+ if (ticks.length > opens.length)
175
+ return { ticks, granularity: g };
176
+ break;
177
+ }
178
+ return { ticks: [...opens], granularity: 'day' };
179
+ }
180
+ return coarsenCalendar(opens, cap);
181
+ })();
182
+ // Round anchors to integer milliseconds: a pan/zoom domain comes from
183
+ // `scale.invert(pixel)` and is fractional, and a fractional anchor breaks
184
+ // the label pipeline — formatters pass through `new Date(ms)`, which
185
+ // truncates, so the instant no longer matches its own anchor set and the
186
+ // label falls through to the d3 multi-scale default (a bare `.259`
187
+ // millisecond tick). Sub-ms precision is invisible at any ladder grain.
188
+ result.ticks = result.ticks.map((t) => Math.round(t));
189
+ // Drop a cramped **leading partial-period** anchor: the first tick is the
190
+ // domain start, which usually sits mid-period (a "1Y back from today" view
191
+ // starts mid-month), so it can land arbitrarily close to the first full
192
+ // period start and the two labels collide (the classic "Jun 23Jul 07"
193
+ // pile-up). When the lead gap is under half a typical period (in **live**
194
+ // time, so a collapsed weekend doesn't fake a gap), the partial anchor
195
+ // isn't earning its label — the boundary row moves to the next tick.
196
+ const t = result.ticks;
197
+ if (t.length >= 3 &&
198
+ provider.distance(t[0], t[1]) < 0.5 * provider.distance(t[1], t[2])) {
199
+ t.shift();
200
+ }
201
+ return result;
202
+ }
203
+ /**
204
+ * The **boundary grain** for ticks at grain `g` — the next-coarser unit a
205
+ * tick's own label doesn't already carry, rendered as the axis's second label
206
+ * row. Clock labels (`14:00`) need the date; day/week labels (`Feb 02`)
207
+ * already carry the month, so they need only the year — as do month/quarter
208
+ * labels (`Feb`); a year label already says everything.
209
+ */
210
+ export function boundaryGrainFor(g) {
211
+ if (isSubDay(g))
212
+ return 'day';
213
+ switch (g) {
214
+ case 'day':
215
+ case 'week':
216
+ case 'month':
217
+ case 'quarter':
218
+ return 'year';
219
+ default:
220
+ return undefined;
221
+ }
222
+ }
223
+ /** d3 time-format specifier for the **major** (first-row) label at grain `g`. */
224
+ export function majorFormatFor(g) {
225
+ switch (g) {
226
+ case 'second1':
227
+ case 'second5':
228
+ case 'second15':
229
+ case 'second30':
230
+ return '%H:%M:%S';
231
+ case 'minute1':
232
+ case 'minute5':
233
+ case 'minute15':
234
+ case 'minute30':
235
+ case 'hour1':
236
+ case 'hour3':
237
+ case 'hour6':
238
+ case 'hour12':
239
+ return '%H:%M';
240
+ case 'day':
241
+ case 'week':
242
+ return '%b %d';
243
+ case 'month':
244
+ case 'quarter':
245
+ return '%b';
246
+ case 'year':
247
+ return '%Y';
248
+ }
249
+ }
250
+ /** d3 time-format specifier for the **boundary** (second-row) label at the
251
+ * boundary grain `g` — a date under clock ticks, the bare year under
252
+ * everything else. Never repeat a unit the first row already shows
253
+ * (`Jan 2026` under a `Jan 05` tick reads as noise). */
254
+ export function boundaryFormatFor(g) {
255
+ return g === 'day' ? '%b %d' : '%Y';
256
+ }
257
+ /**
258
+ * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
259
+ * whose boundary-grain bucket differs from the previous tick's — i.e. a
260
+ * **crossing**, the first tick of a new day / year. The first tick is *not*
261
+ * automatically flagged: the reader's left-edge context is the pinned
262
+ * {@link TradingTimeScale.boundaryContext} label (a property of the domain
263
+ * start, not of any tick — anchoring it to the first tick made it hop
264
+ * tick-to-tick on a live sliding window). Empty when the grain has no
265
+ * boundary row (year grain).
266
+ */
267
+ export function boundaryTicks(ticks, granularity, domainStart) {
268
+ const bg = boundaryGrainFor(granularity);
269
+ if (bg === undefined)
270
+ return [];
271
+ const out = [];
272
+ // Seed with the domain start's bucket when given: a first tick in a
273
+ // different period than the left edge IS a crossing (a 23:55-anchored
274
+ // window whose cramped 23:55 lead was dropped still marks 00:00 as the
275
+ // day turn); a first tick in the same period is not (no tick-hopping
276
+ // context on a live window).
277
+ let prev = domainStart !== undefined ? bucketKey(domainStart, bg) : undefined;
278
+ for (const t of ticks) {
279
+ const k = bucketKey(t, bg);
280
+ if (prev !== undefined && k !== prev)
281
+ out.push(t);
282
+ prev = k;
283
+ }
284
+ return out;
285
+ }
286
+ //# sourceMappingURL=tickLadder.js.map
@@ -47,12 +47,16 @@ export interface TradingCalendarLike {
47
47
  * wherever the container's `xScale` goes.
48
48
  *
49
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.
50
+ * walks the logical ladder (hour1 hour3 hour6 hour12 day → week →
51
+ * month quarter → year) and returns the finest rung that fits `count`, and
52
+ * `.tickFormat` labels each anchor at that grain (`%H:%M` for hours, `%b %d`
53
+ * for days/weeks, `%b` for months/quarters, `%Y` for years) while formatting
54
+ * any other instant (the cursor readout) with the d3 multi-scale default. The
55
+ * coarser context a label drops lives on `.tickBoundaries` — the second-row
56
+ * boundary labels (the date over a clock axis, the year over a day / week /
57
+ * month axis), one per boundary crossing plus the first tick.
58
+ * Without a provider `boundaries` method it falls back to interior even-spaced
59
+ * time ticks.
56
60
  *
57
61
  * **Out-of-domain behavior.** Within the calendar the scale extrapolates like a
58
62
  * normal scale — a live instant *before* the domain start maps to a negative
@@ -67,37 +71,45 @@ export interface TradingTimeScale {
67
71
  invert(pixel: number): number;
68
72
  ticks(count?: number): number[];
69
73
  tickFormat(count?: number, specifier?: string): (date: Date) => string;
74
+ /**
75
+ * The **second-row** (boundary) label for a tick value, or `undefined` for
76
+ * ticks that don't open a new boundary period. Same grain selection as
77
+ * {@link ticks} at the same `count`, so the rows agree: each tick starting
78
+ * a new day / year (whichever is the next-coarser unit the first-row label
79
+ * omits) carries the label — a **crossing**. The left-edge context (what
80
+ * period the domain starts in) is {@link boundaryContext}, pinned by the
81
+ * axis rather than riding a tick; year-grain ticks have no second row.
82
+ */
83
+ tickBoundaries(count?: number): (value: number) => string | undefined;
84
+ /**
85
+ * The boundary-row label for the **domain start** — the reader's left-edge
86
+ * context (`Jan 01` over an intraday axis, the year over a month axis),
87
+ * rendered pinned at the plot's left edge. A property of the domain, not of
88
+ * any tick — so it stays put on a live sliding window instead of hopping
89
+ * from tick to tick. `undefined` when the grain has no boundary row.
90
+ */
91
+ boundaryContext(count?: number): string | undefined;
70
92
  domain(): [number, number];
71
93
  domain(next: readonly [number, number]): TradingTimeScale;
72
94
  range(): [number, number];
73
95
  range(next: readonly [number, number]): TradingTimeScale;
74
96
  copy(): TradingTimeScale;
75
97
  }
76
- /** The calendar grain a run of session opens is bucketed to for axis ticks. */
77
- type TickGranularity = 'session' | 'week' | 'month' | 'quarter' | 'year';
98
+ export { coarsenCalendar } from './tickLadder.js';
99
+ export type { TickGranularity } from './tickLadder.js';
78
100
  /**
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
- * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
88
- * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
89
- * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
90
- * ticks). Callers size the cap to the room the labels have — the container
91
- * derives it from plot width — rather than passing a small constant.
101
+ * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
102
+ * time, and every local midnight is a "session open". Backing a plain
103
+ * continuous time axis with `scaleTradingTime(identityProvider())` runs it
104
+ * through the same logical tick ladder as a trading-calendar axis calendar
105
+ * days are the day anchors, so a year of data ticks on month starts and an
106
+ * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
107
+ * default.
92
108
  */
93
- export declare function coarsenCalendar(opens: readonly number[], count: number): {
94
- ticks: number[];
95
- granularity: TickGranularity;
96
- };
109
+ export declare function identityProvider(): DiscontinuityProvider;
97
110
  /**
98
111
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
99
112
  * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
100
113
  */
101
114
  export declare function scaleTradingTime(provider: DiscontinuityProvider): TradingTimeScale;
102
- export {};
103
115
  //# sourceMappingURL=tradingTimeScale.d.ts.map
@@ -1,79 +1,39 @@
1
1
  import { scaleTime } from 'd3-scale';
2
+ import { boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildTicks, majorFormatFor, } from './tickLadder.js';
3
+ // Grain selection lives in `tickLadder.ts` (the full hour1…year ladder plus
4
+ // the boundary-row helpers); re-exported here so existing imports keep working.
5
+ export { coarsenCalendar } from './tickLadder.js';
2
6
  /**
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.
7
+ * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
8
+ * time, and every local midnight is a "session open". Backing a plain
9
+ * continuous time axis with `scaleTradingTime(identityProvider())` runs it
10
+ * through the same logical tick ladder as a trading-calendar axis calendar
11
+ * days are the day anchors, so a year of data ticks on month starts and an
12
+ * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
13
+ * default.
9
14
  */
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
- * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
57
- * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
58
- * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
59
- * ticks). Callers size the cap to the room the labels have — the container
60
- * derives it from plot width — rather than passing a small constant.
61
- */
62
- export function coarsenCalendar(opens, count) {
63
- if (opens.length <= count)
64
- return { ticks: [...opens], granularity: 'session' };
65
- for (const g of COARSENING_LADDER) {
66
- const ticks = firstOfEachBucket(opens, g);
67
- if (ticks.length <= count)
68
- return { ticks, granularity: g };
69
- }
70
- // Coarser than yearly isn't a calendar grain — decimate the year starts.
71
- const yearly = firstOfEachBucket(opens, 'year');
72
- const step = Math.ceil(yearly.length / count);
73
- return {
74
- ticks: yearly.filter((_, i) => i % step === 0),
75
- granularity: 'year',
15
+ export function identityProvider() {
16
+ const self = {
17
+ clampUp: (t) => t,
18
+ clampDown: (t) => t,
19
+ distance: (from, to) => to - from,
20
+ offset: (v, amount) => v + amount,
21
+ copy: () => self,
22
+ boundaries: (from, to) => {
23
+ const out = [];
24
+ const d = new Date(from);
25
+ // First local midnight strictly after `from`; step by calendar day (not
26
+ // 24h) so DST transitions stay on midnight.
27
+ let cur = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
28
+ while (cur.getTime() < to) {
29
+ if (cur.getTime() > from)
30
+ out.push(cur.getTime());
31
+ cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
32
+ }
33
+ return out;
34
+ },
76
35
  };
36
+ return self;
77
37
  }
78
38
  /**
79
39
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
@@ -103,17 +63,35 @@ export function scaleTradingTime(provider) {
103
63
  const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
104
64
  return [domain[0], ...bounds];
105
65
  };
66
+ /** Whether the provider has calendar structure to ladder on. Without a
67
+ * `boundaries` method there are no anchors — the even-spacing fallback. */
68
+ const hasCalendar = () => provider.boundaries !== undefined;
69
+ /** The ladder result for this domain at `count` — the single source `ticks`,
70
+ * `tickFormat`, and `tickBoundaries` all derive from, so the three agree.
71
+ * Memoized on `(domain, count)`: the three callers (plus gridlines /
72
+ * dividers) hit the same resolution per render, and on a wide continuous
73
+ * domain re-walking every day-open is the expensive part. */
74
+ let laddered = null;
75
+ const resolved = (count) => {
76
+ const key = `${domain[0]}:${domain[1]}:${count}`;
77
+ if (laddered?.key !== key) {
78
+ laddered = {
79
+ key,
80
+ value: buildTicks(provider, sessionOpens(), domain[1], count),
81
+ };
82
+ }
83
+ return laddered.value;
84
+ };
106
85
  scale.ticks = (count = 10) => {
107
86
  const live = totalLive();
108
87
  if (live <= 0 || count < 1)
109
88
  return [domain[0]];
110
- const opens = sessionOpens();
111
- // Calendar-aware: label the session opens (each a new day), thinned to
112
- // week / month / year starts by grain rather than an arbitrary every-nth
113
- // session — the trading-terminal look.
114
- if (opens.length > 1)
115
- return coarsenCalendar(opens, count).ticks;
116
- // No boundaries (a single session / no calendar): fall back to interior
89
+ // Calendar-aware: walk the logical ladder over the session-open anchors
90
+ // (hour steps inside sessions, then day / week / month / quarter / year
91
+ // starts) the trading-terminal look, never an arbitrary every-nth.
92
+ if (hasCalendar())
93
+ return resolved(count).ticks;
94
+ // No boundaries (no calendar structure at all): fall back to interior
117
95
  // even-spaced ticks — endpoints excluded so none sits on the plot edge.
118
96
  const out = [];
119
97
  for (let i = 1; i < count; i++) {
@@ -124,20 +102,44 @@ export function scaleTradingTime(provider) {
124
102
  scale.tickFormat = (count = 10, specifier) => {
125
103
  if (specifier !== undefined)
126
104
  return base.tickFormat(count, specifier);
127
- const opens = sessionOpens();
128
105
  const defFmt = base.tickFormat(count);
129
- if (opens.length <= 1)
106
+ if (!hasCalendar())
130
107
  return defFmt; // no calendar → d3 multi-scale default
131
- // Anchor label at each coarsened session-open tick: a **date** (`%b %d`), or
132
- // the **year** when ticks are a year apart (a plain date would drop the year
133
- // the reader needs). Any other instant a cursor readout uses the d3
134
- // multi-scale default. Same grain as {@link ticks}, so labels and the
135
- // dividers drawn at these instants agree.
136
- const { ticks, granularity } = coarsenCalendar(opens, count);
108
+ // Anchor labels at the grain {@link ticks} chose one uniform format per
109
+ // grain (hours as `%H:%M`, days/weeks as `%b %d`, months/quarters as `%b`,
110
+ // years as `%Y`); the coarser context the label omits is the second row
111
+ // ({@link tickBoundaries}). Any other instant a cursor readout uses
112
+ // the d3 multi-scale default. Same grain as {@link ticks}, so labels and
113
+ // the dividers drawn at these instants agree.
114
+ const { ticks, granularity } = resolved(count);
137
115
  const anchors = new Set(ticks);
138
- const anchorFmt = base.tickFormat(count, granularity === 'year' ? '%Y' : '%b %d');
116
+ const anchorFmt = base.tickFormat(count, majorFormatFor(granularity));
139
117
  return (d) => (anchors.has(+d) ? anchorFmt(d) : defFmt(d));
140
118
  };
119
+ scale.tickBoundaries = (count = 10) => {
120
+ if (!hasCalendar())
121
+ return () => undefined;
122
+ const { ticks, granularity } = resolved(count);
123
+ const bg = boundaryGrainFor(granularity);
124
+ if (bg === undefined)
125
+ return () => undefined;
126
+ const fmt = base.tickFormat(count, boundaryFormatFor(bg));
127
+ const labelled = new Map();
128
+ for (const t of boundaryTicks(ticks, granularity, domain[0])) {
129
+ labelled.set(t, fmt(new Date(t)));
130
+ }
131
+ return (value) => labelled.get(value);
132
+ };
133
+ scale.boundaryContext = (count = 10) => {
134
+ if (!hasCalendar())
135
+ return undefined;
136
+ const { granularity } = resolved(count);
137
+ const bg = boundaryGrainFor(granularity);
138
+ if (bg === undefined)
139
+ return undefined;
140
+ const fmt = base.tickFormat(count, boundaryFormatFor(bg));
141
+ return fmt(new Date(domain[0]));
142
+ };
141
143
  function domainFn(next) {
142
144
  if (next === undefined)
143
145
  return [domain[0], domain[1]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.44.1",
3
+ "version": "0.46.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.44.0",
42
- "pond-ts": "^0.44.0",
41
+ "@pond-ts/react": "^0.46.0",
42
+ "pond-ts": "^0.46.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {