@pond-ts/charts 0.52.0 → 0.53.1

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/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.52.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.53.1...HEAD
12
+ [0.53.1]: https://github.com/pond-ts/pond/compare/v0.53.0...v0.53.1
13
+ [0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
12
14
  [0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
13
15
  [0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
14
16
  [0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
@@ -52,6 +54,126 @@ and type-level changes; patch bumps are strictly additive.
52
54
 
53
55
  ## [Unreleased]
54
56
 
57
+ ## [0.53.1] — 2026-07-25
58
+
59
+ ### Fixed
60
+
61
+ - **charts:** **An `<XAxis format>` again owns its own cursor / marker pills**
62
+ under a container with `origin` set. The elapsed axis supplies a _default_
63
+ finer readout (`00:05:12` under `00:05` ticks), but it was being delivered
64
+ through the same frame field as an explicit `cursorFormat` — so it outranked
65
+ an axis-level `format`, inverting the documented pill precedence
66
+ (`cursorFormat → axis format → container`). The visible symptom was the
67
+ two-strip pattern the docs recommend: a wall-clock strip declared as
68
+ `<XAxis format="%H:%M">` labelled its ticks `11:33` and pilled them
69
+ `00:05:12`. A real `cursorFormat` still outranks an axis `format`, unchanged.
70
+ A container **`timeFormat`** was inverted the same way one rung down — its
71
+ documented back-compat is to shape the readout when no `cursorFormat` is set,
72
+ and the elapsed default was overruling it. Fixed with the same precedence.
73
+
74
+ - **charts:** **A duration axis no longer stacks ticks on one pixel across a
75
+ collapsed session.** The duration ladder strides in wall-clock time, so on a
76
+ trading axis several ticks could land inside closed time — where the scale
77
+ maps all of them to the same seam pixel, stroking labels over labels and
78
+ gridlines over gridlines. Coinciding ticks are now dropped, so a seam shows
79
+ one label rather than four — and the one kept is the **last** of the group,
80
+ the session open that genuinely sits on that pixel (`1d 00:00`), rather than
81
+ the first, which falls inside the collapsed night (`12:00` = 21:30, market
82
+ shut). Continuous axes are unaffected (their ticks are tens of pixels apart
83
+ by construction).
84
+
85
+ Both found by an adversarial review of the v0.53.0 duration axis
86
+ ([#540](https://github.com/pond-ts/pond/issues/540)), which also corrected the
87
+ duration-axis docs: the trading-calendar caveat described uneven spacing where
88
+ the real behaviour is thinning around seams, a far-off `origin` (`origin={0}`
89
+ on a 2026 axis ⇒ `20468d 10:00` on every tick) was undocumented, and one row of
90
+ the label-shape table quoted a sample spacing where it meant a tick step.
91
+
92
+ ## [0.53.0] — 2026-07-25
93
+
94
+ ### Changed
95
+
96
+ - **fit (breaking):** **Power bins and zones now use pond's canonical bin
97
+ edges**, so they feed `@pond-ts/charts` with no mapping step:
98
+
99
+ ```tsx
100
+ <BarChart bins={power.distribution} column="seconds" />
101
+ <BarChart bins={power.zones} column="seconds" orientation="horizontal" ordinal />
102
+ ```
103
+
104
+ Each type previously spoke its own dialect for the same concept —
105
+ `PowerBin.wattsFrom` (with **no upper edge at all**), `ZoneTime.lo`/`hi`, and
106
+ `PowerZone.minWatts`/`maxWatts` — while core's `byColumn` and charts' `BinRecord`
107
+ both use `{ start, end, …aggregates }`. Every caller had to hand-map before
108
+ drawing, even though the internals already computed the canonical shape and
109
+ discarded it.
110
+
111
+ **Migration** (pre-1.0, so the old names are gone rather than deprecated):
112
+
113
+ | Was | Now |
114
+ | --------------------- | -------------------------------------- |
115
+ | `PowerBin.wattsFrom` | `PowerBin.start` (+ new `end`) |
116
+ | `ZoneTime.lo` / `.hi` | `ZoneTime.start` / `.end`, `openEnded` |
117
+ | `PowerZone.minWatts` | `PowerZone.start` |
118
+ | `PowerZone.maxWatts` | `PowerZone.end`, `openEnded` |
119
+
120
+ Only **`PowerZone.maxWatts`** — a zone's upper edge — is affected. The
121
+ identically-named `PowerSummary.maxWatts` and the per-lap / per-section peak
122
+ power are a different concept and are unchanged.
123
+
124
+ `end` is now **always finite and always `> start`** — the guarantee core
125
+ enforces (`byColumn` throws on a zero-width bin) and charts need (an infinite
126
+ edge blows up an axis domain). The open-ended top band, which previously
127
+ carried only `Infinity`, gets a **drawable stand-in** edge: wide enough to
128
+ cover the highest value observed, and at least as wide as the band below it.
129
+ Treat it as a drawing bound rather than data, and test for the band with the
130
+ new **`openEnded`** flag rather than comparing an edge against `Infinity`
131
+ (`openEnded` is now also strictly positional — only the final band can carry
132
+ it). Rounding zone edges to whole watts no longer collapses bands at very low
133
+ FTPs.
134
+
135
+ ### Added
136
+
137
+ - **charts:** **Duration (elapsed) x axis** — `<ChartContainer origin>` labels
138
+ the shared x axis as offsets from a zero point instead of absolute values, so
139
+ a workout / lab run / load test reads `00:00 00:05 00:10` rather than
140
+ `10:35 10:40 10:45`:
141
+
142
+ ```tsx
143
+ <ChartContainer width={620} origin="data">
144
+
145
+ <XAxis label="Elapsed" />
146
+ </ChartContainer>
147
+ ```
148
+
149
+ `'data'` zeroes at the start of the data (and stays there as you pan); a
150
+ **number** sets an explicit zero point — a gun, a trigger, a lap — with ticks
151
+ before it reading negative (`-00:05`). Ticks are placed at round durations
152
+ **measured from the origin** (a ride starting at 10:33:17 ticks 10:33:17,
153
+ 10:38:17, …), off a clock ladder (…15s, 30s, 1m, 2m, 5m, …, 12h, then whole
154
+ days) rather than the 1-2-5 ladder — the part a formatter alone can't do.
155
+ Labels pick their shape from the step and the axis's magnitude
156
+ (`00:00.500` · `00:15` · `01:01:30` · `1d 12:00` · `5d`), gridlines follow the
157
+ same ticks, and the cursor / marker pills read one grain finer (`00:05:12`).
158
+
159
+ It's a **labelling** mode, not a data transform: `range`, `<Marker at>`,
160
+ `onRegionSelect`, `trackerPosition` all stay in absolute axis units. The same
161
+ prop works on a **value** x axis (distance travelled, not distance recorded).
162
+ An explicit format still wins — on a time axis a d3 _time_ specifier can only
163
+ describe an instant, so it labels the wall clock, which is the lever for
164
+ stacking a wall-clock strip under a duration strip on one shared tick set; on
165
+ a value axis a number specifier formats the offset. Ignored on a category
166
+ axis; on a trading calendar the durations are wall-clock, so ticks spanning a
167
+ collapsed session gap sit unevenly.
168
+
169
+ - **fit:** `computePower` takes an options object — **`{ binWatts }`** sets the
170
+ width of the `distribution` buckets (default `1`, unchanged). 1 W bins draw as
171
+ hairlines, so pass the width you intend to render rather than re-bucketing the
172
+ output yourself. It throws `RangeError` on a non-positive or non-finite
173
+ `binWatts`. New exported type `ComputePowerOptions`, also accepted by the
174
+ activity façade: `Activity.power(ftp, options)` and
175
+ `ProfiledActivity.power(options)`.
176
+
55
177
  ## [0.52.0] — 2026-07-23
56
178
 
57
179
  ### Changed
@@ -377,6 +377,35 @@ export interface ChartContainerProps {
377
377
  * `cursorFormat`.)
378
378
  */
379
379
  cursorFormat?: CursorFormat;
380
+ /**
381
+ * Label the x axis as **offsets from a zero point** instead of absolute
382
+ * values — the *duration* (elapsed-time) axis. A time axis reads
383
+ * `00:00 00:05 00:10` where it read `10:35 10:40 10:45`; a value axis reads
384
+ * distance-from-the-origin (`0 500 1000`) where it read absolute distance.
385
+ *
386
+ * - **`'data'`** — the start of the data (the union of the layers' x extents),
387
+ * so the labels are "since the beginning of the series" and stay put as you
388
+ * pan.
389
+ * - **a number** — an explicit zero point in axis units: a race gun, a trigger
390
+ * instant, a lap marker. Ticks before it read negative (`-00:05` — the
391
+ * T-minus case).
392
+ *
393
+ * Ticks are placed at round durations **measured from the origin**, not at the
394
+ * wall-clock boundaries the calendar ladder would pick — that's the difference
395
+ * between `00:00 00:05 00:10` and `00:01:43 00:06:43`. Gridlines follow them,
396
+ * and so does the cursor pill (one grain finer, as ever: `00:05:12`).
397
+ *
398
+ * This is a **labelling** mode, not a data transform: `range`, an annotation's
399
+ * `at`, an `onRegionSelect` span, `trackerPosition` are all still absolute
400
+ * axis units. Ignored on a category axis. An explicit `timeFormat` /
401
+ * `<XAxis format>` still wins — on a time axis a d3 *time* specifier can only
402
+ * describe an instant, so it labels the underlying wall clock (the lever for
403
+ * stacking a wall-clock strip under a duration strip, on shared ticks); on a
404
+ * value axis a number specifier formats the offset. On a trading-calendar
405
+ * axis the durations are **wall-clock**, so ticks spanning a collapsed session
406
+ * gap sit unevenly — elapsed *trading* time is not implemented.
407
+ */
408
+ origin?: number | 'data';
380
409
  /** Visual theme for all rows; defaults to {@link defaultTheme}. */
381
410
  theme?: ChartTheme;
382
411
  children?: ReactNode;
@@ -390,5 +419,5 @@ export interface ChartContainerProps {
390
419
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
391
420
  * (`<YAxis>`).
392
421
  */
393
- export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
422
+ export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
394
423
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } f
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
+ import { scaleElapsed } from './elapsed.js';
6
7
  import { Sequence } from 'pond-ts';
7
8
  import { ContainerContext, CursorContext, } from './context.js';
8
9
  import { maxSlotWidths, sum } from './slots.js';
@@ -44,7 +45,7 @@ function normalizeRange(range) {
44
45
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
45
46
  * (`<YAxis>`).
46
47
  */
47
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
48
+ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
48
49
  // Normalize the `panZoom` mode (boolean shorthand or the three-way string)
49
50
  // into the two gesture flags the event surface reads. `true` ⇒ both; `'pan'`
50
51
  // ⇒ drag only; `false`/`'none'` ⇒ neither. Zoom implies pan (there is no
@@ -391,6 +392,17 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
391
392
  const xTickCount = resolvedKind === 'time'
392
393
  ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
393
394
  : TIME_TICK_COUNT;
395
+ // The elapsed-axis zero point (`origin`), resolved to a number: `'data'` is
396
+ // the start of the data, which before any layer registers falls back to the
397
+ // domain start (the same two-pass settle `resolvedKind` makes). A category
398
+ // axis has no numeric origin to offset from, and a non-finite one is ignored
399
+ // rather than poisoning every tick.
400
+ const elapsedOrigin = useMemo(() => {
401
+ if (origin === undefined || resolvedKind === 'category')
402
+ return undefined;
403
+ const at = origin === 'data' ? (autoExtent?.[0] ?? d0) : origin;
404
+ return Number.isFinite(at) ? at : undefined;
405
+ }, [origin, resolvedKind, autoExtent, d0]);
394
406
  const { xScale, formatTime, formatReadout } = useMemo(() => {
395
407
  if (resolvedKind === 'category') {
396
408
  // Ordinal column-domain axis: a band scale over the category slots. The
@@ -411,6 +423,24 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
411
423
  }
412
424
  if (resolvedKind === 'value') {
413
425
  const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
426
+ if (elapsedOrigin !== undefined) {
427
+ // Offset (elapsed) value axis: same pixels, ticks anchored at the
428
+ // origin, labels reading `v - origin`. A `timeFormat` / `cursorFormat`
429
+ // number specifier resolves through the *offset* domain (that's what
430
+ // the wrapper's `tickFormat` does), so a specifier describes the number
431
+ // actually on show.
432
+ const e = scaleElapsed(s, { origin: elapsedOrigin, kind: 'value' });
433
+ const labels = resolveAxisFormat(e, xTickCount, timeFormat);
434
+ return {
435
+ xScale: e,
436
+ formatTime: labels,
437
+ formatReadout: typeof cursorFormat === 'function'
438
+ ? (v) => cursorFormat(v, { grain: undefined, defaultText: labels(v) })
439
+ : cursorFormat !== undefined
440
+ ? resolveAxisFormat(e, xTickCount, cursorFormat)
441
+ : undefined,
442
+ };
443
+ }
414
444
  const labels = resolveAxisFormat(s, xTickCount, timeFormat);
415
445
  // The value-axis readout channel: a `cursorFormat` **string** is a d3
416
446
  // *number* specifier here (resolved through the linear scale, exactly as
@@ -447,6 +477,49 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
447
477
  }
448
478
  return undefined;
449
479
  };
480
+ // The **elapsed** (duration) flavour of both channels — the same scale
481
+ // wrapped so its ticks are anchored at `at` and its labels are durations.
482
+ // `at` is passed rather than closed over so the caller's `!== undefined`
483
+ // narrowing carries in.
484
+ const elapsedTime = (s, at) => {
485
+ const e = scaleElapsed(s, {
486
+ origin: at,
487
+ kind: 'time',
488
+ // An explicit d3 time specifier can only describe an instant, so it
489
+ // labels the wall clock underneath — the wall-clock-strip-under-a-
490
+ // duration-strip lever (see the `origin` prop docs).
491
+ absolute: (count, specifier) => {
492
+ const f = s.tickFormat(count, specifier);
493
+ return (v) => f(new Date(v));
494
+ },
495
+ });
496
+ // Labels: durations, unless a container `timeFormat` owns them.
497
+ const labels = timeFormat !== undefined
498
+ ? resolveTimeFormat(e, xTickCount, timeFormat)
499
+ : e.tickFormat(xTickCount);
500
+ // Readout: one grain finer than the ticks (`00:05:12` under a `00:05`
501
+ // axis) — the elapsed twin of the calendar axis's `readoutFormat`. Set
502
+ // explicitly (not left `undefined` to fall back to the labels) because
503
+ // here the labels ARE the terse tick text: an elapsed axis runs no
504
+ // date-style ladder, so nothing else would restore the precision.
505
+ const fine = e.readoutFormat(xTickCount);
506
+ const readout = typeof cursorFormat === 'function'
507
+ ? (v) => cursorFormat(v, {
508
+ grain: s.grain(xTickCount),
509
+ defaultText: fine(v),
510
+ })
511
+ : cursorFormat !== undefined
512
+ ? resolveTimeFormat(e, xTickCount, cursorFormat)
513
+ : // A container `timeFormat` shapes the readout too when no
514
+ // `cursorFormat` is set — its documented back-compat behaviour,
515
+ // and the same inversion as the `<XAxis format>` one a rung down:
516
+ // a custom format that owns the labels must own the pill rather
517
+ // than watch the elapsed default overrule it (PR #541 review).
518
+ timeFormat !== undefined
519
+ ? labels
520
+ : fine;
521
+ return { xScale: e, formatTime: labels, formatReadout: readout };
522
+ };
450
523
  if (xDiscontinuities !== undefined) {
451
524
  // Trading-time axis: closed-market gaps collapse, time proportional within
452
525
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
@@ -455,6 +528,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
455
528
  const s = scaleTradingTime(xDiscontinuities)
456
529
  .domain([d0, d1])
457
530
  .range([0, plotWidth]);
531
+ if (elapsedOrigin !== undefined)
532
+ return elapsedTime(s, elapsedOrigin);
458
533
  return {
459
534
  xScale: s,
460
535
  formatTime: timeLabels(s),
@@ -470,6 +545,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
470
545
  const s = scaleTradingTime(identityProvider())
471
546
  .domain([d0, d1])
472
547
  .range([0, plotWidth]);
548
+ if (elapsedOrigin !== undefined)
549
+ return elapsedTime(s, elapsedOrigin);
473
550
  return {
474
551
  xScale: s,
475
552
  formatTime: timeLabels(s),
@@ -483,6 +560,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
483
560
  plotWidth,
484
561
  timeFormat,
485
562
  cursorFormat,
563
+ elapsedOrigin,
486
564
  xDiscontinuities,
487
565
  xTickCount,
488
566
  ]);
@@ -617,6 +695,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
617
695
  formatTime,
618
696
  formatReadout,
619
697
  xFormatCustom: timeFormat !== undefined,
698
+ xReadoutCustom: cursorFormat !== undefined,
620
699
  xTickCount,
621
700
  registerTrackerSource,
622
701
  unregisterTrackerSource,
@@ -678,6 +757,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
678
757
  formatTime,
679
758
  formatReadout,
680
759
  timeFormat,
760
+ cursorFormat,
681
761
  xTickCount,
682
762
  registerTrackerSource,
683
763
  unregisterTrackerSource,
package/dist/XAxis.js CHANGED
@@ -165,7 +165,19 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
165
165
  // own channel, so precise-pill-over-terse-ticks works on any axis kind. A
166
166
  // `transform`ed axis is exempt — its pill speaks the derived unit, which a
167
167
  // data-unit `cursorFormat` can't address.
168
- const readoutFmt = transform === undefined ? (container.formatReadout ?? fmt) : fmt;
168
+ //
169
+ // The middle rung is why `xReadoutCustom` exists: `formatReadout` carries
170
+ // either a real `cursorFormat` (which outranks this axis's `format`) or the
171
+ // axis kind's own default readout (which does not — an elapsed container's
172
+ // finer `00:05:12`). Reading the field alone can't tell them apart, so a
173
+ // wall-clock strip declared as `<XAxis format="%H:%M">` under an elapsed
174
+ // container used to label its ticks `11:33` and pill them `00:05:12`
175
+ // (issue #540).
176
+ const readoutFmt = transform !== undefined
177
+ ? fmt
178
+ : format !== undefined && !container.xReadoutCustom
179
+ ? fmt
180
+ : (container.formatReadout ?? fmt);
169
181
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
170
182
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
171
183
  // colour, reading like a tick. An indicator always shows the axis coordinate
package/dist/context.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { LegendItemSpec } from './swatch.js';
5
5
  import type { Interval } from 'pond-ts';
6
6
  import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
7
7
  import type { ScaleBand } from './bandScale.js';
8
+ import type { ElapsedScale } from './elapsed.js';
8
9
  /**
9
10
  * The frame a {@link ChartContainer} provides to its rows and the time axis.
10
11
  * The container owns the **shared x geometry**: each side is split into *slots*
@@ -162,6 +163,17 @@ export interface ContainerFrame {
162
163
  * x axis suppresses its boundary (second) label row when it's set — a
163
164
  * custom format owns the whole label, so the ladder mustn't second-line it. */
164
165
  readonly xFormatCustom: boolean;
166
+ /**
167
+ * Whether an explicit container `cursorFormat` shaped {@link formatReadout} —
168
+ * as opposed to the axis kind supplying its own default readout (the elapsed
169
+ * axis's finer duration). The two are indistinguishable from the field alone,
170
+ * and `<XAxis>` must tell them apart to honour the documented pill precedence
171
+ * `cursorFormat → axis format → container`: a **`cursorFormat`** outranks an
172
+ * explicit `<XAxis format>`, a **default** does not. Without this the elapsed
173
+ * default silently occupied the `cursorFormat` slot and a wall-clock strip's
174
+ * pill read durations (issue #540, finding 2).
175
+ */
176
+ readonly xReadoutCustom: boolean;
165
177
  /**
166
178
  * The shared **x-side tick count** — the `count` every x-side `ticks()` /
167
179
  * `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
@@ -219,8 +231,14 @@ export interface ContainerFrame {
219
231
  * container is given `discontinuities`) is the third kind — same callable /
220
232
  * `invert` / `ticks` / `tickFormat` surface, but the mapping runs through
221
233
  * trading time so closed-market gaps collapse (see {@link discontinuities}).
222
- */
223
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand;
234
+ *
235
+ * A container given an `origin` wraps whichever of these it built in an
236
+ * {@link ElapsedScale} — the same pixel mapping, but ticks anchored at the
237
+ * origin and labelled as offsets (`00:05`), which is how the whole frame
238
+ * (axis labels, gridlines, cursor pill) reads durations without any consumer
239
+ * knowing about the mode.
240
+ */
241
+ readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
224
242
  /**
225
243
  * The discontinuity provider backing a **trading-time** x axis, if one was
226
244
  * supplied to the container — closed-market time (weekends, holidays,
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The **elapsed (duration) x axis** — the shared x scale relabelled as *offsets
3
+ * from an origin*, so an axis reads `00:00 00:05 00:10` (time since the start of
4
+ * the series) instead of `10:35 10:40 10:45` (wall clock), and a value axis
5
+ * reads distance-from-the-start instead of absolute distance.
6
+ *
7
+ * Two things change, and only these two: **where the ticks sit** and **what they
8
+ * say**. The pixel mapping is untouched, and so are the data coordinates — a
9
+ * mark's `at`, the container's `range`, an `onRegionSelect` span are all still
10
+ * absolute axis units. Relabeling only.
11
+ *
12
+ * Where the ticks sit is the part that can't be done with `<XAxis transform>`:
13
+ * an elapsed axis wants ticks at **round durations measured from the origin**
14
+ * (a run starting at 10:33:17 ticks at 10:33:17, 10:38:17, … so its labels read
15
+ * `00:00 00:05`), not at the wall-clock boundaries a calendar ladder picks. So
16
+ * the walk here is `origin + k·step` with `step` off a **duration ladder**
17
+ * (…15s, 30s, 1m, 2m, 5m… — not the 1-2-5 ladder, which would offer a
18
+ * 200-second tick). A value axis runs the identical walk on the plain 1-2-5
19
+ * ladder.
20
+ *
21
+ * Pure — no DOM, no React; {@link scaleElapsed} wraps a base scale with these
22
+ * ticks + labels and the container hands the result out as its `xScale`, so
23
+ * every consumer (axis labels, gridlines, cursor pill, marker indicators) reads
24
+ * the elapsed axis without knowing it exists.
25
+ */
26
+ /** The smallest 1-2-5 nice step ≥ `target` (the value-axis ladder). */
27
+ export declare function niceStep(target: number): number;
28
+ /**
29
+ * The tick step for a **duration** axis: the smallest ladder step that keeps the
30
+ * tick total at or under `count` across `span` ms. Past a day the ladder runs
31
+ * out and 1-2-5 whole days take over (2d, 5d, 10d, 20d, …) — calendar months
32
+ * are deliberately not a rung, since an elapsed axis measures duration, and
33
+ * "1 month later" is not a duration.
34
+ */
35
+ export declare function durationStep(span: number, count: number): number;
36
+ /**
37
+ * Tick values in **absolute axis units** at `origin + k·step`, covering
38
+ * `domain` — the anchored walk that makes `00:05` land exactly five minutes
39
+ * after the origin rather than on the nearest clock boundary. `k` runs negative
40
+ * where the domain reaches back before the origin (a T-minus axis), so the walk
41
+ * is origin-anchored, not domain-anchored. Ascending; `[]` for a degenerate
42
+ * domain or step.
43
+ */
44
+ export declare function originTicks(domain: readonly [number, number], origin: number, step: number): number[];
45
+ /**
46
+ * Which components a duration label shows. Resolved once from the tick step and
47
+ * the axis's magnitude ({@link durationShape}) so every label on one axis has
48
+ * the same shape — and so the cursor readout can add seconds to the *same*
49
+ * shape rather than picking its own (a `00:05` axis must not read `05:12` under
50
+ * the pointer).
51
+ */
52
+ export interface DurationShape {
53
+ /** Prefix a `Nd ` day part (only rendered when the day count is non-zero). */
54
+ readonly days: boolean;
55
+ /** Head the clock with hours (`HH:MM`) rather than minutes (`MM:SS`). */
56
+ readonly hours: boolean;
57
+ readonly seconds: boolean;
58
+ readonly millis: boolean;
59
+ /** Whole days only (`0d 1d 2d`) — a day-or-coarser step has no clock to show. */
60
+ readonly dayGrain: boolean;
61
+ }
62
+ /**
63
+ * Pick the label shape for a duration axis from its tick `step` (which sets the
64
+ * *finest* component shown — a 5-minute step has no business printing seconds)
65
+ * and `maxAbs`, the largest offset the axis reaches (which sets the *coarsest*).
66
+ *
67
+ * The one non-obvious rung: an axis whose step is a minute or coarser heads its
68
+ * clock with **hours even when they're zero** (`00:05` = five minutes in),
69
+ * because that is what the wall-clock axis it replaces looked like. Only an axis
70
+ * fine enough to show seconds drops to `MM:SS`.
71
+ */
72
+ export declare function durationShape(step: number, maxAbs: number): DurationShape;
73
+ /**
74
+ * Render an elapsed `ms` in the given {@link DurationShape}: `00:05`, `12:30`,
75
+ * `01:15:30`, `2d 06:00`, `0d`, `-00:05`. Negative offsets (a domain reaching
76
+ * back before the origin — the T-minus case) carry a leading `-`.
77
+ *
78
+ * Truncates rather than rounds, so a label reads like a clock: 59.7s at second
79
+ * grain is `00:59`, not `01:00`. Hours accumulate past 24 when the shape has no
80
+ * day part, so an off-axis readout can't silently wrap.
81
+ */
82
+ export declare function formatDuration(ms: number, shape: DurationShape): string;
83
+ /**
84
+ * The x scale a container in elapsed mode hands out — the base scale's pixel
85
+ * mapping (`invert`, `domain`, `range` all pass straight through) with
86
+ * origin-anchored {@link originTicks} and offset labels layered on. Deliberately
87
+ * *not* a {@link TradingTimeScale}: it exposes no `tickBoundaries` / `bands` /
88
+ * `gridLevels`, which is exactly how `<XAxis>` knows to skip the calendar date
89
+ * styles and how `Layers` knows to draw its gridlines at the labelled (elapsed)
90
+ * ticks instead of the calendar grain populations.
91
+ */
92
+ export interface ElapsedScale {
93
+ (value: number): number;
94
+ invert(pixel: number): number;
95
+ ticks(count?: number): number[];
96
+ /**
97
+ * The label formatter. With no `specifier` this is the **offset** formatter —
98
+ * a duration on a time axis, the d3 default over the offset domain on a value
99
+ * axis. With one, see {@link ElapsedOptions.absolute}.
100
+ */
101
+ tickFormat(count?: number, specifier?: string): (value: number | Date) => string;
102
+ domain(): [number, number];
103
+ range(): [number, number];
104
+ /** The zero point, in absolute axis units. */
105
+ readonly origin: number;
106
+ /** A formatter one grain finer than the tick labels (seconds always shown on a
107
+ * time axis), for the cursor pill / marker indicators — the same
108
+ * precise-readout-over-terse-ticks split the calendar axis makes. */
109
+ readoutFormat(count?: number): (value: number) => string;
110
+ }
111
+ /** The slice of the base scale {@link scaleElapsed} wraps — d3's `ScaleLinear`
112
+ * and a `TradingTimeScale` both satisfy it. */
113
+ interface ElapsedBase {
114
+ (value: number): number;
115
+ invert(pixel: number): number;
116
+ domain(): number[];
117
+ range(): number[];
118
+ }
119
+ export interface ElapsedOptions {
120
+ /** The zero point in absolute axis units — what `00:00` (or `0`) means. */
121
+ readonly origin: number;
122
+ readonly kind: 'time' | 'value';
123
+ /**
124
+ * Formatter for an explicit d3 **specifier** on a *time* axis, in absolute
125
+ * units (the container passes its wall-clock scale's `tickFormat`). A d3 time
126
+ * specifier can only describe an instant — `%H:%M` of a duration is not a
127
+ * thing — so an explicit format on an elapsed time axis labels the underlying
128
+ * wall clock. That's the lever for pairing a wall-clock strip with a duration
129
+ * strip on the same ticks. A **value** axis needs none: a number specifier
130
+ * describes the offset perfectly well, so it formats the offset.
131
+ */
132
+ absolute?(count: number, specifier: string): (value: number) => string;
133
+ }
134
+ /**
135
+ * Wrap `base` as an {@link ElapsedScale}: same pixels, ticks anchored at
136
+ * `origin`, labels in offsets.
137
+ */
138
+ export declare function scaleElapsed(base: ElapsedBase, options: ElapsedOptions): ElapsedScale;
139
+ export {};
140
+ //# sourceMappingURL=elapsed.d.ts.map
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The **elapsed (duration) x axis** — the shared x scale relabelled as *offsets
3
+ * from an origin*, so an axis reads `00:00 00:05 00:10` (time since the start of
4
+ * the series) instead of `10:35 10:40 10:45` (wall clock), and a value axis
5
+ * reads distance-from-the-start instead of absolute distance.
6
+ *
7
+ * Two things change, and only these two: **where the ticks sit** and **what they
8
+ * say**. The pixel mapping is untouched, and so are the data coordinates — a
9
+ * mark's `at`, the container's `range`, an `onRegionSelect` span are all still
10
+ * absolute axis units. Relabeling only.
11
+ *
12
+ * Where the ticks sit is the part that can't be done with `<XAxis transform>`:
13
+ * an elapsed axis wants ticks at **round durations measured from the origin**
14
+ * (a run starting at 10:33:17 ticks at 10:33:17, 10:38:17, … so its labels read
15
+ * `00:00 00:05`), not at the wall-clock boundaries a calendar ladder picks. So
16
+ * the walk here is `origin + k·step` with `step` off a **duration ladder**
17
+ * (…15s, 30s, 1m, 2m, 5m… — not the 1-2-5 ladder, which would offer a
18
+ * 200-second tick). A value axis runs the identical walk on the plain 1-2-5
19
+ * ladder.
20
+ *
21
+ * Pure — no DOM, no React; {@link scaleElapsed} wraps a base scale with these
22
+ * ticks + labels and the container hands the result out as its `xScale`, so
23
+ * every consumer (axis labels, gridlines, cursor pill, marker indicators) reads
24
+ * the elapsed axis without knowing it exists.
25
+ */
26
+ import { scaleLinear } from 'd3-scale';
27
+ const SECOND = 1000;
28
+ const MINUTE = 60 * SECOND;
29
+ const HOUR = 60 * MINUTE;
30
+ const DAY = 24 * HOUR;
31
+ /**
32
+ * The duration tick ladder in ms — the steps a *clock* subdivides by, which is
33
+ * not the 1-2-5 ladder: 15s and 30s are round durations where 20s and 50s are
34
+ * not, and an hour divides by 2/3/6/12 rather than by 2/5. Steps coarser than a
35
+ * day fall back to 1-2-5 whole days (see {@link durationStep}).
36
+ */
37
+ const DURATION_STEPS = [
38
+ 1,
39
+ 2,
40
+ 5,
41
+ 10,
42
+ 20,
43
+ 50,
44
+ 100,
45
+ 200,
46
+ 500,
47
+ SECOND,
48
+ 2 * SECOND,
49
+ 5 * SECOND,
50
+ 10 * SECOND,
51
+ 15 * SECOND,
52
+ 30 * SECOND,
53
+ MINUTE,
54
+ 2 * MINUTE,
55
+ 5 * MINUTE,
56
+ 10 * MINUTE,
57
+ 15 * MINUTE,
58
+ 30 * MINUTE,
59
+ HOUR,
60
+ 2 * HOUR,
61
+ 3 * HOUR,
62
+ 6 * HOUR,
63
+ 12 * HOUR,
64
+ DAY,
65
+ ];
66
+ /** The smallest 1-2-5 nice step ≥ `target` (the value-axis ladder). */
67
+ export function niceStep(target) {
68
+ if (!(target > 0) || !Number.isFinite(target))
69
+ return 1;
70
+ const pow = 10 ** Math.floor(Math.log10(target));
71
+ for (const m of [1, 2, 5]) {
72
+ if (m * pow >= target)
73
+ return m * pow;
74
+ }
75
+ return 10 * pow;
76
+ }
77
+ /**
78
+ * The tick step for a **duration** axis: the smallest ladder step that keeps the
79
+ * tick total at or under `count` across `span` ms. Past a day the ladder runs
80
+ * out and 1-2-5 whole days take over (2d, 5d, 10d, 20d, …) — calendar months
81
+ * are deliberately not a rung, since an elapsed axis measures duration, and
82
+ * "1 month later" is not a duration.
83
+ */
84
+ export function durationStep(span, count) {
85
+ const target = span / Math.max(1, count);
86
+ if (!(target > 0) || !Number.isFinite(target))
87
+ return 1;
88
+ for (const step of DURATION_STEPS) {
89
+ if (step >= target)
90
+ return step;
91
+ }
92
+ return niceStep(target / DAY) * DAY;
93
+ }
94
+ /** Backstop against a pathological (step, domain) pair flooding the axis; the
95
+ * step is derived from the domain span, so a real axis never comes close. */
96
+ const MAX_TICKS = 10_000;
97
+ /** Closest two ticks may sit in pixels before the second is dropped as a
98
+ * duplicate. Only ever fires where a discontinuous base scale collapses a span
99
+ * to a point — a continuous axis spaces its ticks tens of px apart. */
100
+ const MIN_TICK_PX = 1;
101
+ /**
102
+ * Tick values in **absolute axis units** at `origin + k·step`, covering
103
+ * `domain` — the anchored walk that makes `00:05` land exactly five minutes
104
+ * after the origin rather than on the nearest clock boundary. `k` runs negative
105
+ * where the domain reaches back before the origin (a T-minus axis), so the walk
106
+ * is origin-anchored, not domain-anchored. Ascending; `[]` for a degenerate
107
+ * domain or step.
108
+ */
109
+ export function originTicks(domain, origin, step) {
110
+ const lo = Math.min(domain[0], domain[1]);
111
+ const hi = Math.max(domain[0], domain[1]);
112
+ if (!Number.isFinite(lo) ||
113
+ !Number.isFinite(hi) ||
114
+ !Number.isFinite(origin) ||
115
+ !(step > 0) ||
116
+ hi < lo) {
117
+ return [];
118
+ }
119
+ // ±1e-9 relative slack so a tick sitting exactly on a domain edge (the very
120
+ // common `origin === lo` case — the `00:00` tick) is not lost to float drift.
121
+ const eps = 1e-9 * Math.max(1, Math.abs(hi - lo) / step);
122
+ const k0 = Math.ceil((lo - origin) / step - eps);
123
+ const k1 = Math.floor((hi - origin) / step + eps);
124
+ if (k1 < k0 || k1 - k0 > MAX_TICKS)
125
+ return [];
126
+ const out = [];
127
+ for (let k = k0; k <= k1; k++)
128
+ out.push(origin + k * step);
129
+ return out;
130
+ }
131
+ /**
132
+ * Pick the label shape for a duration axis from its tick `step` (which sets the
133
+ * *finest* component shown — a 5-minute step has no business printing seconds)
134
+ * and `maxAbs`, the largest offset the axis reaches (which sets the *coarsest*).
135
+ *
136
+ * The one non-obvious rung: an axis whose step is a minute or coarser heads its
137
+ * clock with **hours even when they're zero** (`00:05` = five minutes in),
138
+ * because that is what the wall-clock axis it replaces looked like. Only an axis
139
+ * fine enough to show seconds drops to `MM:SS`.
140
+ */
141
+ export function durationShape(step, maxAbs) {
142
+ const seconds = step < MINUTE;
143
+ return {
144
+ dayGrain: step >= DAY,
145
+ days: maxAbs >= DAY,
146
+ hours: maxAbs >= HOUR || !seconds,
147
+ seconds,
148
+ millis: step < SECOND,
149
+ };
150
+ }
151
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
152
+ /**
153
+ * Render an elapsed `ms` in the given {@link DurationShape}: `00:05`, `12:30`,
154
+ * `01:15:30`, `2d 06:00`, `0d`, `-00:05`. Negative offsets (a domain reaching
155
+ * back before the origin — the T-minus case) carry a leading `-`.
156
+ *
157
+ * Truncates rather than rounds, so a label reads like a clock: 59.7s at second
158
+ * grain is `00:59`, not `01:00`. Hours accumulate past 24 when the shape has no
159
+ * day part, so an off-axis readout can't silently wrap.
160
+ */
161
+ export function formatDuration(ms, shape) {
162
+ if (!Number.isFinite(ms))
163
+ return '';
164
+ const sign = ms < 0 ? '-' : '';
165
+ let rest = Math.floor(Math.abs(ms));
166
+ const dayPart = Math.floor(rest / DAY);
167
+ if (shape.dayGrain)
168
+ return `${sign}${dayPart}d`;
169
+ if (shape.days)
170
+ rest -= dayPart * DAY;
171
+ const hours = Math.floor(rest / HOUR);
172
+ rest -= hours * HOUR;
173
+ const mins = Math.floor(rest / MINUTE);
174
+ rest -= mins * MINUTE;
175
+ const secs = Math.floor(rest / SECOND);
176
+ rest -= secs * SECOND;
177
+ // The day part shows only when there is one — an axis's first day reads
178
+ // `06:00`, its second `1d 06:00`, exactly as the flat date style promotes a
179
+ // tick that opens a coarser period.
180
+ const prefix = shape.days && dayPart > 0 ? `${dayPart}d ` : '';
181
+ const clock = shape.hours
182
+ ? `${pad(hours)}:${pad(mins)}${shape.seconds ? `:${pad(secs)}` : ''}`
183
+ : `${pad(mins)}:${pad(secs)}`;
184
+ const frac = shape.millis ? `.${pad(rest, 3)}` : '';
185
+ return `${sign}${prefix}${clock}${frac}`;
186
+ }
187
+ /** Default tick target when a caller passes none (d3's convention). */
188
+ const DEFAULT_COUNT = 10;
189
+ /**
190
+ * Wrap `base` as an {@link ElapsedScale}: same pixels, ticks anchored at
191
+ * `origin`, labels in offsets.
192
+ */
193
+ export function scaleElapsed(base, options) {
194
+ const { origin, kind, absolute } = options;
195
+ const bounds = () => {
196
+ const d = base.domain();
197
+ return [Number(d[0] ?? 0), Number(d[1] ?? 0)];
198
+ };
199
+ const stepFor = (count) => {
200
+ const [lo, hi] = bounds();
201
+ const span = Math.abs(hi - lo);
202
+ return kind === 'time'
203
+ ? durationStep(span, count)
204
+ : niceStep(span / Math.max(1, count));
205
+ };
206
+ const shapeFor = (count) => {
207
+ const [lo, hi] = bounds();
208
+ const maxAbs = Math.max(Math.abs(lo - origin), Math.abs(hi - origin));
209
+ return durationShape(stepFor(count), maxAbs);
210
+ };
211
+ /** The value-axis offset formatter — resolved against a scale over the
212
+ * *offset* domain, so d3 picks its precision from the numbers on show. */
213
+ const offsetFormat = (count, specifier) => {
214
+ const [lo, hi] = bounds();
215
+ const s = scaleLinear().domain([lo - origin, hi - origin]);
216
+ const f = specifier !== undefined
217
+ ? s.tickFormat(count, specifier)
218
+ : s.tickFormat(count);
219
+ return (v) => f(v - origin);
220
+ };
221
+ /**
222
+ * Drop ticks that land on a pixel another tick already claimed. The walk is in
223
+ * **wall-clock** ms, but the base scale need not be continuous: on a trading
224
+ * axis every instant inside a collapsed session maps to the one seam pixel, so
225
+ * a duration ladder striding through a closed market emits several ticks at
226
+ * the *same* x — labels stacked on labels, gridlines stroked on gridlines
227
+ * (issue #540, finding 1). A plain axis is untouched: its ticks are ~65px
228
+ * apart by construction, so nothing is ever within the gap.
229
+ *
230
+ * The survivor is the **last** tick of each pixel group, not the first. Ticks
231
+ * ascend, so a group spans a collapsed gap and ends at the first instant the
232
+ * axis actually draws — the session open. Keeping the first would label the
233
+ * seam with a moment the market was shut: on three 09:30–16:00 sessions the
234
+ * pixel would read `12:00` (21:30 that night) instead of `1d 00:00`, the
235
+ * Tuesday open that genuinely sits there (PR #541 review).
236
+ */
237
+ const dedupeByPixel = (values) => {
238
+ const r = base.range();
239
+ // Pre-layout the whole range is one pixel wide; nothing is visible, and
240
+ // deduping there would collapse the axis to a single tick.
241
+ if (!(Math.abs(Number(r[1] ?? 0) - Number(r[0] ?? 0)) > 0))
242
+ return [...values];
243
+ const out = [];
244
+ let lastPx = 0;
245
+ for (const v of values) {
246
+ const px = base(v);
247
+ if (!Number.isFinite(px))
248
+ continue;
249
+ if (out.length > 0 && Math.abs(px - lastPx) < MIN_TICK_PX) {
250
+ out[out.length - 1] = v; // same pixel — the later instant wins
251
+ lastPx = px;
252
+ continue;
253
+ }
254
+ out.push(v);
255
+ lastPx = px;
256
+ }
257
+ return out;
258
+ };
259
+ const scale = ((value) => base(value));
260
+ Object.assign(scale, {
261
+ origin,
262
+ invert: (pixel) => Number(base.invert(pixel)),
263
+ domain: () => bounds(),
264
+ range: () => {
265
+ const r = base.range();
266
+ return [Number(r[0] ?? 0), Number(r[1] ?? 0)];
267
+ },
268
+ ticks: (count = DEFAULT_COUNT) => dedupeByPixel(originTicks(bounds(), origin, stepFor(count))),
269
+ tickFormat: (count = DEFAULT_COUNT, specifier) => {
270
+ if (kind === 'value')
271
+ return offsetFormat(count, specifier);
272
+ if (specifier !== undefined && absolute !== undefined) {
273
+ return absolute(count, specifier);
274
+ }
275
+ const shape = shapeFor(count);
276
+ return (value) => formatDuration(+value - origin, shape);
277
+ },
278
+ readoutFormat: (count = DEFAULT_COUNT) => {
279
+ if (kind === 'value')
280
+ return offsetFormat(count);
281
+ // Seconds on top of the ticks' own shape — never a *different* shape, so
282
+ // a `00:05` axis reads `00:05:12` under the pointer, not `05:12`.
283
+ const shape = { ...shapeFor(count), seconds: true, dayGrain: false };
284
+ return (value) => formatDuration(value - origin, shape);
285
+ },
286
+ });
287
+ return scale;
288
+ }
289
+ //# sourceMappingURL=elapsed.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.52.0",
3
+ "version": "0.53.1",
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.52.0",
42
- "pond-ts": "^0.52.0",
41
+ "@pond-ts/react": "^0.53.0",
42
+ "pond-ts": "^0.53.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {