@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/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/pjm17971/pond-ts/compare/v0.44.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...HEAD
12
+ [0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
13
+ [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
12
14
  [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
13
15
  [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
14
16
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
@@ -42,6 +44,120 @@ and type-level changes; patch bumps are strictly additive.
42
44
 
43
45
  ## [Unreleased]
44
46
 
47
+ ## [0.45.0] — 2026-07-14
48
+
49
+ ### Added
50
+
51
+ - **charts:** the time axis now walks a **logical tick ladder** — clock/calendar
52
+ units (1s…30s, 1m…30m, 1H…12H, day / week / month / quarter / year), picking
53
+ the finest grain that fits the width-derived cap — and renders **two-tier labels**:
54
+ a first row at the tick grain (`14:00`, `Feb 02`, `Feb`, `2026`) plus a
55
+ **boundary row** carrying the coarser context the first row omits (the date
56
+ under clock ticks, the year under day/week/month ticks — never a unit the
57
+ first row already shows), shown once under
58
+ the first tick of each new period. Hour anchors are generated in **live**
59
+ trading time, so none lands in a collapsed gap, an early close, or a lunch
60
+ break. `TradingTimeScale` gains `tickBoundaries(count)`; a cramped leading
61
+ partial-period anchor (the `"Jun 23Jul 07"` pile-up) is dropped. Systematic
62
+ story matrix under `Charts/TimeAxisTicks`.
63
+ - **financial:** the rest of the first study batch — `rollingStdev`,
64
+ `rollingMin`, `rollingMax`, `rollingPercentile` (linear interpolation),
65
+ `zScore` (rolling), `envelope` (MA ± percent, `maType` sma/ema), and
66
+ `percentChange` (n-bar rate of change). Same shape as the others — `column` /
67
+ `output` (or `prefix`) on every one, bar-count periods, length-preserving
68
+ warm-up — and each has a fluent method (`bars.zScore({ period: 20 })`) and a
69
+ **pandas oracle** case (values cross-validated bar-for-bar). This completes the
70
+ #449 first batch.
71
+ - **financial:** opt-in **fluent studies** via `import '@pond-ts/financial/fluent'`
72
+ — mounts `sma` / `ema` / `bollinger` as chainable `TimeSeries` methods so
73
+ composition reads like the core operators beside it:
74
+ `bars.sma({ period: 20 }).ema({ period: 12 }).bollinger({ period: 20 })`.
75
+ Opt-in by import (the default entry leaves `TimeSeries` untouched, so a
76
+ non-financial series never sees `.sma()`); the methods are exactly the
77
+ standalone functions bound to `this`, fully typed (each appends its column to
78
+ the schema). Same prototype-augmentation pattern core uses for column methods.
79
+ - **financial:** `@pond-ts/financial` gains its first **studies** — `sma`, `ema`,
80
+ and `bollinger` (Bollinger Bands®) — pure functions that append a column (or a
81
+ `${prefix}Middle/Upper/Lower` family) to a bar series. Every study takes a
82
+ `column` source (default `'close'`) and an `output` name, so a study runs over
83
+ any numeric column including another study's output; periods are **bar counts**
84
+ (on core's count window, gap-correct); warm-up rows are `undefined`
85
+ (length-preserving). Plus the `OhlcvColumns` contract + `DEFAULT_OHLCV`. (Core
86
+ `AppendColumn` is now exported so study return types name their appended
87
+ column.)
88
+ - **core:** `smooth(col, 'ema', …)` gains the financial **`span`** rate
89
+ convention (`α = 2/(span+1)`, e.g. `{ span: 12 }` for a 12-period EMA) as an
90
+ alternative to `alpha` (exactly one required), and a length-preserving
91
+ **`minSamples`** warm-up that emits `undefined` for the first `N` present
92
+ values while **keeping the row count** — mirroring `rolling`'s `minSamples`,
93
+ the one warm-up convention studies use so a smoothed line aligns on its
94
+ source's time axis. (The existing `warmup` option is unchanged — it still
95
+ *drops* the head rows; `minSamples` is the length-preserving counterpart.)
96
+ - **core:** `TimeSeries.rolling` accepts a **count-based** window —
97
+ `rolling({ count: N }, mapping, opts?)` reduces the last / next / centered
98
+ `N` _rows_ (bars) by position instead of a time span. Unlike a duration
99
+ window, an N-bar window stays correct across session gaps (weekends,
100
+ overnight) where `N` bars do not equal `N × barSize` of time — the load-
101
+ bearing primitive for financial N-bar studies (SMA-20, Bollinger-20, rolling
102
+ stdev). Honours `alignment` and `minSamples` exactly like a duration window
103
+ (`minSamples: N` gives the conventional first-`N-1`-rows-`undefined` warmup);
104
+ per-row output only (not supported with a sequence). Same amortized-O(1)-per-
105
+ row sweep as the duration path.
106
+ - **charts:** `<LineChart sessionBreaks>` breaks the line at each trading-axis
107
+ discontinuity (a session / day / lunch close→open) instead of connecting the
108
+ prior close straight to the next open across the collapsed gap. For intraday
109
+ data on a `discontinuities` / `calendar` axis this gives the terminal look —
110
+ the line ends at the close and re-starts at the open, so one session's price
111
+ doesn't visually flow into the next. It's a **scale** break (driven by the
112
+ axis's collapsed gaps), orthogonal to `gaps` (a **data** break, a NaN run) —
113
+ set both independently. Default `false`; a no-op on a continuous axis or a
114
+ provider without `boundaries`. New `Charts/TradingTimeAxis/SessionBreaks`
115
+ story (connected vs broken) + `sessionRuns` / `gappingTicks` helpers.
116
+
117
+ ### Changed
118
+
119
+ - **charts:** a **plain continuous time axis** now runs the same ladder as a
120
+ trading-calendar axis (via an internal gap-free identity provider) instead of
121
+ d3's multi-scale default — so a year of daily data ticks cleanly on month
122
+ starts (`Jul Aug … Jun` + the year underneath) rather than mixed
123
+ `"Jun 23" / "Sep" / "Dec"` labels, and its tick count is now width-derived
124
+ like the trading axis's. Month/quarter-grain anchor labels are now bare
125
+ months (`Feb`, was `Feb 02`) with the year on the boundary row;
126
+ `coarsenCalendar`'s finest granularity is renamed `'session'` → `'day'`.
127
+
128
+ ### Fixed
129
+
130
+ - **core:** `TimeSeries.withColumn` now appends an **optional** column
131
+ (`required: false`) rather than a required one. It already accepted
132
+ `(number | undefined)[]`, but marking the column required meant a later
133
+ strict-intake rebuild (`smooth`, any row reconstruction) threw on the gaps —
134
+ so a column with a warm-up (a rolling study fed into an EMA, e.g.
135
+ `ema(sma(bars, …), …)`) crashed. The appended column's type is now
136
+ `number | undefined`, matching `smooth`'s optional append. (`OptionalNumberColumn`
137
+ is exported for the widened return type.)
138
+
139
+ ## [0.44.1] — 2026-07-13
140
+
141
+ A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
142
+ `@pond-ts/react`, `@pond-ts/fit`, and `@pond-ts/financial` carry no code
143
+ changes — republished in lock-step; their `^0.44.0` peer ranges already
144
+ admit this patch.
145
+
146
+ ### Fixed
147
+
148
+ - **charts:** trading-time axis tick density now derives from plot width
149
+ instead of a fixed count of 5. The trading scale's tick `count` caps how many
150
+ calendar buckets `coarsenCalendar` may keep, so the fixed count coarsened any
151
+ ≳6-month daily view to quarter or year grain — a 1-year daily chart on a
152
+ ~900px plot showed 2 ticks; it now lands on month grain (~12). Continuous
153
+ (non-trading) time and value axes keep the fixed default, unchanged. The
154
+ count is shared through the container frame (`xTickCount`), so axis labels,
155
+ x gridlines, session dividers, and the cursor-time formatter all derive from
156
+ the same instants — previously the label formatter anchored at the scale's
157
+ internal default (10) while ticks used 5, which is why sparse year-grain
158
+ ticks were labelled with dates (`Jun 22`) instead of years. (Tidal friction
159
+ report, charts 0.44.)
160
+
45
161
  ## [0.44.0] — 2026-07-11
46
162
 
47
163
  The **value-axis charts** release: cross-sectional data (a volatility smile keyed
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
- import { scaleLinear, scaleTime } from 'd3-scale';
4
- import { scaleTradingTime, } from './tradingTimeScale.js';
3
+ import { scaleLinear } from 'd3-scale';
4
+ import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
6
  import { Sequence } from 'pond-ts';
7
7
  import { ContainerContext, } from './context.js';
@@ -11,9 +11,18 @@ import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
11
11
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
12
12
  import { TimeAxis } from './TimeAxis.js';
13
13
  import { defaultTheme } from './theme.js';
14
- /** Time-axis tick count matches `<TimeAxis>` so the cursor-time formatter is
15
- * calibrated as the time-axis labels are. */
14
+ /** Tick count for a **continuous** (non-trading) x axis the `ticks(count)`
15
+ * request `<TimeAxis>`, the x gridlines, and the cursor-time formatter share
16
+ * (as the frame's `xTickCount`). */
16
17
  const TIME_TICK_COUNT = 5;
18
+ /** Target px of plot width per tick on a **trading-time** axis. That scale's
19
+ * `ticks(count)` treats `count` as a **cap on calendar buckets** (see
20
+ * `coarsenCalendar` — it picks the finest grain that fits), so the count must
21
+ * scale with the room the labels actually have: a fixed 5 coarsens any
22
+ * ≳6-month daily view to year grain — 2 ticks on a 900px plot. ~65px fits a
23
+ * `%b %d` anchor label at the default font plus breathing room, so a ~900px
24
+ * year-long daily view lands on month grain. */
25
+ const TRADING_TICK_PX = 65;
17
26
  /**
18
27
  * Normalize the `range` prop — a `[begin, end]` tuple or a `TimeRange` — to a
19
28
  * plain `[number, number]`, or `undefined` when omitted (→ auto-fit). The
@@ -323,6 +332,16 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
323
332
  ? calendar.discontinuities(spacing ? { spacing } : undefined)
324
333
  : undefined, [resolvedKind, discontinuities, calendar, spacing]);
325
334
  const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
335
+ // The shared x-side tick count — labels, x gridlines, session dividers, and
336
+ // `formatTime` all pass this one value, so they derive from the same instants
337
+ // (the alignment previously held by three hardcoded constants agreeing).
338
+ // Time axis (trading or plain — both run the logical tick ladder):
339
+ // width-derived, since the ladder's `count` caps its calendar buckets rather
340
+ // than targeting a tick total; floored at 2 so a pre-layout zero width still
341
+ // requests a drawable tick set. Value/category axes keep the d3 target count.
342
+ const xTickCount = resolvedKind === 'time'
343
+ ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
344
+ : TIME_TICK_COUNT;
326
345
  const { xScale, formatTime } = useMemo(() => {
327
346
  if (resolvedKind === 'category') {
328
347
  // Ordinal column-domain axis: a band scale over the category slots. The
@@ -339,24 +358,34 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
339
358
  const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
340
359
  return {
341
360
  xScale: s,
342
- formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
361
+ formatTime: resolveAxisFormat(s, xTickCount, timeFormat),
343
362
  };
344
363
  }
345
364
  if (xDiscontinuities !== undefined) {
346
365
  // Trading-time axis: closed-market gaps collapse, time proportional within
347
366
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
367
+ // `xTickCount` reaches `tickFormat` too: the trading scale picks its anchor
368
+ // grain from the count, so labels sit on the exact instants the ticks do.
348
369
  const s = scaleTradingTime(xDiscontinuities)
349
370
  .domain([d0, d1])
350
371
  .range([0, plotWidth]);
351
372
  return {
352
373
  xScale: s,
353
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
374
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
354
375
  };
355
376
  }
356
- const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
377
+ // Plain continuous time axis: the same trading-time scale over the
378
+ // gap-free identity provider, so it runs the same logical tick ladder
379
+ // (month starts over a year, clock-aligned hours over an afternoon) —
380
+ // never d3's mixed multi-scale default. Interactions stay on continuous
381
+ // time math: the frame's `discontinuities` remains undefined, and identity
382
+ // distance/offset are plain subtraction/addition anyway.
383
+ const s = scaleTradingTime(identityProvider())
384
+ .domain([d0, d1])
385
+ .range([0, plotWidth]);
357
386
  return {
358
387
  xScale: s,
359
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
388
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
360
389
  };
361
390
  }, [
362
391
  resolvedKind,
@@ -366,6 +395,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
366
395
  plotWidth,
367
396
  timeFormat,
368
397
  xDiscontinuities,
398
+ xTickCount,
369
399
  ]);
370
400
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
371
401
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
@@ -478,6 +508,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
478
508
  onHoverAnnotation,
479
509
  onEditAnnotation,
480
510
  formatTime,
511
+ xFormatCustom: timeFormat !== undefined,
512
+ xTickCount,
481
513
  registerTrackerSource,
482
514
  unregisterTrackerSource,
483
515
  registerSelectable,
@@ -531,6 +563,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
531
563
  onHoverAnnotation,
532
564
  onEditAnnotation,
533
565
  formatTime,
566
+ timeFormat,
567
+ xTickCount,
534
568
  registerTrackerSource,
535
569
  unregisterTrackerSource,
536
570
  registerSelectable,
package/dist/Layers.js CHANGED
@@ -7,10 +7,12 @@ import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
8
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
- /** Gridline tick count. **Must match the axis label counts** (`XAxis`
11
- * `TICK_COUNT`, `ChartContainer` `TIME_TICK_COUNT`, `YAxis`) — the grid, the
12
- * session dividers, and the axis labels are all derived from `ticks(count)`, so
13
- * they only line up while the counts agree. Kept at 5 across all four. */
10
+ /** **Y**-gridline tick count. **Must match the y-axis label counts** (`YAxis`
11
+ * `TICK_COUNT`, `ChartRow` `AXIS_TICK_COUNT`) — horizontal gridlines and the y
12
+ * labels are both derived from `ticks(count)`, so they only line up while the
13
+ * counts agree; kept at 5 across all three. The **x** side instead reads the
14
+ * container's shared `xTickCount` (as `<XAxis>` and `formatTime` do), which is
15
+ * width-derived on a trading-time axis. */
14
16
  const GRID_TICKS = 5;
15
17
  /** Minimum px between session dividers — thins dense collapse points (e.g. a
16
18
  * daily chart where every candle is a new session) so the axis never crowds. */
@@ -56,8 +58,10 @@ export function Layers({ children }) {
56
58
  const background = container.theme.background;
57
59
  const { grid: gridColor, gridDash } = container.theme.axis;
58
60
  const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
59
- // x geometry is shared and lives on the container (uniform across rows).
60
- const { xScale, plotWidth } = container;
61
+ // x geometry is shared and lives on the container (uniform across rows), and
62
+ // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
63
+ // labels, which pass the same `xTickCount` to the same scale.
64
+ const { xScale, plotWidth, xTickCount } = container;
61
65
  const draw = useCallback((ctx, w, h) => {
62
66
  if (background !== undefined) {
63
67
  ctx.fillStyle = background;
@@ -71,7 +75,7 @@ export function Layers({ children }) {
71
75
  const explicitY = tickValues.get(defaultAxisId);
72
76
  // A category axis draws no vertical gridlines — a line through each bar
73
77
  // centre reads as noise; the bars are the structure.
74
- const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
78
+ const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(xTickCount);
75
79
  const xTicks = xTickVals.map((d) => xScale(+d));
76
80
  const yTicks = gridY
77
81
  ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
@@ -104,6 +108,7 @@ export function Layers({ children }) {
104
108
  layers,
105
109
  yScales,
106
110
  xScale,
111
+ xTickCount,
107
112
  defaultAxisId,
108
113
  tickValues,
109
114
  background,
@@ -49,6 +49,20 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
49
49
  * `gap.connectorOpacity`.)
50
50
  */
51
51
  gaps?: GapMode;
52
+ /**
53
+ * Break the line at each **trading-axis discontinuity** (a session / day /
54
+ * lunch close→open) when the container renders on a trading-time axis (a
55
+ * `discontinuities` / `calendar` provider). **Omitted ⇒ `false`**: the line
56
+ * connects the last pre-close point straight to the next open across the
57
+ * collapsed gap (the near-vertical connector). `true` ends the line at the
58
+ * close and re-starts it at the open — the intraday look, where a session's
59
+ * price shouldn't visually flow into the next.
60
+ *
61
+ * This is a **scale** break (driven by the axis's collapsed gaps), orthogonal
62
+ * to {@link gaps} (a **data** break, a NaN run) — set both independently. A
63
+ * no-op on a continuous axis (no provider) or a provider without `boundaries`.
64
+ */
65
+ sessionBreaks?: boolean;
52
66
  /**
53
67
  * @internal Declaration position among the `<Layers>` children, injected by
54
68
  * `Layers` so z-order follows JSX order. Do not set.
@@ -61,5 +75,5 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
61
75
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
62
76
  * it. The line breaks at gaps rather than spanning them.
63
77
  */
64
- export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, index, }: LineChartProps<S, VS>): null;
78
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, sessionBreaks, index, }: LineChartProps<S, VS>): null;
65
79
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -6,13 +6,16 @@ import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
7
7
  import { ContainerContext, LayersContext } from './context.js';
8
8
  import { useSlotKey } from './use-slot-key.js';
9
+ /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
10
+ * constant array and the layer entry isn't rebuilt every render. */
11
+ const NO_BREAKS = [];
9
12
  /**
10
13
  * A line draw layer. Reads `column` from `series` into a {@link ChartSeries}
11
14
  * (columnar, gaps as NaN), registers itself into the enclosing {@link Layers}
12
15
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
13
16
  * it. The line breaks at gaps rather than spanning them.
14
17
  */
15
- export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, index = 0, }) {
18
+ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, index = 0, }) {
16
19
  const container = useContext(ContainerContext);
17
20
  if (container === null) {
18
21
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -33,6 +36,17 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
33
36
  // Faintness of the inferred dashed connectors (dashed / step) — theme-level,
34
37
  // falling back to the shared default so a theme without it still renders faint.
35
38
  const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
39
+ // Trading-axis session breaks: the collapse instants inside this series' span
40
+ // (session/day/lunch opens the axis skips). Data instants, not pixels — so the
41
+ // set is view-independent (pan/zoom reuse it). Only computed when opted in and
42
+ // the container carries a boundary-reporting discontinuity provider.
43
+ const sessionBreakInstants = useMemo(() => {
44
+ const provider = container.discontinuities;
45
+ if (!sessionBreaks || provider?.boundaries === undefined || cs.length < 2) {
46
+ return NO_BREAKS;
47
+ }
48
+ return provider.boundaries(cs.x[0], cs.x[cs.length - 1]);
49
+ }, [sessionBreaks, container.discontinuities, cs]);
36
50
  const entry = useMemo(() => ({
37
51
  layer: {
38
52
  yExtent: () => yExtent(cs),
@@ -67,7 +81,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
67
81
  ? [{ x: e.begin(), value: v, color: style.color, label }]
68
82
  : [];
69
83
  },
70
- draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity),
84
+ draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants),
71
85
  },
72
86
  axisId: axis,
73
87
  index,
@@ -80,6 +94,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
80
94
  curveFactory,
81
95
  gaps,
82
96
  gapConnectorOpacity,
97
+ sessionBreakInstants,
83
98
  axis,
84
99
  index,
85
100
  ]);
package/dist/XAxis.js CHANGED
@@ -7,7 +7,8 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
7
7
  const TICK_STRIP = 22;
8
8
  /** Extra height reserved for an axis `label` line. */
9
9
  const LABEL_STRIP = 16;
10
- const TICK_COUNT = 5;
10
+ /** Extra height reserved for the boundary (second) label row. */
11
+ const BOUNDARY_STRIP = 15;
11
12
  /**
12
13
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
13
14
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -53,7 +54,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
53
54
  if (container === null) {
54
55
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
55
56
  }
56
- const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
57
+ // `xTickCount` is the container's shared x-side count the same value the x
58
+ // gridlines and `formatTime` use, so labels and grid stay on the same instants
59
+ // (width-derived on a trading-time axis).
60
+ const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
57
61
  // The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
58
62
  // cursor is live in-bounds, pin the hovered time to this axis (covering the
59
63
  // tick behind it), matching the on-axis y value pills the rows draw. Gated on
@@ -76,8 +80,8 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
76
80
  format === undefined || xKind === 'category'
77
81
  ? formatTime
78
82
  : xKind === 'time'
79
- ? resolveTimeFormat(xScale, TICK_COUNT, format)
80
- : resolveAxisFormat(xScale, TICK_COUNT, format);
83
+ ? resolveTimeFormat(xScale, xTickCount, format)
84
+ : resolveAxisFormat(xScale, xTickCount, format);
81
85
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
82
86
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
83
87
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -124,11 +128,25 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
124
128
  markerLanes.set(t.id, lane);
125
129
  }
126
130
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
131
+ // The boundary (second) label row — the coarser calendar unit the first-row
132
+ // label omits (the year under day / week / month ticks, the date under
133
+ // clock ticks), placed under the first tick of each new
134
+ // period. Only a ladder-driven time scale supplies it; explicit `ticks`, an
135
+ // explicit axis `format`, and a container-level `timeFormat` all opt out (a
136
+ // custom format owns the whole label, and custom ticks have no grain).
137
+ const boundaryOf = xKind === 'time' &&
138
+ customTicks === undefined &&
139
+ format === undefined &&
140
+ !container.xFormatCustom &&
141
+ 'tickBoundaries' in xScale
142
+ ? xScale.tickBoundaries(xTickCount)
143
+ : undefined;
127
144
  const rawTicks = customTicks
128
145
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
129
- : xScale.ticks(TICK_COUNT).map((d) => ({
146
+ : xScale.ticks(xTickCount).map((d) => ({
130
147
  x: xScale(d),
131
148
  label: fmt(+d),
149
+ boundary: boundaryOf?.(+d),
132
150
  }));
133
151
  // A category axis ticks once per category; thin + truncate its labels when they
134
152
  // crowd (an explicit `customTicks` axis keeps its labels verbatim).
@@ -141,7 +159,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
141
159
  const pillOffset = align === 'right' ? 2 : 6;
142
160
  // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
143
161
  const PILL_LANE_H = theme.font.size + 6;
162
+ // Any boundary label in view grows the strip by one row (like pill lanes do).
163
+ const hasBoundary = placed.some((t) => t.boundary !== undefined);
144
164
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
165
+ (hasBoundary ? BOUNDARY_STRIP : 0) +
145
166
  maxPillLane * PILL_LANE_H;
146
167
  return (_jsxs("div", { style: {
147
168
  position: 'relative',
@@ -183,7 +204,14 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
183
204
  [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
184
205
  transform: labelTransform,
185
206
  whiteSpace: 'nowrap',
186
- }, children: t.label })] }, `${t.x}-${i}`));
207
+ }, children: t.label }), t.boundary !== undefined && (_jsx("div", { "data-boundary-label": true, style: {
208
+ position: 'absolute',
209
+ left: `${labelLeft}px`,
210
+ [onTop ? 'bottom' : 'top']: `${labelOffset + theme.font.size + 3}px`,
211
+ transform: labelTransform,
212
+ whiteSpace: 'nowrap',
213
+ opacity: 0.75,
214
+ }, children: t.boundary }))] }, `${t.x}-${i}`));
187
215
  }), label !== undefined && (_jsx("div", { style: {
188
216
  position: 'absolute',
189
217
  left: 0,
package/dist/context.d.ts CHANGED
@@ -151,6 +151,21 @@ export interface ContainerFrame {
151
151
  /** Format an epoch-ms instant the same way the time axis labels its ticks —
152
152
  * shared by `<TimeAxis>` and the cursor-time readout. */
153
153
  readonly formatTime: (epochMs: number) => string;
154
+ /** Whether an explicit container `timeFormat` shaped {@link formatTime}. The
155
+ * x axis suppresses its boundary (second) label row when it's set — a
156
+ * custom format owns the whole label, so the ladder mustn't second-line it. */
157
+ readonly xFormatCustom: boolean;
158
+ /**
159
+ * The shared **x-side tick count** — the `count` every x-side `ticks()` /
160
+ * `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
161
+ * session dividers, {@link formatTime}), so labels, grid, and dividers all
162
+ * derive from the same instants. A fixed default on a continuous axis;
163
+ * **width-derived on a trading-time axis**, where the count caps how many
164
+ * calendar buckets `coarsenCalendar` may keep — a fixed small count would
165
+ * coarsen any long daily view to year grain (2 ticks) no matter how wide
166
+ * the plot is.
167
+ */
168
+ readonly xTickCount: number;
154
169
  /**
155
170
  * Register a draw layer as a tracker source so the container can fan in every
156
171
  * series' value at the cursor for `onTrackerChanged`. Keyed by the layer's
package/dist/format.d.ts CHANGED
@@ -42,8 +42,10 @@ interface TimeTickable {
42
42
  * - a **function** → used as-is (called with epoch ms);
43
43
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
44
44
  * every value), wrapped to take epoch ms;
45
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
46
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
45
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
46
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
47
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
48
+ * axis's count here is what keeps the labels on the same grain as the ticks.
47
49
  *
48
50
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
49
51
  * formatter in `new Date(ms)`.
package/dist/format.js CHANGED
@@ -32,8 +32,10 @@ export function resolveAxisFormat(scale, count, format) {
32
32
  * - a **function** → used as-is (called with epoch ms);
33
33
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
34
34
  * every value), wrapped to take epoch ms;
35
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
36
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
35
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
36
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
37
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
38
+ * axis's count here is what keeps the labels on the same grain as the ticks.
37
39
  *
38
40
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
39
41
  * formatter in `new Date(ms)`.
@@ -41,7 +43,9 @@ export function resolveAxisFormat(scale, count, format) {
41
43
  export function resolveTimeFormat(scale, count, format) {
42
44
  if (typeof format === 'function')
43
45
  return format;
44
- const tf = format !== undefined ? scale.tickFormat(count, format) : scale.tickFormat();
46
+ const tf = format !== undefined
47
+ ? scale.tickFormat(count, format)
48
+ : scale.tickFormat(count);
45
49
  return (ms) => tf(new Date(ms));
46
50
  }
47
51
  //# sourceMappingURL=format.js.map
package/dist/line.d.ts CHANGED
@@ -41,6 +41,30 @@ export declare function yExtent(cs: ChartSeries): [number, number] | null;
41
41
  * The generator writes path ops to `ctx`; we bracket with `beginPath`/`stroke`.
42
42
  * `cs.y` (a `Float64Array`) is the datum iterable — `y` reads the value, `x`
43
43
  * reads `cs.x[i]` by index, so there's no per-point object allocation.
44
+ *
45
+ * **`boundaries`** (default none) are discontinuity instants — a trading-axis
46
+ * session/day/lunch close→open where the line should *break* even though a data
47
+ * point sits on each side (see {@link sessionRuns}). Each run between boundaries
48
+ * draws as its own subpath, so the line ends at the last pre-boundary point and
49
+ * re-starts at the first post-boundary one — a **scale** break, orthogonal to
50
+ * the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
51
+ * output is identical to a single-pass draw.
52
+ */
53
+ export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, boundaries?: readonly number[]): void;
54
+ /**
55
+ * Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
56
+ * cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
57
+ * discontinuity (a trading session / day / lunch close→open) sits between two
58
+ * consecutive points. A point that lands exactly on a boundary starts the new
59
+ * run (the open). No boundary inside the data (or an empty list) ⇒ a single run
60
+ * over the whole series. This is what turns `<LineChart sessionBreaks>` into a
61
+ * per-session polyline. Pure + O(N).
62
+ *
63
+ * The sweep relies on **ascending** boundaries; the `DiscontinuityProvider`
64
+ * contract doesn't guarantee order, so an unsorted list is sorted defensively
65
+ * (a copy, so the caller's array isn't mutated) rather than silently dropping a
66
+ * break. The list is tiny — one entry per session boundary — so the sort is
67
+ * negligible next to the row sweep.
44
68
  */
45
- export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number): void;
69
+ export declare function sessionRuns(x: Float64Array, length: number, boundaries: readonly number[]): Array<[number, number]>;
46
70
  //# sourceMappingURL=line.d.ts.map