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