@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.
package/dist/line.js CHANGED
@@ -53,20 +53,39 @@ export function yExtent(cs) {
53
53
  * The generator writes path ops to `ctx`; we bracket with `beginPath`/`stroke`.
54
54
  * `cs.y` (a `Float64Array`) is the datum iterable — `y` reads the value, `x`
55
55
  * reads `cs.x[i]` by index, so there's no per-point object allocation.
56
+ *
57
+ * **`boundaries`** (default none) are discontinuity instants — a trading-axis
58
+ * session/day/lunch close→open where the line should *break* even though a data
59
+ * point sits on each side (see {@link sessionRuns}). Each run between boundaries
60
+ * draws as its own subpath, so the line ends at the last pre-boundary point and
61
+ * re-starts at the first post-boundary one — a **scale** break, orthogonal to
62
+ * the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
63
+ * output is identical to a single-pass draw.
56
64
  */
57
- export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
58
- // `none` interpolates interior gaps so the line bridges them; every other mode
59
- // keeps NaN so d3 breaks the solid path (the inferred bridge, if any, is a
60
- // separate overlay pass below).
61
- const ys = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
62
- const gen = d3line()
63
- .defined((v) => Number.isFinite(v))
64
- .x((_, i) => xScale(cs.x[i]))
65
- .y((v) => yScale(v))
66
- .curve(curve)
67
- .context(ctx);
65
+ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = []) {
66
+ // Split into independent index runs at each boundary; no boundary inside the
67
+ // data one run over the whole series (the hot path no slicing, so the draw
68
+ // is byte-identical to the pre-boundary single pass).
69
+ const runs = sessionRuns(cs.x, cs.length, boundaries);
70
+ const singleRun = runs.length === 1;
71
+ // Solid pass: one path across every run. Each run's generator opens with its
72
+ // own moveTo, so a run boundary is a clean pen-up — the session break.
68
73
  ctx.beginPath();
69
- gen(ys);
74
+ for (const [s, e] of runs) {
75
+ // `none` interpolates interior gaps so the line bridges them — but only
76
+ // *within* a run (a session break is not a dropout to interpolate over);
77
+ // every other mode keeps NaN so d3 breaks the solid path (the inferred
78
+ // bridge, if any, is a separate overlay pass below).
79
+ const seg = singleRun ? cs.y : cs.y.subarray(s, e);
80
+ const ys = gaps === 'none' ? bridgeGaps(seg, e - s) : seg;
81
+ const gen = d3line()
82
+ .defined((v) => Number.isFinite(v))
83
+ .x((_, j) => xScale(cs.x[s + j]))
84
+ .y((v) => yScale(v))
85
+ .curve(curve)
86
+ .context(ctx);
87
+ gen(ys);
88
+ }
70
89
  ctx.strokeStyle = style.color;
71
90
  ctx.lineWidth = style.width;
72
91
  // Per-series dash (a modeled/forecast line reads dashed). Applied only when
@@ -84,8 +103,15 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
84
103
  }
85
104
  // Overlay bridges for the inferred-gap modes. `dashed` / `step` are faint
86
105
  // dashed connectors (gapConnectorOpacity); only `fade` drops to the axis floor.
106
+ // Collect edges **per run** so an inferred bridge never spans a session break
107
+ // (the break wins — no dashed/step/fade connector across a collapsed gap).
87
108
  if (gaps === 'dashed' || gaps === 'step' || gaps === 'fade') {
88
- const edges = collectGapEdges(cs.length, cs.x, (i) => cs.y[i], xScale, (i) => yScale(cs.y[i]));
109
+ const edges = [];
110
+ for (const [s, e] of runs) {
111
+ const runEdges = collectGapEdges(e - s, singleRun ? cs.x : cs.x.subarray(s, e), (i) => cs.y[s + i], xScale, (i) => yScale(cs.y[s + i]));
112
+ for (const ed of runEdges)
113
+ edges.push(ed);
114
+ }
89
115
  if (gaps === 'dashed') {
90
116
  drawGapBridges(ctx, edges, style.color, style.width, gapConnectorOpacity);
91
117
  }
@@ -97,4 +123,43 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
97
123
  }
98
124
  }
99
125
  }
126
+ /**
127
+ * Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
128
+ * cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
129
+ * discontinuity (a trading session / day / lunch close→open) sits between two
130
+ * consecutive points. A point that lands exactly on a boundary starts the new
131
+ * run (the open). No boundary inside the data (or an empty list) ⇒ a single run
132
+ * over the whole series. This is what turns `<LineChart sessionBreaks>` into a
133
+ * per-session polyline. Pure + O(N).
134
+ *
135
+ * The sweep relies on **ascending** boundaries; the `DiscontinuityProvider`
136
+ * contract doesn't guarantee order, so an unsorted list is sorted defensively
137
+ * (a copy, so the caller's array isn't mutated) rather than silently dropping a
138
+ * break. The list is tiny — one entry per session boundary — so the sort is
139
+ * negligible next to the row sweep.
140
+ */
141
+ export function sessionRuns(x, length, boundaries) {
142
+ if (boundaries.length === 0 || length === 0)
143
+ return [[0, length]];
144
+ const bounds = boundaries.length > 1 ? [...boundaries].sort((a, b) => a - b) : boundaries;
145
+ const runs = [];
146
+ let start = 0;
147
+ let bi = 0;
148
+ for (let i = 1; i < length; i += 1) {
149
+ const prev = x[i - 1];
150
+ const cur = x[i];
151
+ // Skip boundaries at or before the previous point (already behind the pen).
152
+ while (bi < bounds.length && bounds[bi] <= prev)
153
+ bi += 1;
154
+ if (bi < bounds.length && bounds[bi] <= cur) {
155
+ // A boundary sits in (prev, cur] → break the run before point i.
156
+ runs.push([start, i]);
157
+ start = i;
158
+ while (bi < bounds.length && bounds[bi] <= cur)
159
+ bi += 1;
160
+ }
161
+ }
162
+ runs.push([start, length]);
163
+ return runs;
164
+ }
100
165
  //# sourceMappingURL=line.js.map
@@ -0,0 +1,89 @@
1
+ import type { DiscontinuityProvider } from './tradingTimeScale.js';
2
+ /**
3
+ * The logical tick ladder — grain selection for a time axis. Ticks sit on real
4
+ * calendar/clock units (1s…30s, 1m…30m, 1H…12H, day / week / month / quarter /
5
+ * year — the trading-terminal convention), never on even pixel spacing: the
6
+ * axis walks the ladder finest→coarsest and picks the first grain whose anchor
7
+ * count fits the width-derived cap. The same ladder serves a **disjoint
8
+ * trading-calendar** axis (session opens are the day anchors, hour anchors are
9
+ * generated in live time so they never land in a collapsed gap) and a **plain
10
+ * continuous** axis (an identity provider whose "sessions" are calendar days).
11
+ *
12
+ * Each grain also knows its **boundary grain** — the next-coarser unit its own
13
+ * label doesn't carry (hours → the date, days/weeks → the month, months →
14
+ * the year). The axis renders that as a second label row, once per boundary
15
+ * crossing, so a month row reads `Dec Jan Feb …` with `2026` appearing exactly
16
+ * where the year turns.
17
+ */
18
+ /** The calendar grain a run of tick anchors is bucketed to. */
19
+ export type TickGranularity = 'second1' | 'second5' | 'second15' | 'second30' | 'minute1' | 'minute5' | 'minute15' | 'minute30' | 'hour1' | 'hour3' | 'hour6' | 'hour12' | 'day' | 'week' | 'month' | 'quarter' | 'year';
20
+ /**
21
+ * The local-time bucket key for `t` at grain `g` — two instants in the same
22
+ * day / week / month / quarter / year share a key. Local time (not UTC) so it
23
+ * agrees with the local `scaleTime` label formatter; the exchange's own time
24
+ * zone is unknown to the scale (the deferred refinement), and a session open
25
+ * sits well inside its local day, so runtime-local grouping matches the
26
+ * exchange day in every ordinary case. Hour grains are never bucketed (each
27
+ * anchor is its own tick), so they key by identity.
28
+ */
29
+ export declare function bucketKey(t: number, g: TickGranularity): number;
30
+ /**
31
+ * Thin an ascending run of **session opens** down to about `count` axis ticks by
32
+ * **calendar grain** — the trading-terminal habit of labelling week / month /
33
+ * year starts rather than an arbitrary every-nth session. Picks the finest grain
34
+ * on the ladder (day → week → month → quarter → year) that yields at most
35
+ * `count` buckets and returns the first open in each; beyond yearly it decimates
36
+ * every-nth so the axis never crowds. Exported so the container can draw session
37
+ * dividers at the same instants the axis labels.
38
+ *
39
+ * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
40
+ * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
41
+ * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
42
+ * ticks). Callers size the cap to the room the labels have — the container
43
+ * derives it from plot width — rather than passing a small constant.
44
+ *
45
+ * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
46
+ * sub-day rungs.
47
+ */
48
+ export declare function coarsenCalendar(opens: readonly number[], count: number): {
49
+ ticks: number[];
50
+ granularity: TickGranularity;
51
+ };
52
+ /**
53
+ * The full-ladder grain selection: given the provider, the domain, and the
54
+ * width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
55
+ * then day → week → month → quarter → year (then decimate) and return the
56
+ * first rung that fits.
57
+ * `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
58
+ * caller already has. Sub-day rungs are only reachable when the opens
59
+ * themselves fit — a year of daily sessions never wastes time generating hour
60
+ * anchors.
61
+ */
62
+ export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): {
63
+ ticks: number[];
64
+ granularity: TickGranularity;
65
+ };
66
+ /**
67
+ * The **boundary grain** for ticks at grain `g` — the next-coarser unit a
68
+ * tick's own label doesn't already carry, rendered as the axis's second label
69
+ * row. Clock labels (`14:00`) need the date; day/week labels (`Feb 02`)
70
+ * already carry the month, so they need only the year — as do month/quarter
71
+ * labels (`Feb`); a year label already says everything.
72
+ */
73
+ export declare function boundaryGrainFor(g: TickGranularity): TickGranularity | undefined;
74
+ /** d3 time-format specifier for the **major** (first-row) label at grain `g`. */
75
+ export declare function majorFormatFor(g: TickGranularity): string;
76
+ /** d3 time-format specifier for the **boundary** (second-row) label at the
77
+ * boundary grain `g` — a date under clock ticks, the bare year under
78
+ * everything else. Never repeat a unit the first row already shows
79
+ * (`Jan 2026` under a `Jan 05` tick reads as noise). */
80
+ export declare function boundaryFormatFor(g: TickGranularity): string;
81
+ /**
82
+ * Which of `ticks` (at grain `granularity`) carry a boundary label: the first
83
+ * tick always (the reader needs context immediately), then every tick whose
84
+ * boundary-grain bucket differs from the previous tick's — i.e. the first tick
85
+ * of each new day / year. Returns the boundary-flagged tick values;
86
+ * empty when the grain has no boundary row (year grain).
87
+ */
88
+ export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity): number[];
89
+ //# sourceMappingURL=tickLadder.d.ts.map
@@ -0,0 +1,265 @@
1
+ const SEC_MS = 1_000;
2
+ const MIN_MS = 60_000;
3
+ const HOUR_MS = 3_600_000;
4
+ /** The sub-day rungs, finest first, with their clock step — the 1/5/15/30
5
+ * second and minute steps terminals use, then the hour steps. */
6
+ const SUB_DAY_GRAINS = [
7
+ { g: 'second1', step: 1 * SEC_MS },
8
+ { g: 'second5', step: 5 * SEC_MS },
9
+ { g: 'second15', step: 15 * SEC_MS },
10
+ { g: 'second30', step: 30 * SEC_MS },
11
+ { g: 'minute1', step: 1 * MIN_MS },
12
+ { g: 'minute5', step: 5 * MIN_MS },
13
+ { g: 'minute15', step: 15 * MIN_MS },
14
+ { g: 'minute30', step: 30 * MIN_MS },
15
+ { g: 'hour1', step: 1 * HOUR_MS },
16
+ { g: 'hour3', step: 3 * HOUR_MS },
17
+ { g: 'hour6', step: 6 * HOUR_MS },
18
+ { g: 'hour12', step: 12 * HOUR_MS },
19
+ ];
20
+ /** Whether `g` is one of the sub-day (clock-step) rungs. */
21
+ function isSubDay(g) {
22
+ return g !== 'day' && SUB_DAY_GRAINS.some((r) => r.g === g);
23
+ }
24
+ /**
25
+ * The local-time bucket key for `t` at grain `g` — two instants in the same
26
+ * day / week / month / quarter / year share a key. Local time (not UTC) so it
27
+ * agrees with the local `scaleTime` label formatter; the exchange's own time
28
+ * zone is unknown to the scale (the deferred refinement), and a session open
29
+ * sits well inside its local day, so runtime-local grouping matches the
30
+ * exchange day in every ordinary case. Hour grains are never bucketed (each
31
+ * anchor is its own tick), so they key by identity.
32
+ */
33
+ export function bucketKey(t, g) {
34
+ if (isSubDay(g))
35
+ return t;
36
+ const d = new Date(t);
37
+ switch (g) {
38
+ case 'day':
39
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
40
+ case 'week': {
41
+ const dow = (d.getDay() + 6) % 7; // 0 = Monday
42
+ // Local midnight of this week's Monday (Date normalizes a negative date).
43
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
44
+ }
45
+ case 'month':
46
+ return d.getFullYear() * 12 + d.getMonth();
47
+ case 'quarter':
48
+ return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
49
+ case 'year':
50
+ return d.getFullYear();
51
+ default:
52
+ return t;
53
+ }
54
+ }
55
+ /** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
56
+ function firstOfEachBucket(opens, g) {
57
+ const out = [];
58
+ let prev;
59
+ for (const t of opens) {
60
+ const k = bucketKey(t, g);
61
+ if (k !== prev) {
62
+ out.push(t);
63
+ prev = k;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ const COARSENING_LADDER = [
69
+ 'week',
70
+ 'month',
71
+ 'quarter',
72
+ 'year',
73
+ ];
74
+ /**
75
+ * Thin an ascending run of **session opens** down to about `count` axis ticks by
76
+ * **calendar grain** — the trading-terminal habit of labelling week / month /
77
+ * year starts rather than an arbitrary every-nth session. Picks the finest grain
78
+ * on the ladder (day → week → month → quarter → year) that yields at most
79
+ * `count` buckets and returns the first open in each; beyond yearly it decimates
80
+ * every-nth so the axis never crowds. Exported so the container can draw session
81
+ * dividers at the same instants the axis labels.
82
+ *
83
+ * `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
84
+ * small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
85
+ * run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
86
+ * ticks). Callers size the cap to the room the labels have — the container
87
+ * derives it from plot width — rather than passing a small constant.
88
+ *
89
+ * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
90
+ * sub-day rungs.
91
+ */
92
+ export function coarsenCalendar(opens, count) {
93
+ if (opens.length <= count)
94
+ return { ticks: [...opens], granularity: 'day' };
95
+ for (const g of COARSENING_LADDER) {
96
+ const ticks = firstOfEachBucket(opens, g);
97
+ if (ticks.length <= count)
98
+ return { ticks, granularity: g };
99
+ }
100
+ // Coarser than yearly isn't a calendar grain — decimate the year starts.
101
+ const yearly = firstOfEachBucket(opens, 'year');
102
+ const step = Math.ceil(yearly.length / count);
103
+ return {
104
+ ticks: yearly.filter((_, i) => i % step === 0),
105
+ granularity: 'year',
106
+ };
107
+ }
108
+ /** The first clock-aligned `stepMs` multiple at or after `t`, relative to `t`'s
109
+ * own local midnight — so a 3-hour step lands on 00:00 / 03:00 / 06:00 local,
110
+ * whatever the session open was. Fixed-ms stepping from midnight, so on a DST
111
+ * transition day the later anchors drift off the wall-clock grid by the shift
112
+ * (labels stay truthful — they format the real instant); exchange-tz grain is
113
+ * the already-deferred refinement. */
114
+ function nextAligned(t, stepMs) {
115
+ const d = new Date(t);
116
+ const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
117
+ return midnight + Math.ceil((t - midnight) / stepMs) * stepMs;
118
+ }
119
+ /**
120
+ * The sub-day anchors at `stepMs`: each session open, plus each clock-aligned
121
+ * step instant strictly inside that session's **live** span. (The caller may
122
+ * still drop the very first anchor as a cramped lead — see {@link buildTicks}.) An instant is
123
+ * in-session iff live distance-then-offset round-trips it — so a lunch-break
124
+ * gap, an early close, or a collapsed overnight never gets an anchor, and no
125
+ * new provider surface is needed. Bails once `cap` is exceeded (the caller
126
+ * only needs to know the grain doesn't fit).
127
+ */
128
+ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
129
+ const out = [];
130
+ for (let i = 0; i < opens.length; i++) {
131
+ const open = opens[i];
132
+ const end = i + 1 < opens.length ? opens[i + 1] : domainEnd;
133
+ out.push(open);
134
+ for (let t = nextAligned(open + 1, stepMs); t < end; t += stepMs) {
135
+ if (provider.offset(open, provider.distance(open, t)) === t) {
136
+ out.push(t);
137
+ if (out.length > cap)
138
+ return out;
139
+ }
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * The full-ladder grain selection: given the provider, the domain, and the
146
+ * width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
147
+ * then day → week → month → quarter → year (then decimate) and return the
148
+ * first rung that fits.
149
+ * `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
150
+ * caller already has. Sub-day rungs are only reachable when the opens
151
+ * themselves fit — a year of daily sessions never wastes time generating hour
152
+ * anchors.
153
+ */
154
+ export function buildTicks(provider, opens, domainEnd, cap) {
155
+ const result = (() => {
156
+ if (opens.length <= cap) {
157
+ for (const { g, step } of SUB_DAY_GRAINS) {
158
+ const ticks = stepAnchors(provider, opens, domainEnd, step, cap);
159
+ // A clock rung must earn its labels: if it adds no intraday anchor
160
+ // beyond the opens themselves, it's really day grain (a row of
161
+ // "09:30"s under every session is a worse day axis, not a clock axis).
162
+ if (ticks.length <= cap && ticks.length > opens.length)
163
+ return { ticks, granularity: g };
164
+ }
165
+ return { ticks: [...opens], granularity: 'day' };
166
+ }
167
+ return coarsenCalendar(opens, cap);
168
+ })();
169
+ // Round anchors to integer milliseconds: a pan/zoom domain comes from
170
+ // `scale.invert(pixel)` and is fractional, and a fractional anchor breaks
171
+ // the label pipeline — formatters pass through `new Date(ms)`, which
172
+ // truncates, so the instant no longer matches its own anchor set and the
173
+ // label falls through to the d3 multi-scale default (a bare `.259`
174
+ // millisecond tick). Sub-ms precision is invisible at any ladder grain.
175
+ result.ticks = result.ticks.map((t) => Math.round(t));
176
+ // Drop a cramped **leading partial-period** anchor: the first tick is the
177
+ // domain start, which usually sits mid-period (a "1Y back from today" view
178
+ // starts mid-month), so it can land arbitrarily close to the first full
179
+ // period start and the two labels collide (the classic "Jun 23Jul 07"
180
+ // pile-up). When the lead gap is under half a typical period (in **live**
181
+ // time, so a collapsed weekend doesn't fake a gap), the partial anchor
182
+ // isn't earning its label — the boundary row moves to the next tick.
183
+ const t = result.ticks;
184
+ if (t.length >= 3 &&
185
+ provider.distance(t[0], t[1]) < 0.5 * provider.distance(t[1], t[2])) {
186
+ t.shift();
187
+ }
188
+ return result;
189
+ }
190
+ /**
191
+ * The **boundary grain** for ticks at grain `g` — the next-coarser unit a
192
+ * tick's own label doesn't already carry, rendered as the axis's second label
193
+ * row. Clock labels (`14:00`) need the date; day/week labels (`Feb 02`)
194
+ * already carry the month, so they need only the year — as do month/quarter
195
+ * labels (`Feb`); a year label already says everything.
196
+ */
197
+ export function boundaryGrainFor(g) {
198
+ if (isSubDay(g))
199
+ return 'day';
200
+ switch (g) {
201
+ case 'day':
202
+ case 'week':
203
+ case 'month':
204
+ case 'quarter':
205
+ return 'year';
206
+ default:
207
+ return undefined;
208
+ }
209
+ }
210
+ /** d3 time-format specifier for the **major** (first-row) label at grain `g`. */
211
+ export function majorFormatFor(g) {
212
+ switch (g) {
213
+ case 'second1':
214
+ case 'second5':
215
+ case 'second15':
216
+ case 'second30':
217
+ return '%H:%M:%S';
218
+ case 'minute1':
219
+ case 'minute5':
220
+ case 'minute15':
221
+ case 'minute30':
222
+ case 'hour1':
223
+ case 'hour3':
224
+ case 'hour6':
225
+ case 'hour12':
226
+ return '%H:%M';
227
+ case 'day':
228
+ case 'week':
229
+ return '%b %d';
230
+ case 'month':
231
+ case 'quarter':
232
+ return '%b';
233
+ case 'year':
234
+ return '%Y';
235
+ }
236
+ }
237
+ /** d3 time-format specifier for the **boundary** (second-row) label at the
238
+ * boundary grain `g` — a date under clock ticks, the bare year under
239
+ * everything else. Never repeat a unit the first row already shows
240
+ * (`Jan 2026` under a `Jan 05` tick reads as noise). */
241
+ export function boundaryFormatFor(g) {
242
+ return g === 'day' ? '%b %d' : '%Y';
243
+ }
244
+ /**
245
+ * Which of `ticks` (at grain `granularity`) carry a boundary label: the first
246
+ * tick always (the reader needs context immediately), then every tick whose
247
+ * boundary-grain bucket differs from the previous tick's — i.e. the first tick
248
+ * of each new day / year. Returns the boundary-flagged tick values;
249
+ * empty when the grain has no boundary row (year grain).
250
+ */
251
+ export function boundaryTicks(ticks, granularity) {
252
+ const bg = boundaryGrainFor(granularity);
253
+ if (bg === undefined)
254
+ return [];
255
+ const out = [];
256
+ let prev;
257
+ for (const t of ticks) {
258
+ const k = bucketKey(t, bg);
259
+ if (prev === undefined || k !== prev)
260
+ out.push(t);
261
+ prev = k;
262
+ }
263
+ return out;
264
+ }
265
+ //# sourceMappingURL=tickLadder.js.map
@@ -47,12 +47,16 @@ export interface TradingCalendarLike {
47
47
  * wherever the container's `xScale` goes.
48
48
  *
49
49
  * Ticks are **calendar-aware** when the provider enumerates its gaps: `.ticks`
50
- * returns the session opens thinned to a calendar grain (week / month / year
51
- * starts, whichever fits ~`count`), and `.tickFormat` labels each anchor with a
52
- * **date** (`%b %d`) or the **year** (`%Y`) when ticks are a year apart —
53
- * while formatting any other instant (a mid-session tick, the cursor readout)
54
- * with the d3 multi-scale default. Without a provider `boundaries` method it
55
- * falls back to interior even-spaced time 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
52
+ * `.tickFormat` labels each anchor at that grain (`%H:%M` for hours, `%b %d`
53
+ * 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
55
+ * 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.
58
+ * Without a provider `boundaries` method it falls back to interior even-spaced
59
+ * time ticks.
56
60
  *
57
61
  * **Out-of-domain behavior.** Within the calendar the scale extrapolates like a
58
62
  * normal scale — a live instant *before* the domain start maps to a negative
@@ -67,31 +71,36 @@ export interface TradingTimeScale {
67
71
  invert(pixel: number): number;
68
72
  ticks(count?: number): number[];
69
73
  tickFormat(count?: number, specifier?: string): (date: Date) => string;
74
+ /**
75
+ * The **second-row** (boundary) label for a tick value, or `undefined` for
76
+ * ticks that don't open a new boundary period. Same grain selection as
77
+ * {@link ticks} at the same `count`, so the rows agree: the first tick and
78
+ * each tick starting a new day / year (whichever is the next-coarser
79
+ * unit the first-row label omits) carry the label; year-grain ticks have no
80
+ * second row.
81
+ */
82
+ tickBoundaries(count?: number): (value: number) => string | undefined;
70
83
  domain(): [number, number];
71
84
  domain(next: readonly [number, number]): TradingTimeScale;
72
85
  range(): [number, number];
73
86
  range(next: readonly [number, number]): TradingTimeScale;
74
87
  copy(): TradingTimeScale;
75
88
  }
76
- /** The calendar grain a run of session opens is bucketed to for axis ticks. */
77
- type TickGranularity = 'session' | 'week' | 'month' | 'quarter' | 'year';
89
+ export { coarsenCalendar } from './tickLadder.js';
90
+ export type { TickGranularity } from './tickLadder.js';
78
91
  /**
79
- * Thin an ascending run of **session opens** down to about `count` axis ticks by
80
- * **calendar grain** the trading-terminal habit of labelling week / month /
81
- * year starts rather than an arbitrary every-nth session. Picks the finest grain
82
- * on the ladder (session week month quarter year) that yields at most
83
- * `count` buckets and returns the first open in each; beyond yearly it decimates
84
- * every-nth so the axis never crowds. Exported so the container can draw session
85
- * dividers at the same instants the axis labels.
92
+ * The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
93
+ * time, and every local midnight is a "session open". Backing a plain
94
+ * continuous time axis with `scaleTradingTime(identityProvider())` runs it
95
+ * through the same logical tick ladder as a trading-calendar axis calendar
96
+ * days are the day anchors, so a year of data ticks on month starts and an
97
+ * afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
98
+ * default.
86
99
  */
87
- export declare function coarsenCalendar(opens: readonly number[], count: number): {
88
- ticks: number[];
89
- granularity: TickGranularity;
90
- };
100
+ export declare function identityProvider(): DiscontinuityProvider;
91
101
  /**
92
102
  * Build a {@link TradingTimeScale} over the given discontinuity `provider`.
93
103
  * Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
94
104
  */
95
105
  export declare function scaleTradingTime(provider: DiscontinuityProvider): TradingTimeScale;
96
- export {};
97
106
  //# sourceMappingURL=tradingTimeScale.d.ts.map