@pond-ts/charts 0.68.0 → 0.70.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.
@@ -1,3 +1,4 @@
1
+ import { TimeZone } from 'pond-ts';
1
2
  import type { DiscontinuityProvider } from './tradingTimeScale.js';
2
3
  /**
3
4
  * The logical tick ladder — grain selection for a time axis. Ticks sit on real
@@ -34,16 +35,62 @@ export type TickGranularity = 'second1' | 'second5' | 'second15' | 'second30' |
34
35
  export type TimeGrain = 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second';
35
36
  /** Collapse a {@link TickGranularity} to its coarse {@link TimeGrain} unit. */
36
37
  export declare function coarseUnitOf(g: TickGranularity): TimeGrain;
38
+ /** The calendar operations the tick ladder needs. Months are `1…12`. */
39
+ export interface TickCalendar {
40
+ /** Midnight of the day containing `t`. */
41
+ startOfDay(t: number): number;
42
+ /** Midnight of the day after the one containing `t`. */
43
+ nextDay(t: number): number;
44
+ /** Midnight of the Monday of the week containing `t`. */
45
+ startOfWeek(t: number): number;
46
+ /** The civil date of `t`. */
47
+ parts(t: number): {
48
+ year: number;
49
+ month: number;
50
+ day: number;
51
+ };
52
+ /** Midnight of the first of `month` (`1…12`; `13` carries into the next year, `0` into the previous). */
53
+ monthStart(year: number, month: number): number;
54
+ /** Days in `month` of `year`. */
55
+ daysInMonth(year: number, month: number): number;
56
+ /**
57
+ * The first clock-aligned `stepMs` multiple at or after `t`, counted from
58
+ * `t`'s own midnight — 00:00 / 03:00 / 06:00 … for a 3 h step. The local
59
+ * calendar steps **fixed milliseconds** from midnight (so on a DST day the
60
+ * later anchors drift off the wall clock by the shift, until the next
61
+ * midnight re-anchors — the pre-seam behaviour, kept); a zoned calendar
62
+ * aligns to the **wall clock**, so 6 h anchors read 00 / 06 / 12 / 18 on
63
+ * both sides of the jump, with one short or long gap at the transition.
64
+ * Always `>= t`.
65
+ */
66
+ nextAligned(t: number, stepMs: number): number;
67
+ /**
68
+ * The anchor after an aligned anchor `t` at `stepMs`. The local calendar
69
+ * steps `t + stepMs` — fixed elapsed milliseconds, the pre-seam loop
70
+ * verbatim, which is what keeps a session that spans a DST midnight (a
71
+ * futures 18:00 → 17:00 session) ticking exactly as it did. A zoned
72
+ * calendar re-aligns through {@link nextAligned} so the anchors stay on
73
+ * the wall clock across the jump.
74
+ */
75
+ nextAnchor(t: number, stepMs: number): number;
76
+ }
77
+ /** The runtime-local calendar — `Date`'s local accessors, exactly as the
78
+ * ladder computed before it had a zone. The default {@link TickCalendar}. */
79
+ export declare const localTickCalendar: TickCalendar;
80
+ /** A {@link TickCalendar} for an IANA zone, on core's `TimeZone`. */
81
+ export declare function zonedTickCalendar(zone: TimeZone): TickCalendar;
82
+ /** Resolve an optional IANA id to the calendar the ladder should use: the
83
+ * runtime-local calendar when `timeZone` is undefined, else the zone's. */
84
+ export declare function tickCalendarFor(timeZone: string | undefined): TickCalendar;
37
85
  /**
38
- * The local-time bucket key for `t` at grain `g` — two instants in the same
39
- * day / week / month / quarter / year share a key. Local time (not UTC) so it
40
- * agrees with the local `scaleTime` label formatter; the exchange's own time
41
- * zone is unknown to the scale (the deferred refinement), and a session open
42
- * sits well inside its local day, so runtime-local grouping matches the
43
- * exchange day in every ordinary case. Hour grains are never bucketed (each
44
- * anchor is its own tick), so they key by identity.
86
+ * The calendar bucket key for `t` at grain `g` — two instants in the same
87
+ * day / week / month / quarter / year share a key. Computed in `cal`'s zone
88
+ * (runtime-local by default) so it agrees with the label formatter for the
89
+ * same zone; a trading axis passes its exchange zone so the grain buckets by
90
+ * the exchange day rather than the viewer's. Hour grains are never bucketed
91
+ * (each anchor is its own tick), so they key by identity.
45
92
  */
46
- export declare function bucketKey(t: number, g: TickGranularity): number;
93
+ export declare function bucketKey(t: number, g: TickGranularity, cal?: TickCalendar): number;
47
94
  /**
48
95
  * Thin an ascending run of **session opens** down to about `count` axis ticks.
49
96
  * Picks the finest rung: every session → **per-month uniform session stride**
@@ -82,7 +129,7 @@ export declare function bucketKey(t: number, g: TickGranularity): number;
82
129
  * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
83
130
  * sub-day rungs.
84
131
  */
85
- export declare function coarsenCalendar(opens: readonly number[], count: number, spanDays?: number, provider?: DiscontinuityProvider): {
132
+ export declare function coarsenCalendar(opens: readonly number[], count: number, spanDays?: number, provider?: DiscontinuityProvider, cal?: TickCalendar): {
86
133
  ticks: number[];
87
134
  granularity: TickGranularity;
88
135
  };
@@ -105,7 +152,7 @@ export declare function coarsenCalendar(opens: readonly number[], count: number,
105
152
  * the live-span estimate first (like {@link buildTicks}) and skipped when they
106
153
  * add no anchor beyond the session opens themselves (that is the day level).
107
154
  */
108
- export declare function buildGridLevels(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): Array<{
155
+ export declare function buildGridLevels(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number, cal?: TickCalendar): Array<{
109
156
  granularity: TickGranularity;
110
157
  values: number[];
111
158
  }>;
@@ -130,7 +177,7 @@ export declare function nominalStepMs(g: TickGranularity): number;
130
177
  * themselves fit — a year of daily sessions never wastes time generating hour
131
178
  * anchors.
132
179
  */
133
- export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): {
180
+ export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number, cal?: TickCalendar): {
134
181
  ticks: number[];
135
182
  granularity: TickGranularity;
136
183
  };
@@ -181,14 +228,13 @@ export declare function bandFormatFor(g: TickGranularity): string;
181
228
  * same-shade day-bands side by side on a gappy calendar. A rare, cosmetic
182
229
  * consequence of keeping the shade fixed to the date rather than the slot.
183
230
  */
184
- export declare function bandShaded(t: number, g: TickGranularity): boolean;
185
- /** The local-time start of the band grain `g` containing `t` (the band's left
186
- * edge): local midnight, month start, or Jan 1. Through the Date ctor so DST
187
- * and month/year overflow normalize correctly. */
188
- export declare function bandStartOf(t: number, g: TickGranularity): number;
231
+ export declare function bandShaded(t: number, g: TickGranularity, cal?: TickCalendar): boolean;
232
+ /** The start of the band grain `g` containing `t` (the band's left edge) in
233
+ * `cal`'s zone: midnight, month start, or Jan 1. */
234
+ export declare function bandStartOf(t: number, g: TickGranularity, cal?: TickCalendar): number;
189
235
  /** The start of the band grain `g` **after** the one containing `t` — the next
190
- * local midnight / month start / Jan 1. */
191
- export declare function bandNext(t: number, g: TickGranularity): number;
236
+ * midnight / month start / Jan 1 in `cal`'s zone. */
237
+ export declare function bandNext(t: number, g: TickGranularity, cal?: TickCalendar): number;
192
238
  /**
193
239
  * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
194
240
  * whose boundary-grain bucket differs from the previous tick's — i.e. a
@@ -199,7 +245,7 @@ export declare function bandNext(t: number, g: TickGranularity): number;
199
245
  * tick-to-tick on a live sliding window). Empty when the grain has no
200
246
  * boundary row (year grain).
201
247
  */
202
- export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): number[];
248
+ export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number, cal?: TickCalendar): number[];
203
249
  /**
204
250
  * The terse **base** (non-promoted) flat label format for grain `g` — the label
205
251
  * a tick carries when it opens no coarser period: the clock time for a sub-day
@@ -228,5 +274,5 @@ export declare function flatBaseFormatFor(g: TickGranularity): string;
228
274
  * opening at May 1 midnight reads `May 16 …`, not `1 16 …`). Without
229
275
  * `domainStart` the first tick is never promoted.
230
276
  */
231
- export declare function flatFormats(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): string[];
277
+ export declare function flatFormats(ticks: readonly number[], granularity: TickGranularity, domainStart?: number, cal?: TickCalendar): string[];
232
278
  //# sourceMappingURL=tickLadder.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { TimeZone } from 'pond-ts';
1
2
  /** Collapse a {@link TickGranularity} to its coarse {@link TimeGrain} unit. */
2
3
  export function coarseUnitOf(g) {
3
4
  if (g.startsWith('second'))
@@ -32,43 +33,131 @@ const SUB_DAY_GRAINS = [
32
33
  function isSubDay(g) {
33
34
  return g !== 'day' && SUB_DAY_GRAINS.some((r) => r.g === g);
34
35
  }
36
+ /** The runtime-local calendar — `Date`'s local accessors, exactly as the
37
+ * ladder computed before it had a zone. The default {@link TickCalendar}. */
38
+ export const localTickCalendar = {
39
+ startOfDay: (t) => {
40
+ const d = new Date(t);
41
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
42
+ },
43
+ nextDay: (t) => {
44
+ const d = new Date(t);
45
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
46
+ },
47
+ startOfWeek: (t) => {
48
+ const d = new Date(t);
49
+ const dow = (d.getDay() + 6) % 7; // 0 = Monday
50
+ // Local midnight of this week's Monday (Date normalizes a negative date).
51
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
52
+ },
53
+ parts: (t) => {
54
+ const d = new Date(t);
55
+ return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
56
+ },
57
+ monthStart: (year, month) => new Date(year, month - 1, 1).getTime(),
58
+ daysInMonth: (year, month) => new Date(year, month, 0).getDate(),
59
+ nextAligned: (t, stepMs) => {
60
+ const d = new Date(t);
61
+ const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
62
+ return midnight + Math.ceil((t - midnight) / stepMs) * stepMs;
63
+ },
64
+ nextAnchor: (t, stepMs) => t + stepMs,
65
+ };
66
+ /** A {@link TickCalendar} for an IANA zone, on core's `TimeZone`. */
67
+ export function zonedTickCalendar(zone) {
68
+ const monthStart = (year, month) => {
69
+ // Normalise an overflowed month the way the Date constructor does.
70
+ const total = year * 12 + (month - 1);
71
+ const y = Math.floor(total / 12);
72
+ const m = total - y * 12 + 1;
73
+ return zone.instant({ year: y, month: m, day: 1 });
74
+ };
75
+ return {
76
+ startOfDay: (t) => zone.startOf('day', t),
77
+ nextDay: (t) => zone.next('day', t),
78
+ startOfWeek: (t) => zone.startOf('week', t),
79
+ parts: (t) => {
80
+ const p = zone.parts(t);
81
+ return { year: p.year, month: p.month, day: p.day };
82
+ },
83
+ monthStart,
84
+ daysInMonth: (year, month) => Math.round((monthStart(year, month + 1) - monthStart(year, month)) / DAY_MS),
85
+ nextAligned: zonedNextAligned,
86
+ nextAnchor: (t, stepMs) => zonedNextAligned(t + 1, stepMs),
87
+ };
88
+ function zonedNextAligned(t, stepMs) {
89
+ {
90
+ const p = zone.parts(t);
91
+ const sinceMidnight = ((p.hour * 60 + p.minute) * 60 + p.second) * 1000 + p.millisecond;
92
+ // Walk wall-clock multiples of the step until one resolves at or after
93
+ // `t`: a repeated hour (fall-back) can resolve a wall time to its
94
+ // earlier reading, which sits before a `t` in the later one.
95
+ for (let k = Math.ceil(sinceMidnight / stepMs);; k += 1) {
96
+ const target = k * stepMs;
97
+ if (target >= DAY_MS)
98
+ return zone.next('day', t);
99
+ const hour = Math.floor(target / HOUR_MS);
100
+ const minute = Math.floor((target - hour * HOUR_MS) / MIN_MS);
101
+ const second = Math.floor((target % MIN_MS) / SEC_MS);
102
+ const millisecond = target % SEC_MS;
103
+ const at = zone.instant({
104
+ year: p.year,
105
+ month: p.month,
106
+ day: p.day,
107
+ hour,
108
+ minute,
109
+ second,
110
+ millisecond,
111
+ });
112
+ if (at >= t)
113
+ return at;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ /** Resolve an optional IANA id to the calendar the ladder should use: the
119
+ * runtime-local calendar when `timeZone` is undefined, else the zone's. */
120
+ export function tickCalendarFor(timeZone) {
121
+ return timeZone === undefined
122
+ ? localTickCalendar
123
+ : zonedTickCalendar(TimeZone.of(timeZone));
124
+ }
35
125
  /**
36
- * The local-time bucket key for `t` at grain `g` — two instants in the same
37
- * day / week / month / quarter / year share a key. Local time (not UTC) so it
38
- * agrees with the local `scaleTime` label formatter; the exchange's own time
39
- * zone is unknown to the scale (the deferred refinement), and a session open
40
- * sits well inside its local day, so runtime-local grouping matches the
41
- * exchange day in every ordinary case. Hour grains are never bucketed (each
42
- * anchor is its own tick), so they key by identity.
126
+ * The calendar bucket key for `t` at grain `g` — two instants in the same
127
+ * day / week / month / quarter / year share a key. Computed in `cal`'s zone
128
+ * (runtime-local by default) so it agrees with the label formatter for the
129
+ * same zone; a trading axis passes its exchange zone so the grain buckets by
130
+ * the exchange day rather than the viewer's. Hour grains are never bucketed
131
+ * (each anchor is its own tick), so they key by identity.
43
132
  */
44
- export function bucketKey(t, g) {
133
+ export function bucketKey(t, g, cal = localTickCalendar) {
45
134
  if (isSubDay(g))
46
135
  return t;
47
- const d = new Date(t);
48
136
  switch (g) {
49
137
  case 'day':
50
- return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
51
- case 'week': {
52
- const dow = (d.getDay() + 6) % 7; // 0 = Monday
53
- // Local midnight of this week's Monday (Date normalizes a negative date).
54
- return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
138
+ return cal.startOfDay(t);
139
+ case 'week':
140
+ return cal.startOfWeek(t);
141
+ case 'month': {
142
+ const p = cal.parts(t);
143
+ return p.year * 12 + (p.month - 1);
144
+ }
145
+ case 'quarter': {
146
+ const p = cal.parts(t);
147
+ return p.year * 4 + Math.floor((p.month - 1) / 3);
55
148
  }
56
- case 'month':
57
- return d.getFullYear() * 12 + d.getMonth();
58
- case 'quarter':
59
- return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
60
149
  case 'year':
61
- return d.getFullYear();
150
+ return cal.parts(t).year;
62
151
  default:
63
152
  return t;
64
153
  }
65
154
  }
66
155
  /** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
67
- function firstOfEachBucket(opens, g) {
156
+ function firstOfEachBucket(opens, g, cal) {
68
157
  const out = [];
69
158
  let prev;
70
159
  for (const t of opens) {
71
- const k = bucketKey(t, g);
160
+ const k = bucketKey(t, g, cal);
72
161
  if (k !== prev) {
73
162
  out.push(t);
74
163
  prev = k;
@@ -84,10 +173,6 @@ const COARSENING_LADDER = [
84
173
  /** Nominal days per month — the band gate: the session-stride band applies
85
174
  * while a nominal month still affords ≥ 2 marks at the span-derived budget. */
86
175
  const DAYS_PER_MONTH = 30.44;
87
- /** Days in the local month containing `d`. */
88
- function daysInLocalMonth(d) {
89
- return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
90
- }
91
176
  /**
92
177
  * Whether index `i` marks in a month of `n` sessions/days at stride `k`. Two
93
178
  * regimes, split on `m = floor(n / k)` — the whole stride-intervals the month
@@ -134,15 +219,15 @@ function monthMark(i, k, n) {
134
219
  * first open has no known predecessor and marks only on its own exact day — a
135
220
  * snapped edge mark would appear/disappear with the pan phase.)
136
221
  */
137
- function subdivideMonthsByDay(opens, gapDays) {
222
+ function subdivideMonthsByDay(opens, gapDays, cal) {
138
223
  const k = Math.max(2, Math.ceil(gapDays - 1e-9));
139
224
  const out = [];
140
225
  let prev = null;
141
226
  for (const t of opens) {
142
- const d = new Date(t);
143
- const month = d.getFullYear() * 12 + d.getMonth();
144
- const dom = d.getDate();
145
- const monthLen = daysInLocalMonth(d);
227
+ const p = cal.parts(t);
228
+ const month = p.year * 12 + (p.month - 1);
229
+ const dom = p.day;
230
+ const monthLen = cal.daysInMonth(p.year, p.month);
146
231
  // Scan window: same month → since the previous open's date; the first
147
232
  // open of a new month → from day 1 (a weekend month-start snaps here);
148
233
  // the window's first open → its own exact day only (see doc above).
@@ -193,12 +278,13 @@ function subdivideMonthsByDay(opens, gapDays) {
193
278
  * never report it — is detected by live-time flow and indexed 0, keeping the
194
279
  * world-start month pinned.
195
280
  */
196
- function subdivideMonthsBySession(opens, gapDays, provider) {
281
+ function subdivideMonthsBySession(opens, gapDays, provider, cal) {
197
282
  const monthKeyOf = (t) => {
198
- const d = new Date(t);
199
- return d.getFullYear() * 12 + d.getMonth();
283
+ const p = cal.parts(t);
284
+ return p.year * 12 + (p.month - 1);
200
285
  };
201
- const monthStartOf = (key) => new Date(Math.floor(key / 12), key % 12, 1).getTime();
286
+ const monthStartOf = (key) => cal.monthStart(Math.floor(key / 12), (key % 12) + 1);
287
+ const dayOf = (t) => cal.parts(t).day;
202
288
  const strideOf = (count, extentDays) => Math.max(2, Math.ceil((gapDays * count) / Math.max(1, extentDays) - 1e-9));
203
289
  // Full-month roster: session count + the per-month stride its calendar-day
204
290
  // extent affords. Cached per call; ≤ (visible months + 1) provider queries,
@@ -211,9 +297,7 @@ function subdivideMonthsBySession(opens, gapDays, provider) {
211
297
  const count = Math.max(1, sessions.length);
212
298
  const extentDays = sessions.length === 0
213
299
  ? 0
214
- : new Date(sessions[sessions.length - 1]).getDate() -
215
- new Date(sessions[0]).getDate() +
216
- 1;
300
+ : dayOf(sessions[sessions.length - 1]) - dayOf(sessions[0]) + 1;
217
301
  r = { count, stride: strideOf(count, extentDays) };
218
302
  rosters.set(key, r);
219
303
  }
@@ -236,13 +320,11 @@ function subdivideMonthsBySession(opens, gapDays, provider) {
236
320
  // roster, detected by live-time flow — it is the month's session 0.
237
321
  const worldStart = !exact && pre === 0 && provider.distance(t, t + MIN_MS) > 0;
238
322
  const count = Math.max(1, sessions.length + (worldStart ? 1 : 0));
239
- const rosterFirstDom = sessions.length > 0 ? new Date(sessions[0]).getDate() : 31;
323
+ const rosterFirstDom = sessions.length > 0 ? dayOf(sessions[0]) : 31;
240
324
  const firstDom = worldStart
241
- ? Math.min(new Date(t).getDate(), rosterFirstDom)
325
+ ? Math.min(dayOf(t), rosterFirstDom)
242
326
  : rosterFirstDom;
243
- const lastDom = sessions.length > 0
244
- ? new Date(sessions[sessions.length - 1]).getDate()
245
- : new Date(t).getDate();
327
+ const lastDom = sessions.length > 0 ? dayOf(sessions[sessions.length - 1]) : dayOf(t);
246
328
  const stride = strideOf(count, lastDom - firstDom + 1);
247
329
  rosters.set(key, { count, stride });
248
330
  if ((exact || worldStart) && monthMark(pre, stride, count)) {
@@ -301,7 +383,7 @@ function subdivideMonthsBySession(opens, gapDays, provider) {
301
383
  * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
302
384
  * sub-day rungs.
303
385
  */
304
- export function coarsenCalendar(opens, count, spanDays, provider) {
386
+ export function coarsenCalendar(opens, count, spanDays, provider, cal = localTickCalendar) {
305
387
  if (opens.length <= count)
306
388
  return { ticks: [...opens], granularity: 'day' };
307
389
  // Per-month uniform session stride. Band-gated to spans where a nominal
@@ -313,18 +395,18 @@ export function coarsenCalendar(opens, count, spanDays, provider) {
313
395
  const gapDays = (spanDays ?? openSpanDays) / count;
314
396
  if (dailyDense && DAYS_PER_MONTH / gapDays >= 2) {
315
397
  const ticks = provider?.boundaries !== undefined
316
- ? subdivideMonthsBySession(opens, gapDays, provider)
317
- : subdivideMonthsByDay(opens, gapDays);
398
+ ? subdivideMonthsBySession(opens, gapDays, provider, cal)
399
+ : subdivideMonthsByDay(opens, gapDays, cal);
318
400
  if (ticks.length > 0)
319
401
  return { ticks, granularity: 'day' };
320
402
  }
321
403
  for (const g of COARSENING_LADDER) {
322
- const ticks = firstOfEachBucket(opens, g);
404
+ const ticks = firstOfEachBucket(opens, g, cal);
323
405
  if (ticks.length <= count)
324
406
  return { ticks, granularity: g };
325
407
  }
326
408
  // Coarser than yearly isn't a calendar grain — decimate the year starts.
327
- const yearly = firstOfEachBucket(opens, 'year');
409
+ const yearly = firstOfEachBucket(opens, 'year', cal);
328
410
  const step = Math.ceil(yearly.length / count);
329
411
  return {
330
412
  ticks: yearly.filter((_, i) => i % step === 0),
@@ -350,7 +432,7 @@ export function coarsenCalendar(opens, count, spanDays, provider) {
350
432
  * the live-span estimate first (like {@link buildTicks}) and skipped when they
351
433
  * add no anchor beyond the session opens themselves (that is the day level).
352
434
  */
353
- export function buildGridLevels(provider, opens, domainEnd, cap) {
435
+ export function buildGridLevels(provider, opens, domainEnd, cap, cal = localTickCalendar) {
354
436
  const out = [];
355
437
  if (cap < 1 || opens.length === 0)
356
438
  return out;
@@ -359,7 +441,7 @@ export function buildGridLevels(provider, opens, domainEnd, cap) {
359
441
  if (opens.length + Math.floor(liveSpan / step) > cap)
360
442
  continue;
361
443
  const budget = cap + opens.length + 4;
362
- const anchors = stepAnchors(provider, opens, domainEnd, step, budget);
444
+ const anchors = stepAnchors(provider, opens, domainEnd, step, budget, cal);
363
445
  if (anchors.length > budget)
364
446
  continue;
365
447
  if (anchors.length > opens.length) {
@@ -370,7 +452,7 @@ export function buildGridLevels(provider, opens, domainEnd, cap) {
370
452
  out.push({ granularity: 'day', values: [...opens] });
371
453
  }
372
454
  for (const g of COARSENING_LADDER) {
373
- const values = firstOfEachBucket(opens, g);
455
+ const values = firstOfEachBucket(opens, g, cal);
374
456
  if (values.length <= cap)
375
457
  out.push({ granularity: g, values });
376
458
  }
@@ -407,52 +489,41 @@ export function nominalStepMs(g) {
407
489
  /**
408
490
  * Whether `t` sits exactly on a calendar instant of grain `g` — a local
409
491
  * midnight at day grain, a month / quarter / year start, a clock-aligned
410
- * step multiple (relative to `t`'s own local midnight, the same convention
411
- * as {@link nextAligned}) on the sub-day rungs. The window-edge genuineness
492
+ * step multiple (relative to `t`'s own midnight, the calendar's
493
+ * `nextAligned` convention) on the sub-day rungs. The window-edge genuineness
412
494
  * test in {@link buildTicks} — the only way a **continuous** axis's edge tick
413
495
  * survives, since a gap-free provider has no dead time to probe.
414
496
  *
415
- * The sub-day test shares {@link nextAligned}'s **fixed-elapsed-ms** rung
497
+ * The sub-day test shares the local calendar's **fixed-elapsed-ms** rung
416
498
  * convention **by design** — so an edge tick is judged aligned iff it is one
417
499
  * of the instants {@link stepAnchors} would actually generate. On the two DST
418
500
  * transition days a wall-clock `03:00` is then *not* "aligned" to `hour3`
419
501
  * (only 2h elapsed since midnight) while `04:00` is — the same drift the
420
- * anchors themselves take, and the already-deferred exchange-tz grain
421
- * refinement (see `nextAligned`), not a fresh inconsistency. It only decides
502
+ * anchors themselves take (a zoned calendar aligns to the wall clock instead),
503
+ * not a fresh inconsistency. It only decides
422
504
  * whether the *window-edge* tick is kept on those days — nil practical impact
423
505
  * (Codex review, #479).
424
506
  */
425
- function alignedToGrain(t, g) {
426
- const d = new Date(t);
427
- const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
507
+ function alignedToGrain(t, g, cal) {
508
+ const midnight = cal.startOfDay(t);
428
509
  const sub = SUB_DAY_GRAINS.find((r) => r.g === g);
429
510
  if (sub !== undefined)
430
- return (t - midnight) % sub.step === 0;
511
+ return cal.nextAligned(t, sub.step) === t;
431
512
  if (t !== midnight)
432
513
  return false;
514
+ const p = cal.parts(t);
433
515
  switch (g) {
434
516
  case 'month':
435
- return d.getDate() === 1;
517
+ return p.day === 1;
436
518
  case 'quarter':
437
- return d.getDate() === 1 && d.getMonth() % 3 === 0;
519
+ return p.day === 1 && (p.month - 1) % 3 === 0;
438
520
  case 'year':
439
- return d.getDate() === 1 && d.getMonth() === 0;
521
+ return p.day === 1 && p.month === 1;
440
522
  default:
441
523
  // 'day' (and the unreachable 'week' — there is no week rung).
442
524
  return true;
443
525
  }
444
526
  }
445
- /** The first clock-aligned `stepMs` multiple at or after `t`, relative to `t`'s
446
- * own local midnight — so a 3-hour step lands on 00:00 / 03:00 / 06:00 local,
447
- * whatever the session open was. Fixed-ms stepping from midnight, so on a DST
448
- * transition day the later anchors drift off the wall-clock grid by the shift
449
- * (labels stay truthful — they format the real instant); exchange-tz grain is
450
- * the already-deferred refinement. */
451
- function nextAligned(t, stepMs) {
452
- const d = new Date(t);
453
- const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
454
- return midnight + Math.ceil((t - midnight) / stepMs) * stepMs;
455
- }
456
527
  /**
457
528
  * The sub-day anchors at `stepMs`: each session open, plus each clock-aligned
458
529
  * step instant strictly inside that session's **live** span. (The caller may
@@ -462,13 +533,16 @@ function nextAligned(t, stepMs) {
462
533
  * new provider surface is needed. Bails once `cap` is exceeded (the caller
463
534
  * only needs to know the grain doesn't fit).
464
535
  */
465
- function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
536
+ function stepAnchors(provider, opens, domainEnd, stepMs, cap, cal) {
466
537
  const out = [];
467
538
  for (let i = 0; i < opens.length; i++) {
468
539
  const open = opens[i];
469
540
  const end = i + 1 < opens.length ? opens[i + 1] : domainEnd;
470
541
  out.push(open);
471
- for (let t = nextAligned(open + 1, stepMs); t < end; t += stepMs) {
542
+ // Stepping is the calendar's: local is `t + stepMs` (the pre-seam loop,
543
+ // so a session spanning a DST midnight ticks exactly as before), zoned
544
+ // re-aligns each anchor to the wall clock across a DST jump.
545
+ for (let t = cal.nextAligned(open + 1, stepMs); t < end; t = cal.nextAnchor(t, stepMs)) {
472
546
  if (provider.offset(open, provider.distance(open, t)) === t) {
473
547
  out.push(t);
474
548
  if (out.length > cap)
@@ -488,7 +562,7 @@ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
488
562
  * themselves fit — a year of daily sessions never wastes time generating hour
489
563
  * anchors.
490
564
  */
491
- export function buildTicks(provider, opens, domainEnd, cap) {
565
+ export function buildTicks(provider, opens, domainEnd, cap, cal = localTickCalendar) {
492
566
  const result = (() => {
493
567
  if (opens.length <= cap) {
494
568
  // Pick the clock rung from the **live-span estimate**, not the
@@ -515,7 +589,7 @@ export function buildTicks(provider, opens, domainEnd, cap) {
515
589
  // phase ticks), try the coarser rungs, whose smaller estimates
516
590
  // leave the budget room to finish.
517
591
  const budget = cap + opens.length + 4;
518
- const ticks = stepAnchors(provider, opens, domainEnd, step, budget);
592
+ const ticks = stepAnchors(provider, opens, domainEnd, step, budget, cal);
519
593
  if (ticks.length > budget)
520
594
  continue;
521
595
  // A clock rung must earn its labels: if it adds no intraday anchor
@@ -534,7 +608,7 @@ export function buildTicks(provider, opens, domainEnd, cap) {
534
608
  // put instead of reshuffling when the enumerated open count wobbles ±1.
535
609
  // The provider routes the day band to session-index space (screen-even
536
610
  // marks; see subdivideMonthsBySession).
537
- return coarsenCalendar(opens, cap, (domainEnd - opens[0]) / DAY_MS, provider);
611
+ return coarsenCalendar(opens, cap, (domainEnd - opens[0]) / DAY_MS, provider, cal);
538
612
  })();
539
613
  // Round anchors to integer milliseconds: a pan/zoom domain comes from
540
614
  // `scale.invert(pixel)` and is fractional, and a fractional anchor breaks
@@ -564,7 +638,7 @@ export function buildTicks(provider, opens, domainEnd, cap) {
564
638
  const edge = t[0];
565
639
  const genuine = provider.distance(edge, edge + 1) > 0 && // live (a dead edge never ticks)
566
640
  (provider.distance(edge - 1, edge) === 0 || // a session open, or…
567
- alignedToGrain(edge, result.granularity)); // …exactly on the grain
641
+ alignedToGrain(edge, result.granularity, cal)); // …exactly on the grain
568
642
  if (!genuine)
569
643
  t.shift();
570
644
  }
@@ -716,42 +790,41 @@ export function bandFormatFor(g) {
716
790
  * same-shade day-bands side by side on a gappy calendar. A rare, cosmetic
717
791
  * consequence of keeping the shade fixed to the date rather than the slot.
718
792
  */
719
- export function bandShaded(t, g) {
720
- const d = new Date(t);
793
+ export function bandShaded(t, g, cal = localTickCalendar) {
794
+ const p = cal.parts(t);
721
795
  let index;
722
796
  switch (g) {
723
797
  case 'day':
724
- // UTC of the local Y/M/D — an integer calendar-day count, DST-immune.
725
- index = Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000);
798
+ // UTC of the calendar Y/M/D — an integer calendar-day count, DST-immune.
799
+ index = Math.floor(Date.UTC(p.year, p.month - 1, p.day) / 86_400_000);
726
800
  break;
727
801
  case 'month':
728
- index = d.getFullYear() * 12 + d.getMonth();
802
+ index = p.year * 12 + (p.month - 1);
729
803
  break;
730
804
  default: // year
731
- index = d.getFullYear();
805
+ index = p.year;
732
806
  }
733
807
  return ((index % 2) + 2) % 2 === 1;
734
808
  }
735
- /** The local-time start of the band grain `g` containing `t` (the band's left
736
- * edge): local midnight, month start, or Jan 1. Through the Date ctor so DST
737
- * and month/year overflow normalize correctly. */
738
- export function bandStartOf(t, g) {
739
- const d = new Date(t);
809
+ /** The start of the band grain `g` containing `t` (the band's left edge) in
810
+ * `cal`'s zone: midnight, month start, or Jan 1. */
811
+ export function bandStartOf(t, g, cal = localTickCalendar) {
740
812
  if (g === 'day')
741
- return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
813
+ return cal.startOfDay(t);
814
+ const p = cal.parts(t);
742
815
  if (g === 'month')
743
- return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
744
- return new Date(d.getFullYear(), 0, 1).getTime(); // year
816
+ return cal.monthStart(p.year, p.month);
817
+ return cal.monthStart(p.year, 1); // year
745
818
  }
746
819
  /** The start of the band grain `g` **after** the one containing `t` — the next
747
- * local midnight / month start / Jan 1. */
748
- export function bandNext(t, g) {
749
- const d = new Date(t);
820
+ * midnight / month start / Jan 1 in `cal`'s zone. */
821
+ export function bandNext(t, g, cal = localTickCalendar) {
750
822
  if (g === 'day')
751
- return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
823
+ return cal.nextDay(t);
824
+ const p = cal.parts(t);
752
825
  if (g === 'month')
753
- return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
754
- return new Date(d.getFullYear() + 1, 0, 1).getTime(); // year
826
+ return cal.monthStart(p.year, p.month + 1);
827
+ return cal.monthStart(p.year + 1, 1); // year
755
828
  }
756
829
  /**
757
830
  * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
@@ -763,7 +836,7 @@ export function bandNext(t, g) {
763
836
  * tick-to-tick on a live sliding window). Empty when the grain has no
764
837
  * boundary row (year grain).
765
838
  */
766
- export function boundaryTicks(ticks, granularity, domainStart) {
839
+ export function boundaryTicks(ticks, granularity, domainStart, cal = localTickCalendar) {
767
840
  const bg = boundaryGrainFor(granularity);
768
841
  if (bg === undefined)
769
842
  return [];
@@ -773,9 +846,9 @@ export function boundaryTicks(ticks, granularity, domainStart) {
773
846
  // window whose cramped 23:55 lead was dropped still marks 00:00 as the
774
847
  // day turn); a first tick in the same period is not (no tick-hopping
775
848
  // context on a live window).
776
- let prev = domainStart !== undefined ? bucketKey(domainStart, bg) : undefined;
849
+ let prev = domainStart !== undefined ? bucketKey(domainStart, bg, cal) : undefined;
777
850
  for (const t of ticks) {
778
- const k = bucketKey(t, bg);
851
+ const k = bucketKey(t, bg, cal);
779
852
  if (prev !== undefined && k !== prev)
780
853
  out.push(t);
781
854
  prev = k;
@@ -865,7 +938,7 @@ function flatPromotionFormatFor(level) {
865
938
  * opening at May 1 midnight reads `May 16 …`, not `1 16 …`). Without
866
939
  * `domainStart` the first tick is never promoted.
867
940
  */
868
- export function flatFormats(ticks, granularity, domainStart) {
941
+ export function flatFormats(ticks, granularity, domainStart, cal = localTickCalendar) {
869
942
  const base = flatBaseFormatFor(granularity);
870
943
  const levels = flatPromotionLevels(granularity);
871
944
  // Last-seen bucket per level; seeded from just before the domain start so a
@@ -874,13 +947,13 @@ export function flatFormats(ticks, granularity, domainStart) {
874
947
  const prev = new Map();
875
948
  if (domainStart !== undefined) {
876
949
  for (const L of levels)
877
- prev.set(L, bucketKey(domainStart - 1, L));
950
+ prev.set(L, bucketKey(domainStart - 1, L, cal));
878
951
  }
879
952
  return ticks.map((t) => {
880
953
  let spec = base;
881
954
  let promoted = false;
882
955
  for (const L of levels) {
883
- const k = bucketKey(t, L);
956
+ const k = bucketKey(t, L, cal);
884
957
  // Coarsest changed level wins; still update every level's bucket so a
885
958
  // year turn (which also turns the month/day) leaves them all current.
886
959
  if (!promoted && prev.has(L) && prev.get(L) !== k) {