@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 CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.46.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.48.0...HEAD
12
+ [0.48.0]: https://github.com/pond-ts/pond/compare/v0.47.0...v0.48.0
13
+ [0.47.0]: https://github.com/pond-ts/pond/compare/v0.46.0...v0.47.0
12
14
  [0.46.0]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...v0.46.0
13
15
  [0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
14
16
  [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
@@ -45,13 +47,138 @@ and type-level changes; patch bumps are strictly additive.
45
47
 
46
48
  ## [Unreleased]
47
49
 
50
+ ## [0.48.0] — 2026-07-17
51
+
52
+ ### Added
53
+
54
+ - **charts:** `<ChartContainer>` gains **`cursorFormat`** — an independent
55
+ format for the **cursor / marker readout** (the crosshair time pill, marker
56
+ axis indicators, annotation auto-labels), separate from the tick-label
57
+ `timeFormat` / `format`. Unlike `timeFormat` (which _owns the labels_ and so
58
+ opts the axis out of the `dateStyle` ladder by design), `cursorFormat` shapes
59
+ only the readout and **keeps the flat / stacked date style** — resolving the
60
+ "one knob, two concerns" bind where the only way to fix the pill was to give
61
+ up the styled axis. A d3 specifier **string** formats uniformly; a
62
+ **function** `(epochMs, { grain, defaultText }) => string` is handed the
63
+ axis's resolved coarse **`TimeGrain`** (`year` … `second`) and the
64
+ grain-aware default text, so it can branch on zoom and pass the default
65
+ through — no re-deriving the grain from the range. New public types
66
+ **`CursorFormat`** / **`TimeGrain`**; `TradingTimeScale` gains
67
+ `readoutFormat(count)` and `grain(count)`.
68
+
69
+ ### Fixed
70
+
71
+ - **charts:** the cursor / marker time readout now formats at the axis's
72
+ **own grain** by default instead of d3's multi-scale default — a
73
+ day-or-coarser axis reads a **date** (`Sep 14, 2026`), a sub-day axis reads
74
+ date + clock. Fixes the 0.47.0 flat-axis regression where a daily bar at a
75
+ foreign-timezone midnight rendered as a bare time-of-day (`02 AM`) in the
76
+ crosshair pill (Tidal F-charts-7). Tick labels are unchanged.
77
+
78
+ ## [0.47.0] — 2026-07-17
79
+
80
+ ### Added
81
+
82
+ - **charts:** `<ChartContainer>` gains **`grid`** (default `true`) and
83
+ **`sessionDividers`** (`'labeled' | 'all' | 'none'`, default `'none'`).
84
+ On a calendar (time) axis the vertical gridlines are the **full grain
85
+ populations** — every day / month / aligned clock instant in view, not just
86
+ the thinned instants the labels chose (the labels decorate the grid; they
87
+ don't define it). Each grain fades as a unit by its **calendar density**
88
+ (nominal gap-free spacing: full ≥ 15 px, gone ≤ 5 px, quadratic), so
89
+ zooming out dissolves the fine grain into the coarser one — a map-style
90
+ hierarchical grid with no pop when the label algorithm switches rung — and
91
+ collapsing weekends draws _fewer_ day lines at the _same_ strength, not
92
+ wider-spaced lines that jump to full opacity. Session dividers are opt-in
93
+ emphasis over that grid, and draw only at true collapse **seams** —
94
+ boundaries that removed time actually precedes, not every session roll
95
+ (`'all'` = every seam, the TradingView separator look, crowding lines
96
+ fading out; `'labeled'` = only seams the axis also labels) — so a
97
+ `grid={false}` plot is actually clean by default and a contiguous-sessions
98
+ calendar seams only where days were excised. `TradingTimeScale` gains
99
+ **`gridLevels(minGapPx)`** (the nested per-grain populations + nominal
100
+ spacing behind it). In `'all'` mode, crowding session-divider lines fade on
101
+ a quadratic ramp (full ≥ 28 px apart, gone ≤ 6 px) so zooming out dissolves
102
+ them to a clean plot rather than a permanent gray wash.
103
+ - **charts:** `<TimeAxis>` / `<XAxis>` gain a **`dateStyle`** prop
104
+ (`'flat' | 'stacked'`). `'flat'` — the new default — lays the date context
105
+ out on a **single row** the TradingView way: each tick that opens a coarser
106
+ calendar period is relabelled **inline** to it (the month at a month turn,
107
+ the year at a year turn, the date at a day turn under an intraday grain),
108
+ every other tick a terse label (`5`, `Feb`, `14:00`). `'stacked'` is the
109
+ two-row layout: a terse top row (the grain's bare unit) over a segmented
110
+ **band** row of the next-coarser period — **day** bands under intraday
111
+ ticks, **month** bands under day ticks, **year** bands under month/quarter
112
+ ticks — each a left-aligned label with a divider at its turn, **zebra-shaded**
113
+ by the band's calendar parity (pan/zoom-stable). The turn tick up top is
114
+ emphasized and joins its divider as one continuous boundary line; the partial
115
+ left band pins its label at the plot edge. Themeable via **`theme.axis.band`**
116
+ (`fill` — the shaded cell, "could be a background color"; `divider`; `label`).
117
+ `TradingTimeScale` gains `flatFormat(count)` (the flat single-row lookup),
118
+ `baseFormat(count)` (the terse stacked top row), and `bands(count)` (the band
119
+ segments); non-tick instants — the cursor readout — still read a full
120
+ timestamp.
121
+ The **window edge is never ticked** unless it is a genuine grain anchor (a
122
+ true session open, or an instant exactly on the grain — a midnight at day
123
+ grain, a month start at month grain): a mid-period edge tick sat pinned at
124
+ x=0 relabelling itself as the window panned (a sticky `8` at half-spacing, a
125
+ `15:23` under an hour grain) — misleading, and not something TradingView
126
+ draws. The first real calendar anchor now leads the axis.
127
+
128
+ ### Changed
129
+
130
+ - **Docs hosting.** The documentation site has moved from GitHub Pages
131
+ (`pjm17971.github.io/pond-ts`) to Cloudflare Pages under the custom domain
132
+ **<https://pond-ts.org>**. Storybook now deploys as its own project served at
133
+ `pond-ts.org/storybook/` (via a small routing Worker, `workers/router/`),
134
+ rather than nested into the docs build. Doc links in the README are repointed;
135
+ the deploy workflow keeps the same `v*`-tag gate. See `MIGRATION.md`.
136
+ - **charts:** the time axis now renders the **flat** single-row date style by
137
+ default (previously the two-row stacked layout). Every time chart — trading
138
+ or plain continuous, auto-rendered or explicit `<TimeAxis>` — picks this up.
139
+ Pass `dateStyle="stacked"` to keep the old two-row look. (An explicit
140
+ `format` / container `timeFormat`, a `transform`, or explicit `ticks` opt out
141
+ of both styles as before.)
142
+ - **charts:** the time-axis tick ladder's sub-month density now thins each
143
+ month in **session-index space** — the reverse-engineered TradingView
144
+ algorithm, validated label-for-label against its output on the 2026 NYSE
145
+ calendar. Marks sit an equal number of _bars_ apart, so on a collapsed
146
+ trading axis they are **evenly spaced in pixels**, with no weekend snapping:
147
+ a month opening on a weekend anchors on its first session. Two regimes per
148
+ month: at dense zooms, a **uniform anchored stride** (the month's first
149
+ session, then every k-th, truncated so the gap to the next month label
150
+ stays ≥ the stride — the slack lands at the month end); at coarse zooms
151
+ (≤ 3 marks per month, where a truncated stride would leave a hole of up to
152
+ ~2 strides before the month label and each ±1 stride change would crawl the
153
+ labels), a **balanced division** — mid-month, then thirds — whose marks
154
+ hold still across every zoom that maps to the same density. Month / year
155
+ starts stay pinned at every zoom; pans never reshuffle marks (indices are
156
+ queried per full calendar month, never per window); a stub live-edge month
157
+ earns proportionally fewer marks. Without a calendar provider the same
158
+ scheme runs in day-of-month space. Replaces the old day → weekly ~7×
159
+ density cliff; the separate Monday-anchored week grain was removed — the
160
+ session-stride band covers everything between every-session and month
161
+ grain.
162
+
163
+ ### Fixed
164
+
165
+ - **charts:** a multi-session time axis near the tick cap can no longer come
166
+ back lopsided — with several sessions each gaining a phase tick while the
167
+ span estimate sat within a few ticks of the cap, clock-rung enumeration hit
168
+ its internal bail and the truncated tick array was returned, leaving the
169
+ final sessions with no ticks at all. The enumeration budget now covers the
170
+ estimate's per-session undercount (`cap + opens.length + slack`) so
171
+ enumeration completes, and a still-truncated result is rejected in favor of
172
+ a coarser rung instead of being returned. (Follow-up to the v0.46.0
173
+ sliding-window grain-stability fix, flagged in its release spot-check.)
174
+
48
175
  ## [0.46.0] — 2026-07-14
49
176
 
50
177
  ### Changed
51
178
 
52
179
  - **charts:** the boundary (second-row) axis label's **context** now pins to
53
180
  the plot's left edge instead of riding the first tick: it shows the period
54
- the *domain start* is in, and a crossing label sliding toward the edge
181
+ the _domain start_ is in, and a crossing label sliding toward the edge
55
182
  pushes it off (the sticky-header behavior). On a live sliding window the
56
183
  old first-tick anchoring made `Jan 01` hop tick-to-tick as ticks scrolled
57
184
  out; pinned, it stays put until the period actually changes. Crossing
@@ -141,7 +268,7 @@ and type-level changes; patch bumps are strictly additive.
141
268
  values while **keeping the row count** — mirroring `rolling`'s `minSamples`,
142
269
  the one warm-up convention studies use so a smoothed line aligns on its
143
270
  source's time axis. (The existing `warmup` option is unchanged — it still
144
- *drops* the head rows; `minSamples` is the length-preserving counterpart.)
271
+ _drops_ the head rows; `minSamples` is the length-preserving counterpart.)
145
272
  - **core:** `TimeSeries.rolling` accepts a **count-based** window —
146
273
  `rolling({ count: N }, mapping, opts?)` reduces the last / next / centered
147
274
  `N` _rows_ (bars) by position instead of a time span. Unlike a duration
@@ -3,7 +3,7 @@ import { type DiscontinuityProvider, type TradingCalendarLike } from './tradingT
3
3
  import { Sequence, BoundedSequence } from 'pond-ts';
4
4
  import type { TimeRange } from 'pond-ts';
5
5
  import { type AnnotationKind, type CreateSpec, type CursorMode, type SelectInfo, type TrackerInfo } from './context.js';
6
- import { type AxisFormat } from './format.js';
6
+ import { type AxisFormat, type CursorFormat } from './format.js';
7
7
  import { type ChartTheme } from './theme.js';
8
8
  export interface ChartContainerProps {
9
9
  /**
@@ -29,8 +29,11 @@ export interface ChartContainerProps {
29
29
  * this prop's identity changes, so memoize it — `const disc = useMemo(() =>
30
30
  * calendar.discontinuities(), [calendar])` — rather than calling
31
31
  * `.discontinuities()` inline in JSX, which would rebuild every render.
32
+ *
33
+ * Accepts an explicit `undefined` (a `cond ? provider : undefined` toggle
34
+ * under `exactOptionalPropertyTypes`), same as omitting it.
32
35
  */
33
- discontinuities?: DiscontinuityProvider;
36
+ discontinuities?: DiscontinuityProvider | undefined;
34
37
  /**
35
38
  * The **high-level** sugar for {@link discontinuities}: a trading calendar the
36
39
  * container derives the provider from itself (`calendar.discontinuities({
@@ -53,6 +56,31 @@ export interface ChartContainerProps {
53
56
  * carries its own metric).
54
57
  */
55
58
  spacing?: 'proportional' | 'uniform';
59
+ /**
60
+ * Draw the reference gridlines behind the data. On a calendar (time) axis
61
+ * the verticals are the **full grain populations** — every day / month /
62
+ * aligned clock instant in view, each grain fading by its calendar density
63
+ * — not just the labelled ticks (the labels decorate the grid; they don't
64
+ * define it). **Default `true`.** Set `false` for a clean backdrop —
65
+ * session dividers (below) are independent and still draw when enabled.
66
+ */
67
+ grid?: boolean;
68
+ /**
69
+ * Where to draw **session dividers** — the solid verticals at a trading
70
+ * calendar's collapse **seams**: boundaries that removed (closed-market)
71
+ * time actually precedes, not every session roll (only with a
72
+ * `discontinuities` / `calendar` provider). On a real exchange calendar
73
+ * every session open follows an overnight gap, so seams = session opens; a
74
+ * calendar of contiguous full-day sessions has seams only where days were
75
+ * excised (the weekend). **Default `'none'`** — the hierarchical grid
76
+ * already marks the calendar structure at every zoom, so dividers are
77
+ * opt-in emphasis: `'all'` draws one at *every* seam in view (the
78
+ * TradingView session-separator look, crowding lines fading out),
79
+ * `'labeled'` only at seams the axis also labels. Dividers are independent
80
+ * of {@link grid} — `'all'` + `grid={false}` is the
81
+ * separators-on-a-clean-plot look.
82
+ */
83
+ sessionDividers?: 'labeled' | 'all' | 'none';
56
84
  /** Total width in CSS pixels (plot + axis gutters). */
57
85
  width: number;
58
86
  /** Vertical space between rows in CSS pixels (not under the axis). Default 0. */
@@ -262,12 +290,33 @@ export interface ChartContainerProps {
262
290
  */
263
291
  snap?: boolean;
264
292
  /**
265
- * Time-axis value formatting — a d3 time specifier string (e.g. `'%H:%M'`) or a
266
- * `(epochMs) => string` function ({@link AxisFormat}); applies to both the time
267
- * axis labels and the cursor-time readout. **Omitted d3's multi-scale time
268
- * format** (`12 PM`, `12:10`, …).
293
+ * Time-axis **label** formatting — a d3 time specifier string (e.g. `'%H:%M'`)
294
+ * or a `(epochMs) => string` function ({@link AxisFormat}). A custom format
295
+ * **owns the labels**, so it opts the axis out of the `dateStyle` ladder
296
+ * (flat / stacked) by design. **Omitted the flat/stacked date style.** To
297
+ * shape only the cursor readout while keeping a date style, use
298
+ * {@link cursorFormat} instead. (For back-compat this also shapes the readout
299
+ * when `cursorFormat` is absent.)
269
300
  */
270
301
  timeFormat?: AxisFormat;
302
+ /**
303
+ * The **cursor / marker readout** format — the crosshair time pill, marker
304
+ * axis indicators, and annotation auto-labels — **independent of the tick
305
+ * labels**, so it does **not** disqualify the `dateStyle` ladder.
306
+ * **Omitted ⇒ a grain-aware default**: the readout formats at the axis's own
307
+ * granularity, so a day-or-coarser axis reads a **date** (never a
308
+ * time-of-day) and a sub-day axis reads date + clock — a daily bar at a
309
+ * foreign-tz midnight no longer renders as `02 AM`.
310
+ *
311
+ * A d3 time-specifier **string** formats uniformly at every zoom; a
312
+ * **function** `(epochMs, { grain, defaultText }) => string` receives the
313
+ * axis's resolved coarse {@link TimeGrain} and the grain-aware default text,
314
+ * so it can branch on the zoom level and pass `defaultText` through for
315
+ * grains it doesn't override (no re-deriving the grain from the range). See
316
+ * {@link CursorFormat}. This is the independent readout channel;
317
+ * {@link timeFormat} owns the labels.
318
+ */
319
+ cursorFormat?: CursorFormat;
271
320
  /** Visual theme for all rows; defaults to {@link defaultTheme}. */
272
321
  theme?: ChartTheme;
273
322
  children?: ReactNode;
@@ -281,5 +330,5 @@ export interface ChartContainerProps {
281
330
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
282
331
  * (`<YAxis>`).
283
332
  */
284
- export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, discontinuities, calendar, spacing, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
333
+ export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
285
334
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -43,7 +43,7 @@ function normalizeRange(range) {
43
43
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
44
44
  * (`<YAxis>`).
45
45
  */
46
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, discontinuities, calendar, spacing, children, }) {
46
+ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
47
47
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
48
48
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
49
49
  // is the placeholder while auto-fitting.
@@ -361,6 +361,30 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
361
361
  formatTime: resolveAxisFormat(s, xTickCount, timeFormat),
362
362
  };
363
363
  }
364
+ // The cursor / marker / annotation **readout** formatter. `cursorFormat`
365
+ // wins (the independent readout channel — it never disqualifies the date
366
+ // style); else a container `timeFormat` (back-compat: shapes the readout
367
+ // too, and already opts labels out of the ladder); else the scale's
368
+ // **grain-aware** default (a day-or-coarser axis reads a date, not a
369
+ // time-of-day — the F-charts-7 `02 AM` fix), never d3's multi-scale default.
370
+ const timeReadout = (s) => {
371
+ // A `cursorFormat` **function** gets the axis's resolved coarse grain and
372
+ // the grain-aware default text per instant, so it can branch on zoom and
373
+ // pass the default through. A **string** formats uniformly (d3 specifier).
374
+ if (typeof cursorFormat === 'function') {
375
+ const grain = s.grain(xTickCount);
376
+ const def = s.readoutFormat(xTickCount);
377
+ return (v) => cursorFormat(v, { grain, defaultText: def(v) });
378
+ }
379
+ if (cursorFormat !== undefined) {
380
+ return resolveTimeFormat(s, xTickCount, cursorFormat);
381
+ }
382
+ // No cursorFormat: a container timeFormat still shapes the readout
383
+ // (back-compat); else the scale's grain-aware default.
384
+ return timeFormat !== undefined
385
+ ? resolveTimeFormat(s, xTickCount, timeFormat)
386
+ : s.readoutFormat(xTickCount);
387
+ };
364
388
  if (xDiscontinuities !== undefined) {
365
389
  // Trading-time axis: closed-market gaps collapse, time proportional within
366
390
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
@@ -369,10 +393,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
369
393
  const s = scaleTradingTime(xDiscontinuities)
370
394
  .domain([d0, d1])
371
395
  .range([0, plotWidth]);
372
- return {
373
- xScale: s,
374
- formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
375
- };
396
+ return { xScale: s, formatTime: timeReadout(s) };
376
397
  }
377
398
  // Plain continuous time axis: the same trading-time scale over the
378
399
  // gap-free identity provider, so it runs the same logical tick ladder
@@ -383,10 +404,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
383
404
  const s = scaleTradingTime(identityProvider())
384
405
  .domain([d0, d1])
385
406
  .range([0, plotWidth]);
386
- return {
387
- xScale: s,
388
- formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
389
- };
407
+ return { xScale: s, formatTime: timeReadout(s) };
390
408
  }, [
391
409
  resolvedKind,
392
410
  categories,
@@ -394,6 +412,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
394
412
  d1,
395
413
  plotWidth,
396
414
  timeFormat,
415
+ cursorFormat,
397
416
  xDiscontinuities,
398
417
  xTickCount,
399
418
  ]);
@@ -521,6 +540,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
521
540
  xScale,
522
541
  xKind: resolvedKind,
523
542
  discontinuities: xDiscontinuities,
543
+ grid,
544
+ sessionDividers,
524
545
  panZoom,
525
546
  minDuration,
526
547
  applyRange,
@@ -576,6 +597,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
576
597
  xScale,
577
598
  resolvedKind,
578
599
  xDiscontinuities,
600
+ grid,
601
+ sessionDividers,
579
602
  panZoom,
580
603
  minDuration,
581
604
  applyRange,
package/dist/Layers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { Canvas } from './Canvas.js';
4
- import { drawGrid, drawDividers, thinPixels } from './grid.js';
4
+ import { drawGrid, drawDividers, dividerAlphas, thinPixels } from './grid.js';
5
5
  import { cursorParts, bandRect, regionSpan } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
@@ -14,9 +14,31 @@ import { ContainerContext, LayersContext, RowContext, } from './context.js';
14
14
  * container's shared `xTickCount` (as `<XAxis>` and `formatTime` do), which is
15
15
  * width-derived on a trading-time axis. */
16
16
  const GRID_TICKS = 5;
17
- /** Minimum px between session dividers — thins dense collapse points (e.g. a
18
- * daily chart where every candle is a new session) so the axis never crowds. */
17
+ /** Minimum px between `'labeled'` session dividers — thins dense collapse
18
+ * points (e.g. a daily chart where every candle is a new session) so the axis
19
+ * never crowds. (`'all'` mode fades instead of thinning — see below.) */
19
20
  const MIN_DIVIDER_PX = 40;
21
+ /** Calendar gridlines (one per day / month / aligned hour — the full grain
22
+ * populations, `TradingTimeScale.gridLevels`) are full-strength while their
23
+ * **nominal** spacing (`level.spacing` — calendar density, gap-free; NOT the
24
+ * measured on-screen gaps, so hiding weekends doesn't brighten the grid) is
25
+ * at least this — a day grain reaches full at a ~1.5-month window on a
26
+ * default-width plot, in either mode. Dashed faint lines tolerate more
27
+ * density than the solid session dividers, hence the tighter pair. */
28
+ const GRID_LINE_FULL_PX = 15;
29
+ /** …and fully faded once nominal spacing falls to this (the same quadratic
30
+ * wash-aware ramp as the session lines — alpha `t²`, so total ink → 0).
31
+ * Also the enumeration floor: a grain denser than this is skipped
32
+ * outright. */
33
+ const GRID_LINE_GONE_PX = 5;
34
+ /** `'all'`-mode session lines are full-strength while spaced at least this
35
+ * far apart (the ~1M-view session width on a default-width plot). */
36
+ const SESSION_LINE_FULL_PX = 28;
37
+ /** …and fully invisible once spacing falls to this — a smooth quadratic
38
+ * falloff between the two, so nothing pops in/out as you pan/zoom (a hard
39
+ * drop keyed to pixel clusters shifts phase with the window) and the plot
40
+ * zooms out to *clean*, not to a gray wash (see `dividerAlphas`). */
41
+ const SESSION_LINE_GONE_PX = 6;
20
42
  /** Wheel-zoom sensitivity: `factor = exp(deltaY * k)` (one ~100px notch ≈ ±15%). */
21
43
  const ZOOM_SENSITIVITY = 0.0015;
22
44
  /** Pointer slop (px): a drag must exceed this before it pans, and a click within
@@ -76,27 +98,107 @@ export function Layers({ children }) {
76
98
  // A category axis draws no vertical gridlines — a line through each bar
77
99
  // centre reads as noise; the bars are the structure.
78
100
  const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(xTickCount);
79
- const xTicks = xTickVals.map((d) => xScale(+d));
80
- const yTicks = gridY
81
- ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
82
- : [];
83
- drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
101
+ // The reference grid behind the data, opt-out via `grid={false}` for
102
+ // a clean backdrop (session dividers below stay independent of it).
103
+ if (container.grid) {
104
+ const yTicks = gridY
105
+ ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
106
+ : [];
107
+ // On a calendar axis the verticals are the FULL grain populations —
108
+ // every day in the month, every month in the year, every aligned
109
+ // clock instant — not just the thinned instants the labels chose:
110
+ // the labels decorate the grid, they don't define it. Each grain
111
+ // fades AS A UNIT by its **nominal** (gap-free, calendar-density)
112
+ // spacing — `level.spacing`, the same wash-aware quadratic ramp as
113
+ // the session lines — so zooming out dissolves the fine grain into
114
+ // the coarser one instead of popping when the label algorithm
115
+ // switches rung, and collapsing weekends draws *fewer* day lines at
116
+ // the *same* strength (not wider-spaced lines that jump to full —
117
+ // the owner's same-zoom, different-weight complaint). Levels nest,
118
+ // so walk coarsest→finest and let the first (widest-spaced,
119
+ // strongest) claim shared anchors — a month start draws once, at
120
+ // month strength, never dimmed by the day crowd around it.
121
+ const levels = container.xKind === 'time' && 'gridLevels' in xScale
122
+ ? xScale.gridLevels(GRID_LINE_GONE_PX)
123
+ : [];
124
+ if (levels.length > 0) {
125
+ const xs = [];
126
+ const alphas = [];
127
+ const claimed = new Set();
128
+ for (let i = levels.length - 1; i >= 0; i--) {
129
+ const { values, spacing } = levels[i];
130
+ const t = Math.max(0, Math.min(1, (spacing - GRID_LINE_GONE_PX) /
131
+ (GRID_LINE_FULL_PX - GRID_LINE_GONE_PX)));
132
+ const alpha = t * t;
133
+ if (alpha <= 0.02)
134
+ continue;
135
+ for (const v of values) {
136
+ if (claimed.has(v))
137
+ continue;
138
+ claimed.add(v);
139
+ xs.push(xScale(v));
140
+ alphas.push(alpha);
141
+ }
142
+ }
143
+ drawGrid(ctx, xs, yTicks, w, h, gridColor, gridDash, alphas);
144
+ }
145
+ else {
146
+ // Value axis / no-calendar provider: verticals at the labelled
147
+ // ticks, the pre-hierarchical behavior.
148
+ const xTicks = xTickVals.map((d) => xScale(+d));
149
+ drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
150
+ }
151
+ }
84
152
  // Session dividers: solid verticals at the trading calendar's collapse
85
- // points (session/day opens), where closed time was removed from the axis.
86
- // Draw them at the axis ticks that are collapse points the same
87
- // calendar-coarsened instants the axis labels so a divider sits under
88
- // each date/month/year label, not at every session (which crowds).
153
+ // SEAMS where closed time was actually removed from the axis. Opt-in
154
+ // emphasis over the grid (default 'none' the grid's grain populations
155
+ // already mark the calendar): `'all'` draws one at every seam in view
156
+ // (the TradingView separator look, crowding lines fading out);
157
+ // `'labeled'` only at the axis ticks that are seams.
89
158
  const disc = container.discontinuities;
90
- if (disc?.boundaries) {
159
+ if (disc?.boundaries && container.sessionDividers !== 'none') {
91
160
  const [d0, d1] = container.timeRange;
92
161
  // Call as a method (not a detached reference) so a class-based provider
93
162
  // whose `boundaries` reads `this` keeps its receiver.
94
- const collapse = new Set(disc.boundaries(d0, d1));
95
- const bx = xTickVals
96
- .filter((t) => collapse.has(+t))
97
- .map((t) => xScale(+t));
163
+ //
164
+ // `boundaries` is the provider's session ROSTER — the tick ladder and
165
+ // the grid consume every session open as a date anchor. A divider,
166
+ // though, marks removed time, and a roster entry that no gap precedes
167
+ // (a demo calendar's contiguous 24h weekday "sessions", a seamless
168
+ // session roll) is roster structure, not a seam — the owner's "why
169
+ // session lines on every day when only weekends are removed?"
170
+ // (2026-07-16). Keep exactly the true seams: `b` is one iff the
171
+ // instant just before it has zero live width. For a real exchange
172
+ // calendar every open follows an overnight gap, so this keeps all.
173
+ //
174
+ // Test `<= 0`, not a positive tolerance: a collapsed gap makes
175
+ // `distance(b-1, b)` **exactly** 0 (both instants map to the same live
176
+ // position), so no epsilon is needed — and a positive tolerance is
177
+ // **unit-dependent**. Under `spacing: 'uniform'` distance is measured
178
+ // in session-units, where a contiguous (non-seam) boundary reads a
179
+ // tiny fraction (`1 / sessionMs`) that a `< 0.5` test would misread as
180
+ // a seam. Exact zero matches the semantic contract in either spacing,
181
+ // and agrees with the same probe in `buildTicks` (Codex review, #479).
182
+ const seams = disc
183
+ .boundaries(d0, d1)
184
+ .filter((b) => disc.distance(b - 1, b) <= 0);
185
+ const marks = container.sessionDividers === 'all'
186
+ ? seams
187
+ : (() => {
188
+ const collapse = new Set(seams);
189
+ return xTickVals.map((v) => +v).filter((t) => collapse.has(t));
190
+ })();
191
+ const bx = marks.map((t) => xScale(t));
98
192
  const dividerColor = container.theme.axis.sessionDivider ?? gridColor;
99
- drawDividers(ctx, thinPixels(bx, MIN_DIVIDER_PX), h, dividerColor);
193
+ if (container.sessionDividers === 'all') {
194
+ // Draw every boundary — no thinning (dropping a phase-dependent
195
+ // subset makes lines jump as the window slides). Crowding lines fade
196
+ // instead, so density falls off smoothly toward a clean plot.
197
+ drawDividers(ctx, bx, h, dividerColor, dividerAlphas(bx, SESSION_LINE_GONE_PX, SESSION_LINE_FULL_PX));
198
+ }
199
+ else {
200
+ drawDividers(ctx, thinPixels(bx, MIN_DIVIDER_PX), h, dividerColor);
201
+ }
100
202
  }
101
203
  for (const entry of layers) {
102
204
  const yScale = yScales.get(entry.axisId ?? defaultAxisId);
package/dist/XAxis.d.ts CHANGED
@@ -63,6 +63,23 @@ export interface XAxisProps {
63
63
  * for dense or wide labels that would collide when centred.
64
64
  */
65
65
  align?: 'auto' | 'center' | 'right';
66
+ /**
67
+ * How a **time** axis lays out its date context (ignored on value / category
68
+ * axes, and whenever a custom `format`, `transform`, or explicit `ticks`
69
+ * owns the labels).
70
+ * - **`'flat'` (default)** — one row: each tick that opens a coarser
71
+ * calendar period is relabelled **inline** to it (the month at a month
72
+ * turn, the year at a year turn, the date at a day turn under an intraday
73
+ * grain), every other tick a terse label (`5`, `Feb`, `14:00`). The
74
+ * TradingView look — the row reads `… 30 31 Feb 2 3 …`.
75
+ * - `'stacked'` — two rows: a terse top row (the grain's bare unit — `14:00`,
76
+ * `12`, `Feb`) over a segmented **band** row of the next-coarser period
77
+ * (day bands under intraday ticks, month bands under day ticks, year bands
78
+ * under month/quarter ticks). Each band is a left-aligned label + a divider
79
+ * at its turn, zebra-shaded by the band's calendar parity; the top-row tick
80
+ * on each turn is emphasized and joins its divider as one boundary line.
81
+ */
82
+ dateStyle?: 'flat' | 'stacked';
66
83
  }
67
84
  /**
68
85
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
@@ -74,5 +91,5 @@ export interface XAxisProps {
74
91
  *
75
92
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
76
93
  */
77
- export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
94
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
78
95
  //# sourceMappingURL=XAxis.d.ts.map