@pond-ts/charts 0.46.0 → 0.48.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/CHANGELOG.md +130 -3
- package/dist/ChartContainer.d.ts +56 -7
- package/dist/ChartContainer.js +32 -9
- package/dist/Layers.js +120 -18
- package/dist/XAxis.d.ts +18 -1
- package/dist/XAxis.js +140 -76
- package/dist/context.d.ts +8 -0
- package/dist/format.d.ts +20 -0
- package/dist/grid.d.ts +32 -5
- package/dist/grid.js +81 -13
- package/dist/index.d.ts +2 -2
- package/dist/theme.d.ts +13 -0
- package/dist/theme.js +5 -0
- package/dist/tickLadder.d.ts +156 -16
- package/dist/tickLadder.js +634 -25
- package/dist/tradingTimeScale.d.ts +104 -10
- package/dist/tradingTimeScale.js +116 -4
- package/package.json +4 -4
package/dist/tickLadder.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
/** Collapse a {@link TickGranularity} to its coarse {@link TimeGrain} unit. */
|
|
2
|
+
export function coarseUnitOf(g) {
|
|
3
|
+
if (g.startsWith('second'))
|
|
4
|
+
return 'second';
|
|
5
|
+
if (g.startsWith('minute'))
|
|
6
|
+
return 'minute';
|
|
7
|
+
if (g.startsWith('hour'))
|
|
8
|
+
return 'hour';
|
|
9
|
+
return g; // 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
10
|
+
}
|
|
1
11
|
const SEC_MS = 1_000;
|
|
2
12
|
const MIN_MS = 60_000;
|
|
3
13
|
const HOUR_MS = 3_600_000;
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
4
15
|
/** The sub-day rungs, finest first, with their clock step — the 1/5/15/30
|
|
5
16
|
* second and minute steps terminals use, then the hour steps. */
|
|
6
17
|
const SUB_DAY_GRAINS = [
|
|
@@ -66,32 +77,247 @@ function firstOfEachBucket(opens, g) {
|
|
|
66
77
|
return out;
|
|
67
78
|
}
|
|
68
79
|
const COARSENING_LADDER = [
|
|
69
|
-
'week',
|
|
70
80
|
'month',
|
|
71
81
|
'quarter',
|
|
72
82
|
'year',
|
|
73
83
|
];
|
|
84
|
+
/** Nominal days per month — the band gate: the session-stride band applies
|
|
85
|
+
* while a nominal month still affords ≥ 2 marks at the span-derived budget. */
|
|
86
|
+
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
|
+
/**
|
|
92
|
+
* Whether index `i` marks in a month of `n` sessions/days at stride `k`. Two
|
|
93
|
+
* regimes, split on `m = floor(n / k)` — the whole stride-intervals the month
|
|
94
|
+
* affords:
|
|
95
|
+
*
|
|
96
|
+
* - **`m ≥ 4` (dense): anchored stride** — the month start (index 0), then
|
|
97
|
+
* every `k`-th index, stopping so the gap to the next month start stays
|
|
98
|
+
* ≥ `k` (slack at the month end, never a cramped tick before the month
|
|
99
|
+
* label). This is the decoded TradingView rule, validated label-for-label
|
|
100
|
+
* against owner-supplied 2026 captures: `apr | 8 14 20 24 | may` is session
|
|
101
|
+
* indices `0 4 8 12 16` of April's 21 sessions at stride 4, index 20
|
|
102
|
+
* dropped because `may` would sit only 1 session away.
|
|
103
|
+
* - **`m ≤ 3` (coarse): balanced division** — marks at `round(j·n/m)`. An
|
|
104
|
+
* anchored stride here leaves a hole of up to ~2k before the next month
|
|
105
|
+
* (the `Mar 9 17 ……… Apr` look), and each ±1 stride change relabels every
|
|
106
|
+
* mark even though density barely moves (the 9/17 → 10/19 → 11/21 crawl).
|
|
107
|
+
* Division splits the month into near-equal parts (gaps differ ≤ 1), and —
|
|
108
|
+
* since the marks depend only on `(n, m)` — they hold still across the
|
|
109
|
+
* whole stride range that maps to one `m`: mid-month, then thirds, exactly
|
|
110
|
+
* the coarse-zoom look the anchored data converges to as it densifies.
|
|
111
|
+
*/
|
|
112
|
+
function monthMark(i, k, n) {
|
|
113
|
+
const m = Math.floor(n / k);
|
|
114
|
+
if (m <= 1)
|
|
115
|
+
return i === 0;
|
|
116
|
+
if (m <= 3) {
|
|
117
|
+
for (let j = 0; j < m; j++) {
|
|
118
|
+
if (Math.round((j * n) / m) === i)
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return i === 0 || (i % k === 0 && i <= n - k);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Day-of-month subdivision — the **no-provider fallback** for direct
|
|
127
|
+
* {@link coarsenCalendar} calls: each month's marks land on calendar days at
|
|
128
|
+
* a uniform `ceil(gapDays)` stride from the 1st (day `1` = index 0), under
|
|
129
|
+
* the identity assumption that every calendar day is a session (where
|
|
130
|
+
* day-space and session-space coincide). A stride day with no open in the
|
|
131
|
+
* list **snaps to the next open**: a mark is taken when any stride day lies
|
|
132
|
+
* in `(previous open's date, own date]`. Membership depends only on dates and
|
|
133
|
+
* the span-derived stride, so a pan cannot reshuffle the marks. (The window's
|
|
134
|
+
* first open has no known predecessor and marks only on its own exact day — a
|
|
135
|
+
* snapped edge mark would appear/disappear with the pan phase.)
|
|
136
|
+
*/
|
|
137
|
+
function subdivideMonthsByDay(opens, gapDays) {
|
|
138
|
+
const k = Math.max(2, Math.ceil(gapDays - 1e-9));
|
|
139
|
+
const out = [];
|
|
140
|
+
let prev = null;
|
|
141
|
+
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);
|
|
146
|
+
// Scan window: same month → since the previous open's date; the first
|
|
147
|
+
// open of a new month → from day 1 (a weekend month-start snaps here);
|
|
148
|
+
// the window's first open → its own exact day only (see doc above).
|
|
149
|
+
const from = prev === null ? dom - 1 : prev.month === month ? prev.dom : 0;
|
|
150
|
+
prev = { month, dom };
|
|
151
|
+
for (let g = from + 1; g <= dom; g++) {
|
|
152
|
+
if (monthMark(g - 1, k, monthLen)) {
|
|
153
|
+
out.push(t);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
74
160
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
161
|
+
* **Session-index** subdivision — the decoded TradingView algorithm, used
|
|
162
|
+
* whenever the provider can enumerate the calendar. Each month's sessions are
|
|
163
|
+
* indexed `0…M−1` from the month's first session, and marks land on a
|
|
164
|
+
* **uniform integer stride** of those indices ({@link monthMark}: the month
|
|
165
|
+
* start, then every `k`-th session, truncated so the gap to the next month
|
|
166
|
+
* start stays ≥ `k`). Marks therefore sit an equal number of *bars* apart —
|
|
167
|
+
* evenly spaced pixels on a collapsed (and especially a uniform) trading axis
|
|
168
|
+
* — with the slack at the month **end**, and no weekend/holiday snapping at
|
|
169
|
+
* all: non-sessions simply aren't indices, and a month whose 1st is a Sunday
|
|
170
|
+
* anchors on Monday-the-2nd (index 0). Validated label-for-label against
|
|
171
|
+
* owner-supplied TradingView captures (Feb/Apr/May 2026, NYSE calendar) at
|
|
172
|
+
* strides 4, 3, and 2.
|
|
82
173
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
174
|
+
* The stride is per-month — `ceil(gapDays / (extentDays/M))`, i.e. the
|
|
175
|
+
* span-derived day budget divided by the month's own days-per-session — so a
|
|
176
|
+
* full month lands on the global density while a stub month (the live edge, a
|
|
177
|
+
* fixture that ends mid-month) earns proportionally fewer marks. Zooming
|
|
178
|
+
* steps the stride through the integers (…4 → 3 → 2 → 1), re-labelling some
|
|
179
|
+
* interior marks at each step (strides don't nest; the deliberate trade,
|
|
180
|
+
* owner-confirmed 2026-07-16 after their own TradingView samples showed the
|
|
181
|
+
* same: month anchors never move and density shifts by ~one bar, so it
|
|
182
|
+
* doesn't read as flicker — unlike the dyadic halving this replaces, which
|
|
183
|
+
* nested perfectly but stepped density 2× and wobbled ±1 day inside non-power
|
|
184
|
+
* months). Panning never reshuffles anything: rosters are queried over each
|
|
185
|
+
* **full calendar month** (`boundaries(monthStart−1ms, nextMonthStart)`), so
|
|
186
|
+
* an index is a property of the calendar, never of the window.
|
|
187
|
+
*
|
|
188
|
+
* The window's left edge (`opens[0]`) is indexed via point queries: the count
|
|
189
|
+
* of its month's sessions opening strictly before it, and whether it is
|
|
190
|
+
* itself exactly a session open — only then is it markable (a mid-gap edge
|
|
191
|
+
* has no honest index and would ride the pan). A calendar's **absolute
|
|
192
|
+
* start** — whose first session follows no collapsed gap, so some providers
|
|
193
|
+
* never report it — is detected by live-time flow and indexed 0, keeping the
|
|
194
|
+
* world-start month pinned.
|
|
195
|
+
*/
|
|
196
|
+
function subdivideMonthsBySession(opens, gapDays, provider) {
|
|
197
|
+
const monthKeyOf = (t) => {
|
|
198
|
+
const d = new Date(t);
|
|
199
|
+
return d.getFullYear() * 12 + d.getMonth();
|
|
200
|
+
};
|
|
201
|
+
const monthStartOf = (key) => new Date(Math.floor(key / 12), key % 12, 1).getTime();
|
|
202
|
+
const strideOf = (count, extentDays) => Math.max(2, Math.ceil((gapDays * count) / Math.max(1, extentDays) - 1e-9));
|
|
203
|
+
// Full-month roster: session count + the per-month stride its calendar-day
|
|
204
|
+
// extent affords. Cached per call; ≤ (visible months + 1) provider queries,
|
|
205
|
+
// and the whole resolution is memoized upstream per (domain, count).
|
|
206
|
+
const rosters = new Map();
|
|
207
|
+
const rosterOf = (key) => {
|
|
208
|
+
let r = rosters.get(key);
|
|
209
|
+
if (r === undefined) {
|
|
210
|
+
const sessions = provider.boundaries(monthStartOf(key) - 1, monthStartOf(key + 1));
|
|
211
|
+
const count = Math.max(1, sessions.length);
|
|
212
|
+
const extentDays = sessions.length === 0
|
|
213
|
+
? 0
|
|
214
|
+
: new Date(sessions[sessions.length - 1]).getDate() -
|
|
215
|
+
new Date(sessions[0]).getDate() +
|
|
216
|
+
1;
|
|
217
|
+
r = { count, stride: strideOf(count, extentDays) };
|
|
218
|
+
rosters.set(key, r);
|
|
219
|
+
}
|
|
220
|
+
return r;
|
|
221
|
+
};
|
|
222
|
+
const out = [];
|
|
223
|
+
let curKey = -1;
|
|
224
|
+
let idx = 0;
|
|
225
|
+
for (let i = 0; i < opens.length; i++) {
|
|
226
|
+
const t = opens[i];
|
|
227
|
+
const key = monthKeyOf(t);
|
|
228
|
+
if (i === 0) {
|
|
229
|
+
// Index the window's left edge within its month (see doc above).
|
|
230
|
+
curKey = key;
|
|
231
|
+
const monthStart = monthStartOf(key);
|
|
232
|
+
const sessions = provider.boundaries(monthStart - 1, monthStartOf(key + 1));
|
|
233
|
+
const pre = provider.boundaries(monthStart - 1, t).length;
|
|
234
|
+
const exact = provider.boundaries(t - 1, t + 1).length > 0;
|
|
235
|
+
// The calendar's absolute start (see doc above): unreported by the
|
|
236
|
+
// roster, detected by live-time flow — it is the month's session 0.
|
|
237
|
+
const worldStart = !exact && pre === 0 && provider.distance(t, t + MIN_MS) > 0;
|
|
238
|
+
const count = Math.max(1, sessions.length + (worldStart ? 1 : 0));
|
|
239
|
+
const rosterFirstDom = sessions.length > 0 ? new Date(sessions[0]).getDate() : 31;
|
|
240
|
+
const firstDom = worldStart
|
|
241
|
+
? Math.min(new Date(t).getDate(), rosterFirstDom)
|
|
242
|
+
: rosterFirstDom;
|
|
243
|
+
const lastDom = sessions.length > 0
|
|
244
|
+
? new Date(sessions[sessions.length - 1]).getDate()
|
|
245
|
+
: new Date(t).getDate();
|
|
246
|
+
const stride = strideOf(count, lastDom - firstDom + 1);
|
|
247
|
+
rosters.set(key, { count, stride });
|
|
248
|
+
if ((exact || worldStart) && monthMark(pre, stride, count)) {
|
|
249
|
+
out.push(t);
|
|
250
|
+
}
|
|
251
|
+
idx = exact || worldStart ? pre + 1 : pre;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (key !== curKey) {
|
|
255
|
+
// A month transition: this open is its month's first session, index 0.
|
|
256
|
+
curKey = key;
|
|
257
|
+
idx = 0;
|
|
258
|
+
}
|
|
259
|
+
const { count, stride } = rosterOf(key);
|
|
260
|
+
if (monthMark(idx, stride, count))
|
|
261
|
+
out.push(t);
|
|
262
|
+
idx++;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Thin an ascending run of **session opens** down to about `count` axis ticks.
|
|
268
|
+
* Picks the finest rung: every session → **per-month uniform session stride**
|
|
269
|
+
* (still day grain, month starts pinned) → month → quarter → year; beyond
|
|
270
|
+
* yearly it decimates every-nth so the axis never crowds. Exported so the
|
|
271
|
+
* container can draw session dividers at the same instants the axis labels.
|
|
272
|
+
*
|
|
273
|
+
* The day band thins each month to a **uniform session stride** — the month's
|
|
274
|
+
* first session, then every `k`-th session, truncated so the gap to the next
|
|
275
|
+
* month start stays ≥ `k` (slack at the month end, never a cramped tick
|
|
276
|
+
* before the month label). The decoded-and-validated TradingView algorithm:
|
|
277
|
+
* with a `provider` the stride runs in **session-index space**
|
|
278
|
+
* ({@link subdivideMonthsBySession}) — marks an equal number of bars apart,
|
|
279
|
+
* evenly spaced pixels on a collapsed axis, no weekend snapping; without one
|
|
280
|
+
* it falls back to day-of-month space ({@link subdivideMonthsByDay}).
|
|
281
|
+
* Zooming steps the stride through the integers (…4 → 3 → 2 → 1), a ~one-bar
|
|
282
|
+
* density change that re-labels some interior marks; month / year starts stay
|
|
283
|
+
* pinned at every zoom, and pans never reshuffle anything (the stride derives
|
|
284
|
+
* from the span, the indices from the calendar). Schemes tried and rejected
|
|
285
|
+
* on the way here: a global even day-stride (can't pin month starts — the
|
|
286
|
+
* `Feb` label drifted with zoom), `round(i·L/div)` division (beats against
|
|
287
|
+
* the month length), and dyadic midpoint halving (perfect zoom-nesting, but
|
|
288
|
+
* 2× density jumps and ±1-day wobble inside non-power months; the owner's
|
|
289
|
+
* TradingView captures showed uniform strides re-labelling on zoom reads
|
|
290
|
+
* calmer than either wobble). There is deliberately no week rung: a
|
|
291
|
+
* Monday-anchored week can't pin month starts either, so the day band owns
|
|
292
|
+
* everything between every-session and month grain.
|
|
293
|
+
*
|
|
294
|
+
* `count` is a **cap**, not a target: coarser grains jump by 3–4× (month →
|
|
295
|
+
* quarter → year), so a small fixed count over-coarsens long spans. Callers
|
|
296
|
+
* size the cap to the room the labels have — the container derives it from plot
|
|
297
|
+
* width — rather than passing a small constant. `spanDays` is the domain's
|
|
298
|
+
* calendar-day span from the caller (stable at a fixed zoom); absent (a direct
|
|
299
|
+
* call), it falls back to the opens' own span.
|
|
88
300
|
*
|
|
89
301
|
* This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
|
|
90
302
|
* sub-day rungs.
|
|
91
303
|
*/
|
|
92
|
-
export function coarsenCalendar(opens, count) {
|
|
304
|
+
export function coarsenCalendar(opens, count, spanDays, provider) {
|
|
93
305
|
if (opens.length <= count)
|
|
94
306
|
return { ticks: [...opens], granularity: 'day' };
|
|
307
|
+
// Per-month uniform session stride. Band-gated to spans where a nominal
|
|
308
|
+
// month still affords ≥ 2 marks (below that a month is down to one mark,
|
|
309
|
+
// which *is* month grain — the bucket ladder), and to daily-dense opens: a
|
|
310
|
+
// synthetic run of month/year starts has no days to stride over.
|
|
311
|
+
const openSpanDays = (opens[opens.length - 1] - opens[0]) / DAY_MS || Infinity;
|
|
312
|
+
const dailyDense = opens.length >= 0.5 * openSpanDays;
|
|
313
|
+
const gapDays = (spanDays ?? openSpanDays) / count;
|
|
314
|
+
if (dailyDense && DAYS_PER_MONTH / gapDays >= 2) {
|
|
315
|
+
const ticks = provider?.boundaries !== undefined
|
|
316
|
+
? subdivideMonthsBySession(opens, gapDays, provider)
|
|
317
|
+
: subdivideMonthsByDay(opens, gapDays);
|
|
318
|
+
if (ticks.length > 0)
|
|
319
|
+
return { ticks, granularity: 'day' };
|
|
320
|
+
}
|
|
95
321
|
for (const g of COARSENING_LADDER) {
|
|
96
322
|
const ticks = firstOfEachBucket(opens, g);
|
|
97
323
|
if (ticks.length <= count)
|
|
@@ -105,6 +331,117 @@ export function coarsenCalendar(opens, count) {
|
|
|
105
331
|
granularity: 'year',
|
|
106
332
|
};
|
|
107
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* The **grid populations** behind {@link buildTicks}' labels: every ladder rung
|
|
336
|
+
* that fits `cap` lines, each carrying its FULL anchor population — every
|
|
337
|
+
* aligned clock instant, every session open, every month / quarter / year
|
|
338
|
+
* start — finest rung first. The axis *labels* are a thinned subset of one
|
|
339
|
+
* rung; the grid is the calendar structure itself, so the container draws
|
|
340
|
+
* every anchor of each returned level and fades a level's lines by their pixel
|
|
341
|
+
* spacing (a crowding level dissolves while the coarser ones persist — the
|
|
342
|
+
* map-style hierarchical grid). Levels **nest** (a month start is a session
|
|
343
|
+
* open; an aligned hour sits inside its session; there is no week rung), so a
|
|
344
|
+
* consumer de-duplicates shared anchors coarsest-first and each line draws
|
|
345
|
+
* once, at its coarsest membership's (widest-spaced, so strongest) alpha.
|
|
346
|
+
*
|
|
347
|
+
* `cap` is the max lines per level — the caller derives it from plot width ÷
|
|
348
|
+
* the fade-out spacing, so a level too dense to be visible at all is simply
|
|
349
|
+
* absent rather than enumerated and thrown away. Sub-day rungs are gated on
|
|
350
|
+
* the live-span estimate first (like {@link buildTicks}) and skipped when they
|
|
351
|
+
* add no anchor beyond the session opens themselves (that is the day level).
|
|
352
|
+
*/
|
|
353
|
+
export function buildGridLevels(provider, opens, domainEnd, cap) {
|
|
354
|
+
const out = [];
|
|
355
|
+
if (cap < 1 || opens.length === 0)
|
|
356
|
+
return out;
|
|
357
|
+
const liveSpan = provider.distance(opens[0], domainEnd);
|
|
358
|
+
for (const { g, step } of SUB_DAY_GRAINS) {
|
|
359
|
+
if (opens.length + Math.floor(liveSpan / step) > cap)
|
|
360
|
+
continue;
|
|
361
|
+
const budget = cap + opens.length + 4;
|
|
362
|
+
const anchors = stepAnchors(provider, opens, domainEnd, step, budget);
|
|
363
|
+
if (anchors.length > budget)
|
|
364
|
+
continue;
|
|
365
|
+
if (anchors.length > opens.length) {
|
|
366
|
+
out.push({ granularity: g, values: anchors });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (opens.length <= cap) {
|
|
370
|
+
out.push({ granularity: 'day', values: [...opens] });
|
|
371
|
+
}
|
|
372
|
+
for (const g of COARSENING_LADDER) {
|
|
373
|
+
const values = firstOfEachBucket(opens, g);
|
|
374
|
+
if (values.length <= cap)
|
|
375
|
+
out.push({ granularity: g, values });
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The **nominal wall-clock step** of grain `g` in ms — the calendar time one
|
|
381
|
+
* grid cell of that grain covers (a day is a day whether or not its weekend
|
|
382
|
+
* neighbours are drawn; a month is ~30.44 days). This is what the grid's
|
|
383
|
+
* density fade keys off: `width × step / wallSpan` is a grain's spacing on a
|
|
384
|
+
* gap-free axis, and using it (rather than the measured on-screen gaps) makes
|
|
385
|
+
* the fade **mode-invariant** — collapsing weekends draws fewer day lines at
|
|
386
|
+
* the *same* strength, instead of wider-spaced lines that jump to full
|
|
387
|
+
* opacity at the same zoom.
|
|
388
|
+
*/
|
|
389
|
+
export function nominalStepMs(g) {
|
|
390
|
+
const sub = SUB_DAY_GRAINS.find((r) => r.g === g);
|
|
391
|
+
if (sub !== undefined)
|
|
392
|
+
return sub.step;
|
|
393
|
+
switch (g) {
|
|
394
|
+
case 'day':
|
|
395
|
+
return DAY_MS;
|
|
396
|
+
case 'week':
|
|
397
|
+
return 7 * DAY_MS;
|
|
398
|
+
case 'month':
|
|
399
|
+
return DAYS_PER_MONTH * DAY_MS;
|
|
400
|
+
case 'quarter':
|
|
401
|
+
return 3 * DAYS_PER_MONTH * DAY_MS;
|
|
402
|
+
default:
|
|
403
|
+
// 'year' (the sub-day grains are handled above).
|
|
404
|
+
return 365.25 * DAY_MS;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Whether `t` sits exactly on a calendar instant of grain `g` — a local
|
|
409
|
+
* 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
|
|
412
|
+
* test in {@link buildTicks} — the only way a **continuous** axis's edge tick
|
|
413
|
+
* survives, since a gap-free provider has no dead time to probe.
|
|
414
|
+
*
|
|
415
|
+
* The sub-day test shares {@link nextAligned}'s **fixed-elapsed-ms** rung
|
|
416
|
+
* convention **by design** — so an edge tick is judged aligned iff it is one
|
|
417
|
+
* of the instants {@link stepAnchors} would actually generate. On the two DST
|
|
418
|
+
* transition days a wall-clock `03:00` is then *not* "aligned" to `hour3`
|
|
419
|
+
* (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
|
|
422
|
+
* whether the *window-edge* tick is kept on those days — nil practical impact
|
|
423
|
+
* (Codex review, #479).
|
|
424
|
+
*/
|
|
425
|
+
function alignedToGrain(t, g) {
|
|
426
|
+
const d = new Date(t);
|
|
427
|
+
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
428
|
+
const sub = SUB_DAY_GRAINS.find((r) => r.g === g);
|
|
429
|
+
if (sub !== undefined)
|
|
430
|
+
return (t - midnight) % sub.step === 0;
|
|
431
|
+
if (t !== midnight)
|
|
432
|
+
return false;
|
|
433
|
+
switch (g) {
|
|
434
|
+
case 'month':
|
|
435
|
+
return d.getDate() === 1;
|
|
436
|
+
case 'quarter':
|
|
437
|
+
return d.getDate() === 1 && d.getMonth() % 3 === 0;
|
|
438
|
+
case 'year':
|
|
439
|
+
return d.getDate() === 1 && d.getMonth() === 0;
|
|
440
|
+
default:
|
|
441
|
+
// 'day' (and the unreachable 'week' — there is no week rung).
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
108
445
|
/** The first clock-aligned `stepMs` multiple at or after `t`, relative to `t`'s
|
|
109
446
|
* own local midnight — so a 3-hour step lands on 00:00 / 03:00 / 06:00 local,
|
|
110
447
|
* whatever the session open was. Fixed-ms stepping from midnight, so on a DST
|
|
@@ -144,8 +481,8 @@ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
|
|
|
144
481
|
/**
|
|
145
482
|
* The full-ladder grain selection: given the provider, the domain, and the
|
|
146
483
|
* width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
|
|
147
|
-
* then day
|
|
148
|
-
* first rung that fits.
|
|
484
|
+
* then day (thinned by a per-month uniform session stride) → month → quarter → year
|
|
485
|
+
* (then decimate) and return the first rung that fits.
|
|
149
486
|
* `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
|
|
150
487
|
* caller already has. Sub-day rungs are only reachable when the opens
|
|
151
488
|
* themselves fit — a year of daily sessions never wastes time generating hour
|
|
@@ -166,7 +503,21 @@ export function buildTicks(provider, opens, domainEnd, cap) {
|
|
|
166
503
|
for (const { g, step } of SUB_DAY_GRAINS) {
|
|
167
504
|
if (opens.length + Math.floor(liveSpan / step) > cap)
|
|
168
505
|
continue;
|
|
169
|
-
|
|
506
|
+
// The estimate can undercount the real anchor count by up to one
|
|
507
|
+
// phase tick per session (each session's aligned marks can exceed
|
|
508
|
+
// floor(sessionSpan/step) by one, and per-session floors sum below
|
|
509
|
+
// the floor of the sum), so the enumeration budget must cover
|
|
510
|
+
// cap + opens.length or a near-cap multi-session window truncates.
|
|
511
|
+
// A truncated array must never be returned: it passes the
|
|
512
|
+
// earns-its-labels check below while leaving the later sessions
|
|
513
|
+
// with no ticks at all (a lopsided axis) — if the budget is still
|
|
514
|
+
// exceeded (multiple live segments per session can add further
|
|
515
|
+
// phase ticks), try the coarser rungs, whose smaller estimates
|
|
516
|
+
// leave the budget room to finish.
|
|
517
|
+
const budget = cap + opens.length + 4;
|
|
518
|
+
const ticks = stepAnchors(provider, opens, domainEnd, step, budget);
|
|
519
|
+
if (ticks.length > budget)
|
|
520
|
+
continue;
|
|
170
521
|
// A clock rung must earn its labels: if it adds no intraday anchor
|
|
171
522
|
// beyond the opens themselves, it's really day grain (a row of
|
|
172
523
|
// "09:30"s under every session is a worse day axis, not a clock
|
|
@@ -177,7 +528,13 @@ export function buildTicks(provider, opens, domainEnd, cap) {
|
|
|
177
528
|
}
|
|
178
529
|
return { ticks: [...opens], granularity: 'day' };
|
|
179
530
|
}
|
|
180
|
-
|
|
531
|
+
// Pass the domain's **calendar-day span** (wall time, domain start → end)
|
|
532
|
+
// so the day-stride is picked from a quantity that's constant at a fixed
|
|
533
|
+
// zoom — panning slides the window but never changes it, so the marks stay
|
|
534
|
+
// put instead of reshuffling when the enumerated open count wobbles ±1.
|
|
535
|
+
// The provider routes the day band to session-index space (screen-even
|
|
536
|
+
// marks; see subdivideMonthsBySession).
|
|
537
|
+
return coarsenCalendar(opens, cap, (domainEnd - opens[0]) / DAY_MS, provider);
|
|
181
538
|
})();
|
|
182
539
|
// Round anchors to integer milliseconds: a pan/zoom domain comes from
|
|
183
540
|
// `scale.invert(pixel)` and is fractional, and a fractional anchor breaks
|
|
@@ -186,14 +543,38 @@ export function buildTicks(provider, opens, domainEnd, cap) {
|
|
|
186
543
|
// label falls through to the d3 multi-scale default (a bare `.259`
|
|
187
544
|
// millisecond tick). Sub-ms precision is invisible at any ladder grain.
|
|
188
545
|
result.ticks = result.ticks.map((t) => Math.round(t));
|
|
189
|
-
// Drop
|
|
190
|
-
// domain start, which
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
546
|
+
// Drop the **window-edge rider**: the first tick is `opens[0]` (the raw
|
|
547
|
+
// domain start), which is a calendar anchor only when it happens to BE one.
|
|
548
|
+
// A mid-period edge otherwise becomes a tick pinned at x=0 that relabels
|
|
549
|
+
// itself as the window pans (`8 → 9 → 10` mid-day, a `15:23` under an hour
|
|
550
|
+
// grain) — sticky and misleading (owner, 2026-07-16); TradingView never
|
|
551
|
+
// labels the window edge. Genuine ⇔ the instant is **live** and either a
|
|
552
|
+
// true session open (dead time immediately before — incl. the calendar's
|
|
553
|
+
// absolute start) or sits **exactly on a calendar instant of the chosen
|
|
554
|
+
// grain** (a midnight at day grain, a month start at month grain, a clock
|
|
555
|
+
// multiple on an hour rung) — the latter is how a gap-free continuous axis,
|
|
556
|
+
// which has no dead time to probe, keeps a window cut exactly on a boundary
|
|
557
|
+
// (a Jan-1-to-Jan-1 year fixture keeps its `2026`). Probe the **rounded**
|
|
558
|
+
// tick `edge` (what actually renders), not the raw fractional `opens[0]`:
|
|
559
|
+
// a fractional pan/zoom start that rounds onto a grain instant is aligned
|
|
560
|
+
// by the value the reader sees, and the two agree exactly on the integer-ms
|
|
561
|
+
// instants that aren't pan/zoom-derived.
|
|
196
562
|
const t = result.ticks;
|
|
563
|
+
if (t.length > 0 && t[0] === Math.round(opens[0])) {
|
|
564
|
+
const edge = t[0];
|
|
565
|
+
const genuine = provider.distance(edge, edge + 1) > 0 && // live (a dead edge never ticks)
|
|
566
|
+
(provider.distance(edge - 1, edge) === 0 || // a session open, or…
|
|
567
|
+
alignedToGrain(edge, result.granularity)); // …exactly on the grain
|
|
568
|
+
if (!genuine)
|
|
569
|
+
t.shift();
|
|
570
|
+
}
|
|
571
|
+
// Drop a cramped **leading partial-period** anchor: a genuine first tick
|
|
572
|
+
// (a "1Y back from today" view starting exactly on a mid-month session)
|
|
573
|
+
// can still land arbitrarily close to the first full period start and the
|
|
574
|
+
// two labels collide (the classic "Jun 23Jul 07" pile-up). When the lead
|
|
575
|
+
// gap is under half a typical period (in **live** time, so a collapsed
|
|
576
|
+
// weekend doesn't fake a gap), the partial anchor isn't earning its label —
|
|
577
|
+
// the boundary row moves to the next tick.
|
|
197
578
|
if (t.length >= 3 &&
|
|
198
579
|
provider.distance(t[0], t[1]) < 0.5 * provider.distance(t[1], t[2])) {
|
|
199
580
|
t.shift();
|
|
@@ -254,6 +635,124 @@ export function majorFormatFor(g) {
|
|
|
254
635
|
export function boundaryFormatFor(g) {
|
|
255
636
|
return g === 'day' ? '%b %d' : '%Y';
|
|
256
637
|
}
|
|
638
|
+
// --- Stacked date **bands** (the segmented second row; owner design 2026-07-16) ---
|
|
639
|
+
//
|
|
640
|
+
// The stacked style's second row is a strip of segmented **bands** — one per
|
|
641
|
+
// next-coarser calendar period, labelled at its left edge, zebra-shaded, with a
|
|
642
|
+
// divider at each turn. `bandGrainFor` is the band grain, one step finer than
|
|
643
|
+
// {@link boundaryGrainFor}'s day→year jump: sub-day ticks band by DAY, day/week
|
|
644
|
+
// ticks by MONTH (not year), month/quarter ticks by YEAR. The matching top row
|
|
645
|
+
// reads the terse unit ({@link flatBaseFormatFor}) with the band-turn tick
|
|
646
|
+
// emphasized; a year-grain axis has no band row (nothing coarser to show here).
|
|
647
|
+
/** The **band grain** under `g`-grain ticks (the segmented stacked second row):
|
|
648
|
+
* the next coarser unit — sub-day → day, day/week → month, month/quarter →
|
|
649
|
+
* year, year → none. */
|
|
650
|
+
export function bandGrainFor(g) {
|
|
651
|
+
if (isSubDay(g))
|
|
652
|
+
return 'day';
|
|
653
|
+
switch (g) {
|
|
654
|
+
case 'day':
|
|
655
|
+
case 'week':
|
|
656
|
+
return 'month';
|
|
657
|
+
case 'month':
|
|
658
|
+
case 'quarter':
|
|
659
|
+
return 'year';
|
|
660
|
+
default:
|
|
661
|
+
return undefined; // year — no coarser band
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* d3 specifier for the **cursor / marker readout** at grain `g` — a hovered
|
|
666
|
+
* instant formatted at the axis's own granularity, never finer. A day-or-coarser
|
|
667
|
+
* axis reads a **date** (no time-of-day), so a daily bar at a foreign-tz
|
|
668
|
+
* midnight can't render as `02 AM`; a sub-day axis reads date **+** clock. This
|
|
669
|
+
* is the grain-aware default that replaces d3's multi-scale default for the
|
|
670
|
+
* readout (a `cursorFormat` override, when given, wins over it). Unambiguous by
|
|
671
|
+
* design — the readout carries the year / date the terse tick labels omit.
|
|
672
|
+
*/
|
|
673
|
+
export function readoutFormatFor(g) {
|
|
674
|
+
if (g === 'second1' ||
|
|
675
|
+
g === 'second5' ||
|
|
676
|
+
g === 'second15' ||
|
|
677
|
+
g === 'second30')
|
|
678
|
+
return '%b %-d, %H:%M:%S';
|
|
679
|
+
if (isSubDay(g))
|
|
680
|
+
return '%b %-d, %H:%M';
|
|
681
|
+
switch (g) {
|
|
682
|
+
case 'day':
|
|
683
|
+
case 'week':
|
|
684
|
+
return '%b %-d, %Y';
|
|
685
|
+
case 'month':
|
|
686
|
+
case 'quarter':
|
|
687
|
+
return '%b %Y';
|
|
688
|
+
default:
|
|
689
|
+
return '%Y'; // year
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** d3 specifier for a **band** label at band grain `g`: the date for a day band
|
|
693
|
+
* (`Jan 12`), the full month for a month band (`January`), the year for a year
|
|
694
|
+
* band (`2031`). Left-aligned in the band by the renderer. */
|
|
695
|
+
export function bandFormatFor(g) {
|
|
696
|
+
switch (g) {
|
|
697
|
+
case 'day':
|
|
698
|
+
return '%b %-d';
|
|
699
|
+
case 'month':
|
|
700
|
+
return '%B';
|
|
701
|
+
default:
|
|
702
|
+
return '%Y'; // year (band grains are only day / month / year)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The **zebra parity** of the band starting at `t` (band grain `g`) — `true`
|
|
707
|
+
* when the band is shaded. A stable, pan/zoom-invariant flag derived from the
|
|
708
|
+
* band's own calendar identity: the year number, the months-since-epoch, or
|
|
709
|
+
* the **UTC**-day index (UTC so a DST shift never flips a band's shade). Odd
|
|
710
|
+
* index → shaded, matching the reference frames (2031 / 2033 grey).
|
|
711
|
+
*
|
|
712
|
+
* Parity is **absolute** (per calendar period), not per-visible-position — the
|
|
713
|
+
* price of pan-stability. Calendar-consecutive bands always differ, and
|
|
714
|
+
* collapsed **weekends** stay clean (Fri→Mon is a 3-index step, odd), but a
|
|
715
|
+
* lone skipped weekday — a single **holiday**, a 2-index step — can place two
|
|
716
|
+
* same-shade day-bands side by side on a gappy calendar. A rare, cosmetic
|
|
717
|
+
* consequence of keeping the shade fixed to the date rather than the slot.
|
|
718
|
+
*/
|
|
719
|
+
export function bandShaded(t, g) {
|
|
720
|
+
const d = new Date(t);
|
|
721
|
+
let index;
|
|
722
|
+
switch (g) {
|
|
723
|
+
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);
|
|
726
|
+
break;
|
|
727
|
+
case 'month':
|
|
728
|
+
index = d.getFullYear() * 12 + d.getMonth();
|
|
729
|
+
break;
|
|
730
|
+
default: // year
|
|
731
|
+
index = d.getFullYear();
|
|
732
|
+
}
|
|
733
|
+
return ((index % 2) + 2) % 2 === 1;
|
|
734
|
+
}
|
|
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);
|
|
740
|
+
if (g === 'day')
|
|
741
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
742
|
+
if (g === 'month')
|
|
743
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
744
|
+
return new Date(d.getFullYear(), 0, 1).getTime(); // year
|
|
745
|
+
}
|
|
746
|
+
/** 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);
|
|
750
|
+
if (g === 'day')
|
|
751
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
752
|
+
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
|
|
755
|
+
}
|
|
257
756
|
/**
|
|
258
757
|
* Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
|
|
259
758
|
* whose boundary-grain bucket differs from the previous tick's — i.e. a
|
|
@@ -283,4 +782,114 @@ export function boundaryTicks(ticks, granularity, domainStart) {
|
|
|
283
782
|
}
|
|
284
783
|
return out;
|
|
285
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* The calendar units a **flat**-style tick can be promoted to, coarsest first —
|
|
787
|
+
* the levels coarser than a tick's own label that a single-row axis relabels an
|
|
788
|
+
* opening tick to. A sub-day tick can open a date / month / year; a day or week
|
|
789
|
+
* tick a month / year; a month or quarter tick a year; a year tick nothing.
|
|
790
|
+
*/
|
|
791
|
+
function flatPromotionLevels(g) {
|
|
792
|
+
if (isSubDay(g))
|
|
793
|
+
return ['year', 'month', 'day'];
|
|
794
|
+
switch (g) {
|
|
795
|
+
case 'day':
|
|
796
|
+
case 'week':
|
|
797
|
+
return ['year', 'month'];
|
|
798
|
+
case 'month':
|
|
799
|
+
case 'quarter':
|
|
800
|
+
return ['year'];
|
|
801
|
+
default:
|
|
802
|
+
// 'year' (and, unreachably, the sub-day grains handled above).
|
|
803
|
+
return [];
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The terse **base** (non-promoted) flat label format for grain `g` — the label
|
|
808
|
+
* a tick carries when it opens no coarser period: the clock time for a sub-day
|
|
809
|
+
* grain, a bare day-of-month for day / week (`5`, not `Jan 5` — the month rides
|
|
810
|
+
* the promoted month-start tick), the month abbrev for month / quarter, the
|
|
811
|
+
* year for year. Terser than {@link majorFormatFor} (which carries the month on
|
|
812
|
+
* every day tick) because the flat row leans on inline promotions for context.
|
|
813
|
+
*/
|
|
814
|
+
export function flatBaseFormatFor(g) {
|
|
815
|
+
if (isSubDay(g)) {
|
|
816
|
+
return g.startsWith('second') ? '%H:%M:%S' : '%H:%M';
|
|
817
|
+
}
|
|
818
|
+
switch (g) {
|
|
819
|
+
case 'day':
|
|
820
|
+
case 'week':
|
|
821
|
+
return '%-d';
|
|
822
|
+
case 'month':
|
|
823
|
+
case 'quarter':
|
|
824
|
+
return '%b';
|
|
825
|
+
default:
|
|
826
|
+
// 'year' (sub-day grains are handled by the isSubDay branch above).
|
|
827
|
+
return '%Y';
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* The flat label format for a tick **promoted** to calendar level `level`. A
|
|
832
|
+
* day turn keeps its month (`Jan 5`) so an intraday day boundary reads
|
|
833
|
+
* unambiguously among clock ticks; a month turn shows the bare month, a year
|
|
834
|
+
* turn the year. Only `day` / `month` / `year` are produced by
|
|
835
|
+
* {@link flatPromotionLevels}; other levels fall back to their major format.
|
|
836
|
+
*/
|
|
837
|
+
function flatPromotionFormatFor(level) {
|
|
838
|
+
switch (level) {
|
|
839
|
+
case 'day':
|
|
840
|
+
return '%b %-d';
|
|
841
|
+
case 'month':
|
|
842
|
+
return '%b';
|
|
843
|
+
case 'year':
|
|
844
|
+
return '%Y';
|
|
845
|
+
default:
|
|
846
|
+
return majorFormatFor(level);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* The **flat** (single-row) label format specifier for each tick, parallel to
|
|
851
|
+
* `ticks` (already at grain `granularity`). Each tick shows the coarsest
|
|
852
|
+
* calendar period it *opens* — a year / month / date promotion — and its terse
|
|
853
|
+
* {@link flatBaseFormatFor} label otherwise, so the one row reads
|
|
854
|
+
* `… 30 31 Feb 2 3 …` with `Feb` where the month turns and the year where it
|
|
855
|
+
* turns. A tick "opens" level L when its L-bucket differs from the previous
|
|
856
|
+
* tick's; the coarsest changed level wins (a Jan-1 tick promotes to the year,
|
|
857
|
+
* not the month).
|
|
858
|
+
*
|
|
859
|
+
* `domainStart` seeds the walk (like {@link boundaryTicks}): the first tick is
|
|
860
|
+
* promoted only if it crosses a period relative to the instant just *before*
|
|
861
|
+
* the domain's left edge — so a window opening mid-month doesn't falsely
|
|
862
|
+
* promote its first tick (and a live sliding window doesn't flicker the
|
|
863
|
+
* leftmost label), while a domain starting *exactly* on a boundary still
|
|
864
|
+
* promotes the boundary tick (it truly is the period's first instant: a window
|
|
865
|
+
* opening at May 1 midnight reads `May 16 …`, not `1 16 …`). Without
|
|
866
|
+
* `domainStart` the first tick is never promoted.
|
|
867
|
+
*/
|
|
868
|
+
export function flatFormats(ticks, granularity, domainStart) {
|
|
869
|
+
const base = flatBaseFormatFor(granularity);
|
|
870
|
+
const levels = flatPromotionLevels(granularity);
|
|
871
|
+
// Last-seen bucket per level; seeded from just before the domain start so a
|
|
872
|
+
// first tick sharing the edge's period isn't a crossing, but one exactly ON
|
|
873
|
+
// the period boundary is.
|
|
874
|
+
const prev = new Map();
|
|
875
|
+
if (domainStart !== undefined) {
|
|
876
|
+
for (const L of levels)
|
|
877
|
+
prev.set(L, bucketKey(domainStart - 1, L));
|
|
878
|
+
}
|
|
879
|
+
return ticks.map((t) => {
|
|
880
|
+
let spec = base;
|
|
881
|
+
let promoted = false;
|
|
882
|
+
for (const L of levels) {
|
|
883
|
+
const k = bucketKey(t, L);
|
|
884
|
+
// Coarsest changed level wins; still update every level's bucket so a
|
|
885
|
+
// year turn (which also turns the month/day) leaves them all current.
|
|
886
|
+
if (!promoted && prev.has(L) && prev.get(L) !== k) {
|
|
887
|
+
spec = flatPromotionFormatFor(L);
|
|
888
|
+
promoted = true;
|
|
889
|
+
}
|
|
890
|
+
prev.set(L, k);
|
|
891
|
+
}
|
|
892
|
+
return spec;
|
|
893
|
+
});
|
|
894
|
+
}
|
|
286
895
|
//# sourceMappingURL=tickLadder.js.map
|