@pond-ts/charts 0.46.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,3 +1,4 @@
1
+ import { type TickGranularity } from './tickLadder.js';
1
2
  /**
2
3
  * The structural discontinuity-provider surface `scaleTradingTime` consumes to
3
4
  * collapse closed-market time. Charts declares this **shape** itself and never
@@ -16,10 +17,16 @@ export interface DiscontinuityProvider {
16
17
  offset(value: number, amount: number): number;
17
18
  copy(): DiscontinuityProvider;
18
19
  /**
19
- * Optional: the domain positions of collapsed gaps strictly inside `(from,
20
- * to)` — session/day opens where closed time was removed. The container draws
21
- * a **session divider** at each; a provider that omits it just collapses the
22
- * axis silently. (A `TradingCalendar.discontinuities()` provider supplies it.)
20
+ * Optional: the **session roster** strictly inside `(from, to)` — each
21
+ * session/day open, in ascending order. Consumed two ways: the tick ladder
22
+ * and the calendar grid treat every entry as a **date anchor** (so a
23
+ * provider whose sessions are contiguous the gap-free identity provider,
24
+ * a full-day-sessions demo — still reports every open); the container's
25
+ * **session dividers** draw only at the entries that are true collapse
26
+ * *seams* (removed time immediately precedes them — detected via
27
+ * {@link distance}), which on a real exchange calendar is all of them.
28
+ * A provider that omits this just collapses the axis silently. (A
29
+ * `TradingCalendar.discontinuities()` provider supplies it.)
23
30
  */
24
31
  boundaries?(from: number, to: number): number[];
25
32
  }
@@ -47,14 +54,17 @@ export interface TradingCalendarLike {
47
54
  * wherever the container's `xScale` goes.
48
55
  *
49
56
  * Ticks are **calendar-aware** when the provider enumerates its gaps: `.ticks`
50
- * walks the logical ladder (hour1 → hour3 → hour6 → hour12 → day → week
51
- * month → quarter → year) and returns the finest rung that fits `count`, and
57
+ * walks the logical ladder (hour1 → hour3 → hour6 → hour12 → day → month
58
+ * quarter → year; the day rung also thins by per-month midpoint subdivision before
59
+ * month grain) and returns the finest rung that fits `count`, and
52
60
  * `.tickFormat` labels each anchor at that grain (`%H:%M` for hours, `%b %d`
53
61
  * for days/weeks, `%b` for months/quarters, `%Y` for years) while formatting
54
62
  * any other instant (the cursor readout) with the d3 multi-scale default. The
55
63
  * coarser context a label drops lives on `.tickBoundaries` — the second-row
56
- * boundary labels (the date over a clock axis, the year over a day / week /
57
- * month axis), one per boundary crossing plus the first tick.
64
+ * boundary labels of the **stacked** date style (the date over a clock axis,
65
+ * the year over a day / week / month axis), one per boundary crossing plus the
66
+ * first tick. `.flatFormat` is the **flat** (default) alternative: that context
67
+ * promoted inline into a single row, the TradingView look.
58
68
  * Without a provider `boundaries` method it falls back to interior even-spaced
59
69
  * time ticks.
60
70
  *
@@ -81,6 +91,18 @@ export interface TradingTimeScale {
81
91
  * axis rather than riding a tick; year-grain ticks have no second row.
82
92
  */
83
93
  tickBoundaries(count?: number): (value: number) => string | undefined;
94
+ /**
95
+ * **Flat**-style single-row labels: the coarsest calendar unit each tick
96
+ * opens (year / month / date) promoted **inline** into the one row, its terse
97
+ * base label (bare day-of-month, month abbrev, clock time) otherwise — the
98
+ * TradingView default axis, the alternative to the two-row {@link tickFormat}
99
+ * + {@link tickBoundaries} stack. Same grain selection as {@link ticks} at the
100
+ * same `count`, so the labels sit on the tick instants. A non-tick value (the
101
+ * cursor readout) formats with the d3 multi-scale default, like
102
+ * {@link tickFormat} — so ticks read terse while the crosshair reads a full
103
+ * timestamp.
104
+ */
105
+ flatFormat(count?: number): (value: number) => string;
84
106
  /**
85
107
  * The boundary-row label for the **domain start** — the reader's left-edge
86
108
  * context (`Jan 01` over an intraday axis, the year over a month axis),
@@ -89,6 +111,59 @@ export interface TradingTimeScale {
89
111
  * from tick to tick. `undefined` when the grain has no boundary row.
90
112
  */
91
113
  boundaryContext(count?: number): string | undefined;
114
+ /**
115
+ * The **grid populations** — one level per ladder rung that fits the plot,
116
+ * finest first, each carrying its FULL anchor population in the domain
117
+ * (every aligned clock instant / session open / month / quarter / year
118
+ * start, strictly after the domain start — the window's left edge is not a
119
+ * calendar boundary). Where {@link ticks} thins one rung down to *labels*,
120
+ * these are the calendar structure the labels sit on: the container draws
121
+ * every anchor and fades each level as a unit by its `spacing`, so a
122
+ * crowding level dissolves while the coarser ones persist (the map-style
123
+ * hierarchical grid). Levels nest — de-duplicate coarsest-first.
124
+ *
125
+ * `spacing` is the level's **nominal** spacing in px — `width × the grain's
126
+ * wall-clock step / the domain's wall span` — i.e. its spacing on a gap-free
127
+ * axis. Keying the fade off calendar density rather than the measured
128
+ * on-screen gaps makes the look **mode-invariant**: collapsing weekends
129
+ * draws fewer day lines at the same strength, instead of wider-spaced day
130
+ * lines that jump to full opacity at the same zoom.
131
+ *
132
+ * `minGapPx` is the spacing at which a level's lines have fully faded; a
133
+ * level denser than that is omitted outright. Empty without a calendar
134
+ * (a provider that can't enumerate `boundaries`).
135
+ */
136
+ gridLevels(minGapPx?: number): Array<{
137
+ granularity: TickGranularity;
138
+ values: number[];
139
+ spacing: number;
140
+ }>;
141
+ /**
142
+ * The **terse base** label for each tick — the grain's bare unit with **no**
143
+ * inline promotion (`14:00`, `12`, `Feb`, `2026`), the top row of the
144
+ * **stacked band** date style. Same anchor set as {@link ticks}; a non-tick
145
+ * instant (the cursor) falls through to the d3 multi-scale default. This is
146
+ * `flatFormat` without the promotions — the coarser context lives in the
147
+ * band row ({@link bands}) instead of inline.
148
+ */
149
+ baseFormat(count?: number): (value: number) => string;
150
+ /**
151
+ * The **date bands** — the segmented second row of the stacked style. One
152
+ * entry per next-coarser calendar period touching the domain (day bands
153
+ * under intraday ticks, month bands under day ticks, year bands under
154
+ * month/quarter ticks), each `{ start, label, shaded }`: the period's start
155
+ * instant (the first band's `start` may precede the domain — its label pins
156
+ * at the left edge), the left-aligned label, and a stable zebra `shaded`
157
+ * flag keyed to the band's calendar identity (pan/zoom-invariant). The
158
+ * renderer draws a divider at each interior `start`, fills shaded bands, and
159
+ * emphasizes the top-row tick sitting at each `start`. Empty at year grain
160
+ * (nothing coarser to band) and without a calendar provider.
161
+ */
162
+ bands(count?: number): Array<{
163
+ start: number;
164
+ label: string;
165
+ shaded: boolean;
166
+ }>;
92
167
  domain(): [number, number];
93
168
  domain(next: readonly [number, number]): TradingTimeScale;
94
169
  range(): [number, number];
@@ -1,5 +1,5 @@
1
1
  import { scaleTime } from 'd3-scale';
2
- import { boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildTicks, majorFormatFor, } from './tickLadder.js';
2
+ import { bandFormatFor, bandGrainFor, bandNext, bandShaded, bandStartOf, boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildGridLevels, buildTicks, flatBaseFormatFor, flatFormats, majorFormatFor, nominalStepMs, } from './tickLadder.js';
3
3
  // Grain selection lives in `tickLadder.ts` (the full hour1…year ladder plus
4
4
  // the boundary-row helpers); re-exported here so existing imports keep working.
5
5
  export { coarsenCalendar } from './tickLadder.js';
@@ -58,10 +58,19 @@ export function scaleTradingTime(provider) {
58
58
  return provider.offset(domain[0], frac * totalLive());
59
59
  };
60
60
  /** The session-open instants in the domain — the first session's open (the
61
- * left edge) plus each collapsed-gap boundary — the axis's date anchors. */
61
+ * left edge) plus each collapsed-gap boundary — the axis's date anchors.
62
+ * Memoized per domain: both the label ladder ({@link resolved}) and the
63
+ * grid populations ({@link TradingTimeScale.gridLevels}) walk these every
64
+ * frame of a pan, and on a wide continuous domain the enumeration (one
65
+ * Date per day) is the expensive part. */
66
+ let opensMemo = null;
62
67
  const sessionOpens = () => {
63
- const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
64
- return [domain[0], ...bounds];
68
+ const key = `${domain[0]}:${domain[1]}`;
69
+ if (opensMemo?.key !== key) {
70
+ const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
71
+ opensMemo = { key, value: [domain[0], ...bounds] };
72
+ }
73
+ return opensMemo.value;
65
74
  };
66
75
  /** Whether the provider has calendar structure to ladder on. Without a
67
76
  * `boundaries` method there are no anchors — the even-spacing fallback. */
@@ -130,6 +139,70 @@ export function scaleTradingTime(provider) {
130
139
  }
131
140
  return (value) => labelled.get(value);
132
141
  };
142
+ scale.flatFormat = (count = 10) => {
143
+ // A non-tick instant (the cursor readout) always uses the d3 multi-scale
144
+ // default — same as tickFormat, so the crosshair reads a full timestamp
145
+ // while the ticks read terse. Without a calendar there are no ladder
146
+ // anchors, so every value falls through to the default.
147
+ const defFmt = base.tickFormat(count);
148
+ if (!hasCalendar())
149
+ return (value) => defFmt(new Date(value));
150
+ const { ticks, granularity } = resolved(count);
151
+ // Seed the promotion walk from the previous **live** instant before the
152
+ // domain start (clampDown; identity on a continuous axis): a domain that
153
+ // opens exactly on a month's first session then promotes that tick to the
154
+ // month (`apr 8 14 …`, not `1 8 14 …`) — the previous session was in
155
+ // March — while a mid-session start still suppresses the false promotion.
156
+ const specs = flatFormats(ticks, granularity, provider.clampDown(domain[0] - 1));
157
+ // One d3 formatter per distinct specifier; most ticks share the base one.
158
+ const bySpec = new Map();
159
+ const fmtFor = (spec) => {
160
+ let f = bySpec.get(spec);
161
+ if (f === undefined) {
162
+ f = base.tickFormat(count, spec);
163
+ bySpec.set(spec, f);
164
+ }
165
+ return f;
166
+ };
167
+ const labelled = new Map();
168
+ ticks.forEach((t, i) => labelled.set(t, fmtFor(specs[i])(new Date(t))));
169
+ return (value) => labelled.get(value) ?? defFmt(new Date(value));
170
+ };
171
+ scale.baseFormat = (count = 10) => {
172
+ // The terse top row of the stacked band style: the grain's bare unit, no
173
+ // inline promotion (that context lives in the band row). Anchors get the
174
+ // grain's flat base format; a non-tick instant (the cursor) reads the d3
175
+ // multi-scale default, so the crosshair still shows a full timestamp.
176
+ const defFmt = base.tickFormat(count);
177
+ if (!hasCalendar())
178
+ return (value) => defFmt(new Date(value));
179
+ const { ticks, granularity } = resolved(count);
180
+ const anchors = new Set(ticks);
181
+ const terse = base.tickFormat(count, flatBaseFormatFor(granularity));
182
+ return (value) => anchors.has(value) ? terse(new Date(value)) : defFmt(new Date(value));
183
+ };
184
+ scale.bands = (count = 10) => {
185
+ if (!hasCalendar())
186
+ return [];
187
+ const bg = bandGrainFor(resolved(count).granularity);
188
+ if (bg === undefined)
189
+ return []; // year grain — nothing coarser to band
190
+ const fmt = base.tickFormat(count, bandFormatFor(bg));
191
+ const out = [];
192
+ // First band starts at (or before) the domain start — the partial left
193
+ // band whose label the renderer pins at x=0; step to each next period
194
+ // start still inside the domain. Bounded loop as a runaway guard.
195
+ let s = bandStartOf(domain[0], bg);
196
+ for (let i = 0; i < 100_000 && s < domain[1]; i++) {
197
+ out.push({
198
+ start: s,
199
+ label: fmt(new Date(s)),
200
+ shaded: bandShaded(s, bg),
201
+ });
202
+ s = bandNext(s, bg);
203
+ }
204
+ return out;
205
+ };
133
206
  scale.boundaryContext = (count = 10) => {
134
207
  if (!hasCalendar())
135
208
  return undefined;
@@ -140,6 +213,37 @@ export function scaleTradingTime(provider) {
140
213
  const fmt = base.tickFormat(count, boundaryFormatFor(bg));
141
214
  return fmt(new Date(domain[0]));
142
215
  };
216
+ /** Memoized like {@link resolved}: the draw pass asks once per frame, and
217
+ * the populations only change with the domain / width / fade floor. */
218
+ let gridMemo = null;
219
+ scale.gridLevels = (minGapPx = 4) => {
220
+ if (!hasCalendar())
221
+ return [];
222
+ const width = Math.abs(range[1] - range[0]);
223
+ const wallSpan = domain[1] - domain[0];
224
+ if (wallSpan <= 0 || width <= 0)
225
+ return [];
226
+ const cap = Math.max(1, Math.floor(width / Math.max(1, minGapPx)));
227
+ const key = `${domain[0]}:${domain[1]}:${width}:${minGapPx}`;
228
+ if (gridMemo?.key !== key) {
229
+ gridMemo = {
230
+ key,
231
+ value: buildGridLevels(provider, sessionOpens(), domain[1], cap)
232
+ .map((l) => ({
233
+ granularity: l.granularity,
234
+ // The first open is the domain start itself — a window edge, not
235
+ // a boundary (and mid-period in general). Grid lines are the
236
+ // strictly-interior anchors.
237
+ values: l.values.filter((v) => v > domain[0]),
238
+ // Nominal (gap-free) spacing — the fade metric; see the
239
+ // interface doc for why it isn't the measured on-screen gap.
240
+ spacing: (width * nominalStepMs(l.granularity)) / wallSpan,
241
+ }))
242
+ .filter((l) => l.values.length > 0 && l.spacing > minGapPx),
243
+ };
244
+ }
245
+ return gridMemo.value;
246
+ };
143
247
  function domainFn(next) {
144
248
  if (next === undefined)
145
249
  return [domain[0], domain[1]];
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/pjm17971/pond-ts.git",
9
+ "url": "git+https://github.com/pond-ts/pond.git",
10
10
  "directory": "packages/charts"
11
11
  },
12
12
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.46.0",
42
- "pond-ts": "^0.46.0",
41
+ "@pond-ts/react": "^0.47.0",
42
+ "pond-ts": "^0.47.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {