@pond-ts/charts 0.67.0 → 0.69.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
  /** 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) {
@@ -1,4 +1,15 @@
1
1
  import { type TickGranularity, type TimeGrain } from './tickLadder.js';
2
+ /** Zone options shared by {@link scaleTradingTime} and {@link identityProvider}. */
3
+ export interface ScaleTimeZoneOptions {
4
+ /**
5
+ * The IANA zone the axis's calendar runs in — ticks on that zone's
6
+ * midnights / Mondays / month starts, labels and readouts reading in it.
7
+ * **Omitted ⇒ the runtime's local zone** (the browser's), exactly as before
8
+ * the option existed. Any id `Intl` knows (`'UTC'`, `'Europe/Berlin'`, …);
9
+ * an unknown id throws `RangeError`.
10
+ */
11
+ timeZone?: string | undefined;
12
+ }
2
13
  /**
3
14
  * The structural discontinuity-provider surface `scaleTradingTime` consumes to
4
15
  * collapse closed-market time. Charts declares this **shape** itself and never
@@ -29,6 +40,15 @@ export interface DiscontinuityProvider {
29
40
  * `TradingCalendar.discontinuities()` provider supplies it.)
30
41
  */
31
42
  boundaries?(from: number, to: number): number[];
43
+ /**
44
+ * Optional: the same provider with its calendar in another zone. Only a
45
+ * provider whose gap topology *depends* on a zone needs it — the identity
46
+ * provider, whose "sessions" are calendar days and therefore move with the
47
+ * zone; a trading calendar's session opens are instants and do not. Used by
48
+ * {@link TradingTimeScale.withTimeZone} so a second `<XAxis timeZone>` can
49
+ * re-derive its day anchors in its own zone.
50
+ */
51
+ withTimeZone?(timeZone: string | undefined): DiscontinuityProvider;
32
52
  }
33
53
  /**
34
54
  * The high-level counterpart to a bare {@link DiscontinuityProvider}: anything
@@ -45,6 +65,14 @@ export interface TradingCalendarLike {
45
65
  discontinuities(options?: {
46
66
  spacing?: 'proportional' | 'uniform';
47
67
  }): DiscontinuityProvider;
68
+ /**
69
+ * Optional: the exchange's IANA zone. When present and the container has no
70
+ * explicit `timeZone`, the axis renders in it — ticks on exchange-local day
71
+ * starts, labels and readouts in exchange time — instead of the viewer's
72
+ * zone. A `@pond-ts/financial` `TradingCalendar` built `fromRules` carries
73
+ * its rules' zone here.
74
+ */
75
+ readonly timeZone?: string | undefined;
48
76
  }
49
77
  /**
50
78
  * A d3-scale-shaped time scale whose pixel mapping runs through **trading time**
@@ -198,22 +226,35 @@ export interface TradingTimeScale {
198
226
  range(): [number, number];
199
227
  range(next: readonly [number, number]): TradingTimeScale;
200
228
  copy(): TradingTimeScale;
229
+ /**
230
+ * The same pixel mapping (provider, domain, range) with its **calendar in
231
+ * another zone** — `undefined` for runtime-local. The scale's own zone is
232
+ * unchanged; this is how a second `<XAxis timeZone>` strip ticks and labels
233
+ * in its own zone over the container's shared x mapping. A provider that
234
+ * exposes {@link DiscontinuityProvider.withTimeZone} re-derives its day
235
+ * anchors; any other keeps its instants (a trading calendar's session opens
236
+ * are zone-independent).
237
+ */
238
+ withTimeZone(timeZone: string | undefined): TradingTimeScale;
239
+ /** The IANA zone this scale's calendar runs in; `undefined` = runtime-local. */
240
+ timeZone(): string | undefined;
201
241
  }
202
242
  export { coarsenCalendar } from './tickLadder.js';
203
243
  export type { TickGranularity, TimeGrain } from './tickLadder.js';
204
244
  /**
205
245
  * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
206
- * time, and every local midnight is a "session open". Backing a plain
246
+ * time, and every midnight (in `timeZone`, default runtime-local) is a
247
+ * "session open". Backing a plain
207
248
  * continuous time axis with `scaleTradingTime(identityProvider())` runs it
208
249
  * through the same logical tick ladder as a trading-calendar axis — calendar
209
250
  * days are the day anchors, so a year of data ticks on month starts and an
210
251
  * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
211
252
  * default.
212
253
  */
213
- export declare function identityProvider(): DiscontinuityProvider;
254
+ export declare function identityProvider(options?: ScaleTimeZoneOptions): DiscontinuityProvider;
214
255
  /**
215
256
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
216
257
  * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
217
258
  */
218
- export declare function scaleTradingTime(provider: DiscontinuityProvider): TradingTimeScale;
259
+ export declare function scaleTradingTime(provider: DiscontinuityProvider, options?: ScaleTimeZoneOptions): TradingTimeScale;
219
260
  //# sourceMappingURL=tradingTimeScale.d.ts.map