@pond-ts/charts 0.67.0 → 0.69.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,34 +1,84 @@
1
- import { scaleTime } from 'd3-scale';
2
- import { bandFormatFor, bandGrainFor, bandNext, bandShaded, bandStartOf, boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildGridLevels, buildTicks, coarseUnitOf, flatBaseFormatFor, flatFormats, majorFormatFor, nominalStepMs, readoutFormatFor, } from './tickLadder.js';
1
+ import { scaleTime, scaleUtc } from 'd3-scale';
2
+ import { utcFormat } from 'd3-time-format';
3
+ import { TimeZone } from 'pond-ts';
4
+ import { bandFormatFor, bandGrainFor, bandNext, bandShaded, bandStartOf, boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildGridLevels, buildTicks, coarseUnitOf, flatBaseFormatFor, flatFormats, majorFormatFor, nominalStepMs, readoutFormatFor, tickCalendarFor, } from './tickLadder.js';
5
+ /**
6
+ * The d3 time-specifier formatter for a zone. Local (no zone) is d3's own
7
+ * `timeFormat` through the scale, untouched. A named zone formats a
8
+ * **civil-shifted** date — the instant moved by the zone's offset and then
9
+ * read in UTC — so every `%Y %m %d %H %M %S %a %b %p …` directive reads in the
10
+ * zone for free; `%Z` / `%z`, which the shift would render `UTC` / `+0000`,
11
+ * are substituted per instant from the zone itself. The two directives that
12
+ * print the *instant* rather than a wall-clock field — `%s` (epoch seconds)
13
+ * and `%Q` (epoch ms) — read the shifted instant, i.e. offset by the zone;
14
+ * they have no meaning on a zoned axis.
15
+ */
16
+ function zonedFormatter(zone, specifier) {
17
+ // Tokenise so an escaped percent (`%%`) is never read as the start of a
18
+ // directive: `'%%Z'` is a literal `%Z`, not the zone name.
19
+ const tokens = specifier.match(/%%|%[Zz]|[^%]+|%/g) ?? [];
20
+ const hasZoneName = tokens.some((tok) => tok === '%Z' || tok === '%z');
21
+ if (!hasZoneName) {
22
+ const f = utcFormat(specifier);
23
+ return (d) => {
24
+ const t = +d;
25
+ return f(new Date(t + zone.offsetAt(t)));
26
+ };
27
+ }
28
+ const cache = new Map();
29
+ return (d) => {
30
+ const t = +d;
31
+ const offset = zone.offsetAt(t);
32
+ const sign = offset < 0 ? '-' : '+';
33
+ const abs = Math.abs(offset) / 60_000;
34
+ const hh = String(Math.floor(abs / 60)).padStart(2, '0');
35
+ const mm = String(abs % 60).padStart(2, '0');
36
+ const resolved = tokens
37
+ .map((tok) => tok === '%Z'
38
+ ? zone.abbreviation(t).replace(/%/g, '%%')
39
+ : tok === '%z'
40
+ ? `${sign}${hh}${mm}`
41
+ : tok)
42
+ .join('');
43
+ let f = cache.get(resolved);
44
+ if (f === undefined) {
45
+ f = utcFormat(resolved);
46
+ cache.set(resolved, f);
47
+ }
48
+ return f(new Date(t + offset));
49
+ };
50
+ }
3
51
  // Grain selection lives in `tickLadder.ts` (the full hour1…year ladder plus
4
52
  // the boundary-row helpers); re-exported here so existing imports keep working.
5
53
  export { coarsenCalendar } from './tickLadder.js';
6
54
  /**
7
55
  * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
8
- * time, and every local midnight is a "session open". Backing a plain
56
+ * time, and every midnight (in `timeZone`, default runtime-local) is a
57
+ * "session open". Backing a plain
9
58
  * continuous time axis with `scaleTradingTime(identityProvider())` runs it
10
59
  * through the same logical tick ladder as a trading-calendar axis — calendar
11
60
  * days are the day anchors, so a year of data ticks on month starts and an
12
61
  * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
13
62
  * default.
14
63
  */
15
- export function identityProvider() {
64
+ export function identityProvider(options = {}) {
65
+ const cal = tickCalendarFor(options.timeZone);
16
66
  const self = {
17
67
  clampUp: (t) => t,
18
68
  clampDown: (t) => t,
19
69
  distance: (from, to) => to - from,
20
70
  offset: (v, amount) => v + amount,
21
71
  copy: () => self,
72
+ withTimeZone: (timeZone) => identityProvider({ timeZone }),
22
73
  boundaries: (from, to) => {
23
74
  const out = [];
24
- const d = new Date(from);
25
- // First local midnight strictly after `from`; step by calendar day (not
26
- // 24h) so DST transitions stay on midnight.
27
- let cur = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
28
- while (cur.getTime() < to) {
29
- if (cur.getTime() > from)
30
- out.push(cur.getTime());
31
- cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
75
+ // First midnight (in the zone) strictly after `from`; step by calendar
76
+ // day (not 24h) so DST transitions stay on midnight.
77
+ let cur = cal.nextDay(from);
78
+ while (cur < to) {
79
+ if (cur > from)
80
+ out.push(cur);
81
+ cur = cal.nextDay(cur);
32
82
  }
33
83
  return out;
34
84
  },
@@ -39,11 +89,31 @@ export function identityProvider() {
39
89
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
40
90
  * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
41
91
  */
42
- export function scaleTradingTime(provider) {
92
+ export function scaleTradingTime(provider, options = {}) {
43
93
  let domain = [0, 1];
44
94
  let range = [0, 1];
45
- // A private d3 time scale, kept in sync with the domain, purely for tickFormat.
95
+ const zone = options.timeZone === undefined ? undefined : TimeZone.of(options.timeZone);
96
+ const cal = tickCalendarFor(zone?.id);
97
+ // A private d3 time scale purely for formatting. Local: d3's own `timeFormat`
98
+ // (and its multi-scale default) untouched. Zoned: the same specifiers on a
99
+ // civil-shifted date via `utcFormat`, and a `scaleUtc` multi-scale default
100
+ // on the shifted date so the fallback picks its unit in the zone too.
46
101
  const base = scaleTime();
102
+ const baseUtc = zone === undefined ? undefined : scaleUtc();
103
+ const fmt = (count, specifier) => {
104
+ if (zone === undefined) {
105
+ return specifier === undefined
106
+ ? base.tickFormat(count)
107
+ : base.tickFormat(count, specifier);
108
+ }
109
+ if (specifier !== undefined)
110
+ return zonedFormatter(zone, specifier);
111
+ const def = baseUtc.tickFormat(count);
112
+ return (d) => {
113
+ const t = +d;
114
+ return def(new Date(t + zone.offsetAt(t)));
115
+ };
116
+ };
47
117
  const totalLive = () => provider.distance(domain[0], domain[1]);
48
118
  const scale = ((value) => {
49
119
  const live = totalLive();
@@ -86,7 +156,7 @@ export function scaleTradingTime(provider) {
86
156
  if (laddered?.key !== key) {
87
157
  laddered = {
88
158
  key,
89
- value: buildTicks(provider, sessionOpens(), domain[1], count),
159
+ value: buildTicks(provider, sessionOpens(), domain[1], count, cal),
90
160
  };
91
161
  }
92
162
  return laddered.value;
@@ -110,8 +180,8 @@ export function scaleTradingTime(provider) {
110
180
  };
111
181
  scale.tickFormat = (count = 10, specifier) => {
112
182
  if (specifier !== undefined)
113
- return base.tickFormat(count, specifier);
114
- const defFmt = base.tickFormat(count);
183
+ return fmt(count, specifier);
184
+ const defFmt = fmt(count);
115
185
  if (!hasCalendar())
116
186
  return defFmt; // no calendar → d3 multi-scale default
117
187
  // Anchor labels at the grain {@link ticks} chose — one uniform format per
@@ -122,7 +192,7 @@ export function scaleTradingTime(provider) {
122
192
  // the dividers drawn at these instants agree.
123
193
  const { ticks, granularity } = resolved(count);
124
194
  const anchors = new Set(ticks);
125
- const anchorFmt = base.tickFormat(count, majorFormatFor(granularity));
195
+ const anchorFmt = fmt(count, majorFormatFor(granularity));
126
196
  return (d) => (anchors.has(+d) ? anchorFmt(d) : defFmt(d));
127
197
  };
128
198
  scale.tickBoundaries = (count = 10) => {
@@ -132,10 +202,10 @@ export function scaleTradingTime(provider) {
132
202
  const bg = boundaryGrainFor(granularity);
133
203
  if (bg === undefined)
134
204
  return () => undefined;
135
- const fmt = base.tickFormat(count, boundaryFormatFor(bg));
205
+ const bfmt = fmt(count, boundaryFormatFor(bg));
136
206
  const labelled = new Map();
137
- for (const t of boundaryTicks(ticks, granularity, domain[0])) {
138
- labelled.set(t, fmt(new Date(t)));
207
+ for (const t of boundaryTicks(ticks, granularity, domain[0], cal)) {
208
+ labelled.set(t, bfmt(new Date(t)));
139
209
  }
140
210
  return (value) => labelled.get(value);
141
211
  };
@@ -144,7 +214,7 @@ export function scaleTradingTime(provider) {
144
214
  // tickFormat. (The cursor readout doesn't route through here; it uses
145
215
  // readoutFormat, grain-aware.) Without a calendar there are no ladder
146
216
  // anchors, so every value falls through to the default.
147
- const defFmt = base.tickFormat(count);
217
+ const defFmt = fmt(count);
148
218
  if (!hasCalendar())
149
219
  return (value) => defFmt(new Date(value));
150
220
  const { ticks, granularity } = resolved(count);
@@ -153,13 +223,13 @@ export function scaleTradingTime(provider) {
153
223
  // opens exactly on a month's first session then promotes that tick to the
154
224
  // month (`apr 8 14 …`, not `1 8 14 …`) — the previous session was in
155
225
  // March — while a mid-session start still suppresses the false promotion.
156
- const specs = flatFormats(ticks, granularity, provider.clampDown(domain[0] - 1));
226
+ const specs = flatFormats(ticks, granularity, provider.clampDown(domain[0] - 1), cal);
157
227
  // One d3 formatter per distinct specifier; most ticks share the base one.
158
228
  const bySpec = new Map();
159
229
  const fmtFor = (spec) => {
160
230
  let f = bySpec.get(spec);
161
231
  if (f === undefined) {
162
- f = base.tickFormat(count, spec);
232
+ f = fmt(count, spec);
163
233
  bySpec.set(spec, f);
164
234
  }
165
235
  return f;
@@ -173,20 +243,20 @@ export function scaleTradingTime(provider) {
173
243
  // inline promotion (that context lives in the band row). Anchors get the
174
244
  // grain's flat base format; a non-tick instant (the cursor) reads the d3
175
245
  // multi-scale default, so the crosshair still shows a full timestamp.
176
- const defFmt = base.tickFormat(count);
246
+ const defFmt = fmt(count);
177
247
  if (!hasCalendar())
178
248
  return (value) => defFmt(new Date(value));
179
249
  const { ticks, granularity } = resolved(count);
180
250
  const anchors = new Set(ticks);
181
- const terse = base.tickFormat(count, flatBaseFormatFor(granularity));
251
+ const terse = fmt(count, flatBaseFormatFor(granularity));
182
252
  return (value) => anchors.has(value) ? terse(new Date(value)) : defFmt(new Date(value));
183
253
  };
184
254
  scale.readoutFormat = (count = 10) => {
185
- const defFmt = base.tickFormat(count);
255
+ const defFmt = fmt(count);
186
256
  if (!hasCalendar())
187
257
  return (value) => defFmt(new Date(value));
188
- const fmt = base.tickFormat(count, readoutFormatFor(resolved(count).granularity));
189
- return (value) => fmt(new Date(value));
258
+ const rfmt = fmt(count, readoutFormatFor(resolved(count).granularity));
259
+ return (value) => rfmt(new Date(value));
190
260
  };
191
261
  scale.grain = (count = 10) => hasCalendar() ? coarseUnitOf(resolved(count).granularity) : 'day';
192
262
  scale.bands = (count = 10) => {
@@ -196,7 +266,7 @@ export function scaleTradingTime(provider) {
196
266
  const bg = bandGrainFor(granularity);
197
267
  if (bg === undefined)
198
268
  return []; // year grain — nothing coarser to band
199
- const fmt = base.tickFormat(count, bandFormatFor(bg));
269
+ const bandFmt = fmt(count, bandFormatFor(bg));
200
270
  const tickSet = new Set(ticks);
201
271
  // A band's raw calendar start is a date, not necessarily a LIVE instant —
202
272
  // a month or day beginning on a collapsed weekend/holiday clamps onto the
@@ -207,13 +277,13 @@ export function scaleTradingTime(provider) {
207
277
  // can be resolved as one group rather than emitting (and then trying to
208
278
  // retract) a label per member.
209
279
  const candidates = [];
210
- let s = bandStartOf(domain[0], bg);
280
+ let s = bandStartOf(domain[0], bg, cal);
211
281
  // First band starts at (or before) the domain start — the partial left
212
282
  // band whose label the renderer pins at x=0; step to each next period
213
283
  // start still inside the domain. Bounded loop as a runaway guard.
214
284
  for (let i = 0; i < 100_000 && s < domain[1]; i++) {
215
285
  candidates.push({ s, live: provider.clampUp(s) });
216
- s = bandNext(s, bg);
286
+ s = bandNext(s, bg, cal);
217
287
  }
218
288
  const out = [];
219
289
  for (let i = 0; i < candidates.length;) {
@@ -249,9 +319,9 @@ export function scaleTradingTime(provider) {
249
319
  // Formatted from the representative's own RAW start, not the
250
320
  // clamped one — a genuinely-live rep reads its own date; a
251
321
  // gap-only run's rep reads whichever raw date it fell back to.
252
- label: fmt(new Date(rep.s)),
322
+ label: bandFmt(new Date(rep.s)),
253
323
  showLabel: !collides,
254
- shaded: bandShaded(rep.s, bg),
324
+ shaded: bandShaded(rep.s, bg, cal),
255
325
  });
256
326
  i = j;
257
327
  }
@@ -264,8 +334,7 @@ export function scaleTradingTime(provider) {
264
334
  const bg = boundaryGrainFor(granularity);
265
335
  if (bg === undefined)
266
336
  return undefined;
267
- const fmt = base.tickFormat(count, boundaryFormatFor(bg));
268
- return fmt(new Date(domain[0]));
337
+ return fmt(count, boundaryFormatFor(bg))(new Date(domain[0]));
269
338
  };
270
339
  /** Memoized like {@link resolved}: the draw pass asks once per frame, and
271
340
  * the populations only change with the domain / width / fade floor. */
@@ -282,7 +351,7 @@ export function scaleTradingTime(provider) {
282
351
  if (gridMemo?.key !== key) {
283
352
  gridMemo = {
284
353
  key,
285
- value: buildGridLevels(provider, sessionOpens(), domain[1], cap)
354
+ value: buildGridLevels(provider, sessionOpens(), domain[1], cap, cal)
286
355
  .map((l) => ({
287
356
  granularity: l.granularity,
288
357
  // The first open is the domain start itself — a window edge, not
@@ -312,7 +381,13 @@ export function scaleTradingTime(provider) {
312
381
  return scale;
313
382
  }
314
383
  scale.range = rangeFn;
315
- scale.copy = () => scaleTradingTime(provider.copy()).domain(domain).range(range);
384
+ scale.copy = () => scaleTradingTime(provider.copy(), options).domain(domain).range(range);
385
+ scale.withTimeZone = (timeZone) => scaleTradingTime(provider.withTimeZone?.(timeZone) ?? provider.copy(), {
386
+ timeZone,
387
+ })
388
+ .domain(domain)
389
+ .range(range);
390
+ scale.timeZone = () => zone?.id;
316
391
  return scale;
317
392
  }
318
393
  //# sourceMappingURL=tradingTimeScale.js.map
package/package.json CHANGED
@@ -1,8 +1,30 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.67.0",
3
+ "version": "0.69.0",
4
4
  "private": false,
5
- "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
5
+ "description": "Canvas-rendered, streaming-first React time-series charts for pond-ts: line, area, band, bar, scatter, box, candlestick, with cursors, selection and annotations",
6
+ "keywords": [
7
+ "time-series",
8
+ "timeseries",
9
+ "typescript",
10
+ "streaming",
11
+ "analytics",
12
+ "react",
13
+ "charts",
14
+ "canvas",
15
+ "visualization",
16
+ "line-chart",
17
+ "candlestick",
18
+ "ohlc",
19
+ "realtime",
20
+ "dashboard",
21
+ "react-timeseries-charts",
22
+ "pond-ts"
23
+ ],
24
+ "homepage": "https://pond-ts.org/docs/charts/",
25
+ "bugs": {
26
+ "url": "https://github.com/pond-ts/pond/issues"
27
+ },
6
28
  "license": "MIT",
7
29
  "repository": {
8
30
  "type": "git",
@@ -25,11 +47,12 @@
25
47
  "files": [
26
48
  "dist",
27
49
  "CHANGELOG.md",
28
- "API.md"
50
+ "API.md",
51
+ "AGENTS.md"
29
52
  ],
30
53
  "scripts": {
31
54
  "build": "tsc -p tsconfig.json",
32
- "prepack": "cp ../../LICENSE ./LICENSE && cp ../../CHANGELOG.md ./CHANGELOG.md && cp ../../API.md ./API.md && npm run build && cp cjs-fallback.cjs dist/cjs-fallback.cjs && find dist -name '*.map' -delete",
55
+ "prepack": "cp ../../LICENSE ./LICENSE && cp ../../CHANGELOG.md ./CHANGELOG.md && cp ../../API.md ./API.md && cp ../../docs/agents/USING_POND.md ./AGENTS.md && npm run build && cp cjs-fallback.cjs dist/cjs-fallback.cjs && find dist -name '*.map' -delete",
33
56
  "test": "npm run test:type && npm run test:runtime",
34
57
  "test:type": "tsc -p tsconfig.types.json",
35
58
  "test:runtime": "vitest run",
@@ -39,8 +62,8 @@
39
62
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
40
63
  },
41
64
  "peerDependencies": {
42
- "@pond-ts/react": "^0.67.0",
43
- "pond-ts": "^0.67.0",
65
+ "@pond-ts/react": "^0.69.0",
66
+ "pond-ts": "^0.69.0",
44
67
  "react": "^18.0.0 || ^19.0.0"
45
68
  },
46
69
  "devDependencies": {
@@ -50,6 +73,7 @@
50
73
  "@testing-library/react": "^16.3.2",
51
74
  "@types/d3-scale": "^4.0.9",
52
75
  "@types/d3-shape": "^3.1.8",
76
+ "@types/d3-time-format": "^4.0.3",
53
77
  "@types/react": "^19.0.0",
54
78
  "@types/react-dom": "^19.2.3",
55
79
  "happy-dom": "^20.9.0",
@@ -63,6 +87,7 @@
63
87
  },
64
88
  "dependencies": {
65
89
  "d3-scale": "^4.0.2",
66
- "d3-shape": "^3.2.0"
90
+ "d3-shape": "^3.2.0",
91
+ "d3-time-format": "^4.1.0"
67
92
  }
68
93
  }