@pond-ts/charts 0.44.0 → 0.45.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,73 +1,39 @@
1
1
  import { scaleTime } from 'd3-scale';
2
+ import { boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildTicks, majorFormatFor, } from './tickLadder.js';
3
+ // Grain selection lives in `tickLadder.ts` (the full hour1…year ladder plus
4
+ // the boundary-row helpers); re-exported here so existing imports keep working.
5
+ export { coarsenCalendar } from './tickLadder.js';
2
6
  /**
3
- * The local-time bucket key for `t` at grain `g` — two instants in the same
4
- * week / month / quarter / year share a key. Local time (not UTC) so it agrees
5
- * with the local `scaleTime` label formatter; the exchange's own time zone is
6
- * unknown to the scale (the deferred refinement), and a session open sits well
7
- * inside its local day, so runtime-local grouping matches the exchange day in
8
- * every ordinary case.
7
+ * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
8
+ * time, and every local midnight is a "session open". Backing a plain
9
+ * continuous time axis with `scaleTradingTime(identityProvider())` runs it
10
+ * through the same logical tick ladder as a trading-calendar axis calendar
11
+ * days are the day anchors, so a year of data ticks on month starts and an
12
+ * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
13
+ * default.
9
14
  */
10
- function bucketKey(t, g) {
11
- if (g === 'session')
12
- return t; // every open its own bucket
13
- const d = new Date(t);
14
- switch (g) {
15
- case 'week': {
16
- const dow = (d.getDay() + 6) % 7; // 0 = Monday
17
- // Local midnight of this week's Monday (Date normalizes a negative date).
18
- return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
19
- }
20
- case 'month':
21
- return d.getFullYear() * 12 + d.getMonth();
22
- case 'quarter':
23
- return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
24
- case 'year':
25
- return d.getFullYear();
26
- }
27
- }
28
- /** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
29
- function firstOfEachBucket(opens, g) {
30
- const out = [];
31
- let prev;
32
- for (const t of opens) {
33
- const k = bucketKey(t, g);
34
- if (k !== prev) {
35
- out.push(t);
36
- prev = k;
37
- }
38
- }
39
- return out;
40
- }
41
- const COARSENING_LADDER = [
42
- 'week',
43
- 'month',
44
- 'quarter',
45
- 'year',
46
- ];
47
- /**
48
- * Thin an ascending run of **session opens** down to about `count` axis ticks by
49
- * **calendar grain** — the trading-terminal habit of labelling week / month /
50
- * year starts rather than an arbitrary every-nth session. Picks the finest grain
51
- * on the ladder (session → week → month → quarter → year) that yields at most
52
- * `count` buckets and returns the first open in each; beyond yearly it decimates
53
- * every-nth so the axis never crowds. Exported so the container can draw session
54
- * dividers at the same instants the axis labels.
55
- */
56
- export function coarsenCalendar(opens, count) {
57
- if (opens.length <= count)
58
- return { ticks: [...opens], granularity: 'session' };
59
- for (const g of COARSENING_LADDER) {
60
- const ticks = firstOfEachBucket(opens, g);
61
- if (ticks.length <= count)
62
- return { ticks, granularity: g };
63
- }
64
- // Coarser than yearly isn't a calendar grain — decimate the year starts.
65
- const yearly = firstOfEachBucket(opens, 'year');
66
- const step = Math.ceil(yearly.length / count);
67
- return {
68
- ticks: yearly.filter((_, i) => i % step === 0),
69
- granularity: 'year',
15
+ export function identityProvider() {
16
+ const self = {
17
+ clampUp: (t) => t,
18
+ clampDown: (t) => t,
19
+ distance: (from, to) => to - from,
20
+ offset: (v, amount) => v + amount,
21
+ copy: () => self,
22
+ boundaries: (from, to) => {
23
+ 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);
32
+ }
33
+ return out;
34
+ },
70
35
  };
36
+ return self;
71
37
  }
72
38
  /**
73
39
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
@@ -97,17 +63,35 @@ export function scaleTradingTime(provider) {
97
63
  const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
98
64
  return [domain[0], ...bounds];
99
65
  };
66
+ /** Whether the provider has calendar structure to ladder on. Without a
67
+ * `boundaries` method there are no anchors — the even-spacing fallback. */
68
+ const hasCalendar = () => provider.boundaries !== undefined;
69
+ /** The ladder result for this domain at `count` — the single source `ticks`,
70
+ * `tickFormat`, and `tickBoundaries` all derive from, so the three agree.
71
+ * Memoized on `(domain, count)`: the three callers (plus gridlines /
72
+ * dividers) hit the same resolution per render, and on a wide continuous
73
+ * domain re-walking every day-open is the expensive part. */
74
+ let laddered = null;
75
+ const resolved = (count) => {
76
+ const key = `${domain[0]}:${domain[1]}:${count}`;
77
+ if (laddered?.key !== key) {
78
+ laddered = {
79
+ key,
80
+ value: buildTicks(provider, sessionOpens(), domain[1], count),
81
+ };
82
+ }
83
+ return laddered.value;
84
+ };
100
85
  scale.ticks = (count = 10) => {
101
86
  const live = totalLive();
102
87
  if (live <= 0 || count < 1)
103
88
  return [domain[0]];
104
- const opens = sessionOpens();
105
- // Calendar-aware: label the session opens (each a new day), thinned to
106
- // week / month / year starts by grain rather than an arbitrary every-nth
107
- // session — the trading-terminal look.
108
- if (opens.length > 1)
109
- return coarsenCalendar(opens, count).ticks;
110
- // No boundaries (a single session / no calendar): fall back to interior
89
+ // Calendar-aware: walk the logical ladder over the session-open anchors
90
+ // (hour steps inside sessions, then day / week / month / quarter / year
91
+ // starts) the trading-terminal look, never an arbitrary every-nth.
92
+ if (hasCalendar())
93
+ return resolved(count).ticks;
94
+ // No boundaries (no calendar structure at all): fall back to interior
111
95
  // even-spaced ticks — endpoints excluded so none sits on the plot edge.
112
96
  const out = [];
113
97
  for (let i = 1; i < count; i++) {
@@ -118,20 +102,34 @@ export function scaleTradingTime(provider) {
118
102
  scale.tickFormat = (count = 10, specifier) => {
119
103
  if (specifier !== undefined)
120
104
  return base.tickFormat(count, specifier);
121
- const opens = sessionOpens();
122
105
  const defFmt = base.tickFormat(count);
123
- if (opens.length <= 1)
106
+ if (!hasCalendar())
124
107
  return defFmt; // no calendar → d3 multi-scale default
125
- // Anchor label at each coarsened session-open tick: a **date** (`%b %d`), or
126
- // the **year** when ticks are a year apart (a plain date would drop the year
127
- // the reader needs). Any other instant a cursor readout uses the d3
128
- // multi-scale default. Same grain as {@link ticks}, so labels and the
129
- // dividers drawn at these instants agree.
130
- const { ticks, granularity } = coarsenCalendar(opens, count);
108
+ // Anchor labels at the grain {@link ticks} chose one uniform format per
109
+ // grain (hours as `%H:%M`, days/weeks as `%b %d`, months/quarters as `%b`,
110
+ // years as `%Y`); the coarser context the label omits is the second row
111
+ // ({@link tickBoundaries}). Any other instant a cursor readout uses
112
+ // the d3 multi-scale default. Same grain as {@link ticks}, so labels and
113
+ // the dividers drawn at these instants agree.
114
+ const { ticks, granularity } = resolved(count);
131
115
  const anchors = new Set(ticks);
132
- const anchorFmt = base.tickFormat(count, granularity === 'year' ? '%Y' : '%b %d');
116
+ const anchorFmt = base.tickFormat(count, majorFormatFor(granularity));
133
117
  return (d) => (anchors.has(+d) ? anchorFmt(d) : defFmt(d));
134
118
  };
119
+ scale.tickBoundaries = (count = 10) => {
120
+ if (!hasCalendar())
121
+ return () => undefined;
122
+ const { ticks, granularity } = resolved(count);
123
+ const bg = boundaryGrainFor(granularity);
124
+ if (bg === undefined)
125
+ return () => undefined;
126
+ const fmt = base.tickFormat(count, boundaryFormatFor(bg));
127
+ const labelled = new Map();
128
+ for (const t of boundaryTicks(ticks, granularity)) {
129
+ labelled.set(t, fmt(new Date(t)));
130
+ }
131
+ return (value) => labelled.get(value);
132
+ };
135
133
  function domainFn(next) {
136
134
  if (next === undefined)
137
135
  return [domain[0], domain[1]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -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.44.0",
42
- "pond-ts": "^0.44.0",
41
+ "@pond-ts/react": "^0.45.0",
42
+ "pond-ts": "^0.45.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {