@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.
@@ -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
- * Thin an ascending run of **session opens** down to about `count` axis ticks by
76
- * **calendar grain** — the trading-terminal habit of labelling week / month /
77
- * year starts rather than an arbitrary every-nth session. Picks the finest grain
78
- * on the ladder (day week month quarter → year) that yields at most
79
- * `count` buckets and returns the first open in each; beyond yearly it decimates
80
- * every-nth so the axis never crowds. Exported so the container can draw session
81
- * dividers at the same instants the axis labels.
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
- * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
84
- * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
85
- * run spans 6 quarter buckets capped at 5 it collapses to year grain, 2
86
- * ticks). Callers size the cap to the room the labels have — the container
87
- * derives it from plot width rather than passing a small constant.
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 week → month → quarter → year (then decimate) and return the
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
- const ticks = stepAnchors(provider, opens, domainEnd, step, cap + 4);
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
- return coarsenCalendar(opens, cap);
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 a cramped **leading partial-period** anchor: the first tick is the
190
- // domain start, which usually sits mid-period (a "1Y back from today" view
191
- // starts mid-month), so it can land arbitrarily close to the first full
192
- // period start and the two labels collide (the classic "Jun 23Jul 07"
193
- // pile-up). When the lead gap is under half a typical period (in **live**
194
- // time, so a collapsed weekend doesn't fake a gap), the partial anchor
195
- // isn't earning its label the boundary row moves to the next tick.
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