@pond-ts/charts 0.68.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.
- package/AGENTS.md +12 -3
- package/API.md +79 -75
- package/CHANGELOG.md +81 -1
- package/README.md +4 -0
- package/dist/ChartContainer.d.ts +23 -0
- package/dist/ChartContainer.js +15 -4
- package/dist/XAxis.d.ts +13 -1
- package/dist/XAxis.js +35 -3
- package/dist/context.d.ts +14 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/tickLadder.d.ts +66 -20
- package/dist/tickLadder.js +178 -105
- package/dist/tradingTimeScale.d.ts +44 -3
- package/dist/tradingTimeScale.js +113 -38
- package/package.json +6 -4
package/dist/tickLadder.js
CHANGED
|
@@ -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
|
|
37
|
-
* day / week / month / quarter / year share a key.
|
|
38
|
-
* agrees with the
|
|
39
|
-
* zone
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
51
|
-
case 'week':
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
143
|
-
const month =
|
|
144
|
-
const dom =
|
|
145
|
-
const monthLen =
|
|
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
|
|
199
|
-
return
|
|
283
|
+
const p = cal.parts(t);
|
|
284
|
+
return p.year * 12 + (p.month - 1);
|
|
200
285
|
};
|
|
201
|
-
const monthStartOf = (key) =>
|
|
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
|
-
:
|
|
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 ?
|
|
323
|
+
const rosterFirstDom = sessions.length > 0 ? dayOf(sessions[0]) : 31;
|
|
240
324
|
const firstDom = worldStart
|
|
241
|
-
? Math.min(
|
|
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
|
|
411
|
-
*
|
|
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
|
|
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
|
|
421
|
-
*
|
|
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
|
|
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
|
|
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
|
|
517
|
+
return p.day === 1;
|
|
436
518
|
case 'quarter':
|
|
437
|
-
return
|
|
519
|
+
return p.day === 1 && (p.month - 1) % 3 === 0;
|
|
438
520
|
case 'year':
|
|
439
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
725
|
-
index = Math.floor(Date.UTC(
|
|
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 =
|
|
802
|
+
index = p.year * 12 + (p.month - 1);
|
|
729
803
|
break;
|
|
730
804
|
default: // year
|
|
731
|
-
index =
|
|
805
|
+
index = p.year;
|
|
732
806
|
}
|
|
733
807
|
return ((index % 2) + 2) % 2 === 1;
|
|
734
808
|
}
|
|
735
|
-
/** The
|
|
736
|
-
*
|
|
737
|
-
|
|
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
|
|
813
|
+
return cal.startOfDay(t);
|
|
814
|
+
const p = cal.parts(t);
|
|
742
815
|
if (g === 'month')
|
|
743
|
-
return
|
|
744
|
-
return
|
|
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
|
-
*
|
|
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
|
|
823
|
+
return cal.nextDay(t);
|
|
824
|
+
const p = cal.parts(t);
|
|
752
825
|
if (g === 'month')
|
|
753
|
-
return
|
|
754
|
-
return
|
|
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
|
|
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
|