@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,3 +1,4 @@
1
+ import { type TickGranularity, type TimeGrain } 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,18 @@ 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
- * any other instant (the cursor readout) with the d3 multi-scale default. The
62
+ * any other instant with the d3 multi-scale default (the cursor readout uses
63
+ * `.readoutFormat`, a grain-aware date/clock format, not this). The
55
64
  * 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.
65
+ * boundary labels of the **stacked** date style (the date over a clock axis,
66
+ * the year over a day / week / month axis), one per boundary crossing plus the
67
+ * first tick. `.flatFormat` is the **flat** (default) alternative: that context
68
+ * promoted inline into a single row, the TradingView look.
58
69
  * Without a provider `boundaries` method it falls back to interior even-spaced
59
70
  * time ticks.
60
71
  *
@@ -81,6 +92,18 @@ export interface TradingTimeScale {
81
92
  * axis rather than riding a tick; year-grain ticks have no second row.
82
93
  */
83
94
  tickBoundaries(count?: number): (value: number) => string | undefined;
95
+ /**
96
+ * **Flat**-style single-row labels: the coarsest calendar unit each tick
97
+ * opens (year / month / date) promoted **inline** into the one row, its terse
98
+ * base label (bare day-of-month, month abbrev, clock time) otherwise — the
99
+ * TradingView default axis, the alternative to the two-row {@link tickFormat}
100
+ * + {@link tickBoundaries} stack. Same grain selection as {@link ticks} at the
101
+ * same `count`, so the labels sit on the tick instants. A non-tick value
102
+ * formats with the d3 multi-scale default, like {@link tickFormat}; the
103
+ * cursor readout itself uses {@link readoutFormat} (grain-aware), so ticks
104
+ * read terse while the crosshair reads an unambiguous date/clock.
105
+ */
106
+ flatFormat(count?: number): (value: number) => string;
84
107
  /**
85
108
  * The boundary-row label for the **domain start** — the reader's left-edge
86
109
  * context (`Jan 01` over an intraday axis, the year over a month axis),
@@ -89,6 +112,77 @@ export interface TradingTimeScale {
89
112
  * from tick to tick. `undefined` when the grain has no boundary row.
90
113
  */
91
114
  boundaryContext(count?: number): string | undefined;
115
+ /**
116
+ * The **grid populations** — one level per ladder rung that fits the plot,
117
+ * finest first, each carrying its FULL anchor population in the domain
118
+ * (every aligned clock instant / session open / month / quarter / year
119
+ * start, strictly after the domain start — the window's left edge is not a
120
+ * calendar boundary). Where {@link ticks} thins one rung down to *labels*,
121
+ * these are the calendar structure the labels sit on: the container draws
122
+ * every anchor and fades each level as a unit by its `spacing`, so a
123
+ * crowding level dissolves while the coarser ones persist (the map-style
124
+ * hierarchical grid). Levels nest — de-duplicate coarsest-first.
125
+ *
126
+ * `spacing` is the level's **nominal** spacing in px — `width × the grain's
127
+ * wall-clock step / the domain's wall span` — i.e. its spacing on a gap-free
128
+ * axis. Keying the fade off calendar density rather than the measured
129
+ * on-screen gaps makes the look **mode-invariant**: collapsing weekends
130
+ * draws fewer day lines at the same strength, instead of wider-spaced day
131
+ * lines that jump to full opacity at the same zoom.
132
+ *
133
+ * `minGapPx` is the spacing at which a level's lines have fully faded; a
134
+ * level denser than that is omitted outright. Empty without a calendar
135
+ * (a provider that can't enumerate `boundaries`).
136
+ */
137
+ gridLevels(minGapPx?: number): Array<{
138
+ granularity: TickGranularity;
139
+ values: number[];
140
+ spacing: number;
141
+ }>;
142
+ /**
143
+ * The **terse base** label for each tick — the grain's bare unit with **no**
144
+ * inline promotion (`14:00`, `12`, `Feb`, `2026`), the top row of the
145
+ * **stacked band** date style. Same anchor set as {@link ticks}; a non-tick
146
+ * instant (the cursor) falls through to the d3 multi-scale default. This is
147
+ * `flatFormat` without the promotions — the coarser context lives in the
148
+ * band row ({@link bands}) instead of inline.
149
+ */
150
+ baseFormat(count?: number): (value: number) => string;
151
+ /**
152
+ * The **cursor / marker readout** formatter — a hovered instant formatted at
153
+ * the axis's own grain, never finer (a day-or-coarser axis reads a **date**,
154
+ * so a daily bar at a foreign-tz midnight never renders as a time-of-day like
155
+ * `02 AM`; a sub-day axis reads date **+** clock). This is the grain-aware
156
+ * default the crosshair pill, marker indicators, and annotation auto-labels
157
+ * use — replacing d3's multi-scale default, which showed local time-of-day
158
+ * for any off-local-midnight instant. A container `cursorFormat` overrides it.
159
+ */
160
+ readoutFormat(count?: number): (value: number) => string;
161
+ /**
162
+ * The axis's resolved **coarse grain** ({@link TimeGrain}) at `count` — the
163
+ * unit the ticks currently sit on (`year` … `second`), strides collapsed. A
164
+ * `cursorFormat` callback receives this so it can branch on the zoom level
165
+ * (return a year when zoomed out, a clock when zoomed in) without
166
+ * re-deriving the ladder the axis already ran.
167
+ */
168
+ grain(count?: number): TimeGrain;
169
+ /**
170
+ * The **date bands** — the segmented second row of the stacked style. One
171
+ * entry per next-coarser calendar period touching the domain (day bands
172
+ * under intraday ticks, month bands under day ticks, year bands under
173
+ * month/quarter ticks), each `{ start, label, shaded }`: the period's start
174
+ * instant (the first band's `start` may precede the domain — its label pins
175
+ * at the left edge), the left-aligned label, and a stable zebra `shaded`
176
+ * flag keyed to the band's calendar identity (pan/zoom-invariant). The
177
+ * renderer draws a divider at each interior `start`, fills shaded bands, and
178
+ * emphasizes the top-row tick sitting at each `start`. Empty at year grain
179
+ * (nothing coarser to band) and without a calendar provider.
180
+ */
181
+ bands(count?: number): Array<{
182
+ start: number;
183
+ label: string;
184
+ shaded: boolean;
185
+ }>;
92
186
  domain(): [number, number];
93
187
  domain(next: readonly [number, number]): TradingTimeScale;
94
188
  range(): [number, number];
@@ -96,7 +190,7 @@ export interface TradingTimeScale {
96
190
  copy(): TradingTimeScale;
97
191
  }
98
192
  export { coarsenCalendar } from './tickLadder.js';
99
- export type { TickGranularity } from './tickLadder.js';
193
+ export type { TickGranularity, TimeGrain } from './tickLadder.js';
100
194
  /**
101
195
  * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
102
196
  * time, and every local midnight is a "session open". Backing a plain
@@ -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, coarseUnitOf, flatBaseFormatFor, flatFormats, majorFormatFor, nominalStepMs, readoutFormatFor, } 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,78 @@ 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 always uses the d3 multi-scale default — same as
144
+ // tickFormat. (The cursor readout doesn't route through here; it uses
145
+ // readoutFormat, grain-aware.) 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.readoutFormat = (count = 10) => {
185
+ const defFmt = base.tickFormat(count);
186
+ if (!hasCalendar())
187
+ return (value) => defFmt(new Date(value));
188
+ const fmt = base.tickFormat(count, readoutFormatFor(resolved(count).granularity));
189
+ return (value) => fmt(new Date(value));
190
+ };
191
+ scale.grain = (count = 10) => hasCalendar() ? coarseUnitOf(resolved(count).granularity) : 'day';
192
+ scale.bands = (count = 10) => {
193
+ if (!hasCalendar())
194
+ return [];
195
+ const bg = bandGrainFor(resolved(count).granularity);
196
+ if (bg === undefined)
197
+ return []; // year grain — nothing coarser to band
198
+ const fmt = base.tickFormat(count, bandFormatFor(bg));
199
+ const out = [];
200
+ // First band starts at (or before) the domain start — the partial left
201
+ // band whose label the renderer pins at x=0; step to each next period
202
+ // start still inside the domain. Bounded loop as a runaway guard.
203
+ let s = bandStartOf(domain[0], bg);
204
+ for (let i = 0; i < 100_000 && s < domain[1]; i++) {
205
+ out.push({
206
+ start: s,
207
+ label: fmt(new Date(s)),
208
+ shaded: bandShaded(s, bg),
209
+ });
210
+ s = bandNext(s, bg);
211
+ }
212
+ return out;
213
+ };
133
214
  scale.boundaryContext = (count = 10) => {
134
215
  if (!hasCalendar())
135
216
  return undefined;
@@ -140,6 +221,37 @@ export function scaleTradingTime(provider) {
140
221
  const fmt = base.tickFormat(count, boundaryFormatFor(bg));
141
222
  return fmt(new Date(domain[0]));
142
223
  };
224
+ /** Memoized like {@link resolved}: the draw pass asks once per frame, and
225
+ * the populations only change with the domain / width / fade floor. */
226
+ let gridMemo = null;
227
+ scale.gridLevels = (minGapPx = 4) => {
228
+ if (!hasCalendar())
229
+ return [];
230
+ const width = Math.abs(range[1] - range[0]);
231
+ const wallSpan = domain[1] - domain[0];
232
+ if (wallSpan <= 0 || width <= 0)
233
+ return [];
234
+ const cap = Math.max(1, Math.floor(width / Math.max(1, minGapPx)));
235
+ const key = `${domain[0]}:${domain[1]}:${width}:${minGapPx}`;
236
+ if (gridMemo?.key !== key) {
237
+ gridMemo = {
238
+ key,
239
+ value: buildGridLevels(provider, sessionOpens(), domain[1], cap)
240
+ .map((l) => ({
241
+ granularity: l.granularity,
242
+ // The first open is the domain start itself — a window edge, not
243
+ // a boundary (and mid-period in general). Grid lines are the
244
+ // strictly-interior anchors.
245
+ values: l.values.filter((v) => v > domain[0]),
246
+ // Nominal (gap-free) spacing — the fade metric; see the
247
+ // interface doc for why it isn't the measured on-screen gap.
248
+ spacing: (width * nominalStepMs(l.granularity)) / wallSpan,
249
+ }))
250
+ .filter((l) => l.values.length > 0 && l.spacing > minGapPx),
251
+ };
252
+ }
253
+ return gridMemo.value;
254
+ };
143
255
  function domainFn(next) {
144
256
  if (next === undefined)
145
257
  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.48.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.48.0",
42
+ "pond-ts": "^0.48.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {