@pond-ts/charts 0.68.0 → 0.70.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.
@@ -27,6 +27,23 @@ export interface BandChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
27
27
  * Denoise the underlying values with `smooth()`, not this.
28
28
  */
29
29
  curve?: Curve;
30
+ /**
31
+ * Break the envelope at each **trading-axis discontinuity** (a session / day /
32
+ * lunch close→open) when the container renders on a trading-time axis (a
33
+ * `discontinuities` / `calendar` provider). **Omitted ⇒ `false`**: the fill
34
+ * connects the last pre-close sample straight to the next open across the
35
+ * collapsed gap (a near-vertical sliver). `true` ends the fill at the close
36
+ * and re-starts it at the open — the intraday look, where one session's
37
+ * envelope shouldn't visually flow into the next. Same semantics as
38
+ * {@link LineChart}'s `sessionBreaks`, so a band and its centre line break in
39
+ * step.
40
+ *
41
+ * This is a **scale** break (driven by the axis's collapsed gaps), orthogonal
42
+ * to a **data** break (a NaN run on either edge, which a band always breaks
43
+ * at). A no-op on a continuous axis (no provider) or a provider without
44
+ * `boundaries`.
45
+ */
46
+ sessionBreaks?: boolean;
30
47
  /**
31
48
  * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
32
49
  * once the visible envelope is denser than ~2 samples per device pixel, it is
@@ -34,6 +51,8 @@ export interface BandChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
34
51
  * the samples span, so it covers the same pixels from O(plot width) points.
35
52
  * Applies with a linear `curve`; pass `false` to always fill every sample, or
36
53
  * `{ threshold }` to tune. Shares {@link LineChart}'s `DecimateOption`.
54
+ * Composes with {@link sessionBreaks}: the break instants are folded into the
55
+ * pixel-column edges so no column merges two sessions' envelopes.
37
56
  */
38
57
  decimate?: DecimateOption;
39
58
  /**
@@ -92,6 +111,6 @@ export type BandChartProps<S extends SeriesSchema = SeriesSchema, VS extends Val
92
111
  * </Layers>
93
112
  * ```
94
113
  */
95
- export declare function BandChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, upper, as: semantic, axis, curve, decimate, legend, index, }: BandChartProps<S, VS>): null;
114
+ export declare function BandChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, upper, as: semantic, axis, curve, sessionBreaks, decimate, legend, index, }: BandChartProps<S, VS>): null;
96
115
  export {};
97
116
  //# sourceMappingURL=BandChart.d.ts.map
package/dist/BandChart.js CHANGED
@@ -6,6 +6,9 @@ import { resolveCurve } from './curve.js';
6
6
  import { ContainerContext, LayersContext } from './context.js';
7
7
  import { legendLabelFor, useLegendItems, } from './swatch.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 variance-band draw layer: fills the envelope between the `lower` and `upper`
11
14
  * columns of `series` (typically `rollingByColumn` percentiles), gap-aware, and
@@ -23,7 +26,7 @@ import { useSlotKey } from './use-slot-key.js';
23
26
  * </Layers>
24
27
  * ```
25
28
  */
26
- export function BandChart({ series, lower, upper, as: semantic, axis, curve, decimate = true, legend, index = 0, }) {
29
+ export function BandChart({ series, lower, upper, as: semantic, axis, curve, sessionBreaks = false, decimate = true, legend, index = 0, }) {
27
30
  const container = useContext(ContainerContext);
28
31
  if (container === null) {
29
32
  throw new Error('<BandChart> must be rendered inside a <ChartContainer>');
@@ -35,6 +38,18 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
35
38
  const bs = useMemo(() => series instanceof ValueSeries
36
39
  ? bandFromValueSeries(series, lower, upper)
37
40
  : bandFromTimeSeries(series, lower, upper), [series, lower, upper]);
41
+ // Trading-axis session breaks: the collapse instants inside this band's span
42
+ // (session/day/lunch opens the axis skips). Data instants, not pixels — so the
43
+ // set is view-independent (pan/zoom reuse it). Only computed when opted in and
44
+ // the container carries a boundary-reporting discontinuity provider. The same
45
+ // lookup `<LineChart>` does, so a band and its centre line break identically.
46
+ const sessionBreakInstants = useMemo(() => {
47
+ const provider = container.discontinuities;
48
+ if (!sessionBreaks || provider?.boundaries === undefined || bs.length < 2) {
49
+ return NO_BREAKS;
50
+ }
51
+ return provider.boundaries(bs.x[0], bs.x[bs.length - 1]);
52
+ }, [sessionBreaks, container.discontinuities, bs]);
38
53
  // Styling: semantic identifier → theme band style. The single styling channel.
39
54
  const { band } = container.theme;
40
55
  const style = (semantic !== undefined ? band[semantic] : undefined) ?? band.default;
@@ -118,7 +133,7 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
118
133
  },
119
134
  ];
120
135
  },
121
- draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory, decimate),
136
+ draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory, sessionBreakInstants, decimate),
122
137
  },
123
138
  axisId: axis,
124
139
  index,
@@ -129,6 +144,7 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
129
144
  upper,
130
145
  style,
131
146
  curveFactory,
147
+ sessionBreakInstants,
132
148
  decimate,
133
149
  axis,
134
150
  index,
@@ -192,6 +192,29 @@ export interface ChartContainerProps {
192
192
  * calendar reference (build it once, not inline in JSX).
193
193
  */
194
194
  calendar?: TradingCalendarLike;
195
+ /**
196
+ * The IANA **time zone the time axis renders in** — ticks land on that
197
+ * zone's midnights / Mondays / month starts, labels, grid, date bands,
198
+ * session dividers and every cursor / marker readout read in it.
199
+ * **Omitted ⇒ the viewer's own zone** (the runtime's), which is what every
200
+ * chart did before this prop existed; `'UTC'` or any id `Intl` knows
201
+ * (`'Europe/Berlin'`, `'Australia/Sydney'`, …) names one. A trading
202
+ * {@link calendar} that carries a `timeZone` (a `@pond-ts/financial`
203
+ * `TradingCalendar.fromRules`) supplies the default, so a NYSE chart reads
204
+ * New York time wherever it is viewed; an explicit prop still wins. The
205
+ * calendar's zone is used even when a low-level {@link discontinuities}
206
+ * provider overrides its gap topology — the calendar still says which
207
+ * exchange this is.
208
+ *
209
+ * Pair it with the aggregate that produced the data — the same primitive
210
+ * (`TimeZone`) places these ticks and cuts `Sequence.calendar` buckets, so
211
+ * `Sequence.calendar('day', { timeZone })` and `<ChartContainer timeZone>`
212
+ * given the same zone put a bucket edge and its tick on one instant.
213
+ * Function formatters (`timeFormat`, `cursorFormat`) still receive epoch ms;
214
+ * read the resolved zone from the chart context. An unknown id throws
215
+ * `RangeError`. Only affects a **time** axis.
216
+ */
217
+ timeZone?: string | undefined;
195
218
  /**
196
219
  * The trading axis **metric**, when a {@link calendar} is supplied
197
220
  * (trading-calendar RFC Q7). `'proportional'` (default) keeps time
@@ -4,7 +4,7 @@ import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
4
4
  import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
6
  import { scaleElapsed } from './elapsed.js';
7
- import { Sequence } from 'pond-ts';
7
+ import { Sequence, TimeZone } from 'pond-ts';
8
8
  import { ContainerContext, CursorContext, } from './context.js';
9
9
  import { LegacyCursor, legacyCursorWarning, presetNameFor, warnOnDuplicateGestureOwners, } from './cursors.js';
10
10
  import { effectiveSelectorEntries, resolveControlledHovered, resolveControlledSelected, selectorEntryEqual, warnInertClick, } from './selectors.js';
@@ -198,7 +198,7 @@ function AutoSizeContainer(props) {
198
198
  * pass; a chart legitimately gated this long is not painting anyway. */
199
199
  const ZERO_SIZE_WARNING_MS = 600;
200
200
  /** {@link ChartContainer} with its width resolved to a concrete pixel number. */
201
- function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, height, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, axisPanZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
201
+ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, height, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, axisPanZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, timeZone: timeZoneProp, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
202
202
  // ── Legacy cursor props (deprecated) ───────────────────────────────────────
203
203
  // The string surface keeps working for one minor: the resolved mode is
204
204
  // synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
@@ -967,6 +967,13 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
967
967
  ? calendar.discontinuities(spacing ? { spacing } : undefined)
968
968
  : undefined, [resolvedKind, discontinuities, calendar, spacing]);
969
969
  const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
970
+ // The axis zone: the explicit prop, else the calendar's exchange zone, else
971
+ // runtime-local (`undefined`). Canonicalised through `TimeZone.of` so a bad
972
+ // id fails here, once, with its name, and so `'utc'` and `'UTC'` are one key.
973
+ const timeZone = useMemo(() => {
974
+ const id = timeZoneProp ?? calendar?.timeZone;
975
+ return id === undefined ? undefined : TimeZone.of(id).id;
976
+ }, [timeZoneProp, calendar]);
970
977
  // The shared x-side tick count — labels, x gridlines, session dividers, and
971
978
  // `formatTime` all pass this one value, so they derive from the same instants
972
979
  // (the alignment previously held by three hardcoded constants agreeing).
@@ -1147,7 +1154,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1147
1154
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
1148
1155
  // `xTickCount` reaches `tickFormat` too: the trading scale picks its anchor
1149
1156
  // grain from the count, so labels sit on the exact instants the ticks do.
1150
- const s = scaleTradingTime(xDiscontinuities)
1157
+ const s = scaleTradingTime(xDiscontinuities, { timeZone })
1151
1158
  .domain([d0, d1])
1152
1159
  .range([0, plotWidth]);
1153
1160
  if (elapsedOrigin !== undefined)
@@ -1164,7 +1171,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1164
1171
  // never d3's mixed multi-scale default. Interactions stay on continuous
1165
1172
  // time math: the frame's `discontinuities` remains undefined, and identity
1166
1173
  // distance/offset are plain subtraction/addition anyway.
1167
- const s = scaleTradingTime(identityProvider())
1174
+ const s = scaleTradingTime(identityProvider({ timeZone }), { timeZone })
1168
1175
  .domain([d0, d1])
1169
1176
  .range([0, plotWidth]);
1170
1177
  if (elapsedOrigin !== undefined)
@@ -1187,6 +1194,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1187
1194
  elapsedOrigin,
1188
1195
  xDiscontinuities,
1189
1196
  xTickCount,
1197
+ timeZone,
1190
1198
  ]);
1191
1199
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
1192
1200
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
@@ -1361,6 +1369,8 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1361
1369
  onEditAnnotation,
1362
1370
  formatTime,
1363
1371
  formatReadout,
1372
+ timeZone,
1373
+ timeFormat,
1364
1374
  xFormatCustom: timeFormat !== undefined,
1365
1375
  xReadoutCustom: cursorFormat !== undefined,
1366
1376
  xTickCount,
@@ -1445,6 +1455,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1445
1455
  onEditAnnotation,
1446
1456
  formatTime,
1447
1457
  formatReadout,
1458
+ timeZone,
1448
1459
  timeFormat,
1449
1460
  cursorFormat,
1450
1461
  xTickCount,
package/dist/XAxis.d.ts CHANGED
@@ -90,6 +90,18 @@ export interface XAxisProps {
90
90
  * measured fit (thin + middle-ellipsize) is what prevents collisions.
91
91
  */
92
92
  align?: 'auto' | 'center' | 'right';
93
+ /**
94
+ * Render **this strip** in an IANA zone other than the container's — the
95
+ * second axis of a two-zone pair (`<XAxis />` in the container's zone below
96
+ * the plot, `<XAxis side="top" timeZone="Asia/Tokyo" />` above it). Same
97
+ * pixel mapping, its own calendar: day ticks on *this* zone's midnights,
98
+ * labels, the date bands and this strip's cursor / marker pills reading in
99
+ * it. Time axis only; ignored under a `transform` or explicit `ticks`. A
100
+ * container `cursorFormat` still wins for the pill (it is its own channel);
101
+ * a container `timeFormat` string is re-resolved in this zone. Omit to
102
+ * follow the container's `timeZone` (or the viewer's zone).
103
+ */
104
+ timeZone?: string | undefined;
93
105
  /**
94
106
  * How a **time** axis lays out its date context (ignored on value / category
95
107
  * axes, and whenever a custom `format`, `transform`, or explicit `ticks`
@@ -140,6 +152,6 @@ export interface XAxisProps {
140
152
  * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
141
153
  * A category axis has no continuous domain and stays inert.
142
154
  */
143
- export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
155
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, timeZone, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
144
156
  export {};
145
157
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useContext, useRef } from 'react';
2
+ import { Fragment, useContext, useMemo, useRef } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
+ import { TimeZone } from 'pond-ts';
4
5
  import { derivedTicks } from './derivedTicks.js';
5
6
  import { ContainerContext, CursorContext, } from './context.js';
6
7
  import { tickValues } from './yticks.js';
@@ -166,7 +167,7 @@ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily)
166
167
  * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
167
168
  * A category axis has no continuous domain and stays inert.
168
169
  */
169
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
170
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', timeZone, onMouseEvent, } = {}) {
170
171
  const container = useContext(ContainerContext);
171
172
  if (container === null) {
172
173
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -175,7 +176,38 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
175
176
  // `xTickCount` is the container's shared x-side count — the same value the x
176
177
  // gridlines and `formatTime` use, so labels and grid stay on the same instants
177
178
  // (width-derived on a trading-time axis).
178
- const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
179
+ const { xScale: containerScale, plotWidth, leftGutter, theme, formatTime: containerFormatTime, xKind, xTickCount, } = container;
180
+ // A per-strip zone: the container's shared scale re-derived with its
181
+ // calendar in `timeZone` — identical pixel mapping, so every `xScale(v)`
182
+ // below lands where the plot puts it, but ticks / labels / bands / this
183
+ // strip's pills come from this zone's ladder. Canonicalised (and validated)
184
+ // so an unknown id throws by name and `'utc'` / `'UTC'` memoize as one.
185
+ const zonedScale = useMemo(() => {
186
+ if (timeZone === undefined ||
187
+ xKind !== 'time' ||
188
+ !('withTimeZone' in containerScale)) {
189
+ return undefined;
190
+ }
191
+ const id = TimeZone.of(timeZone).id;
192
+ const s = containerScale;
193
+ return s.timeZone() === id ? undefined : s.withTimeZone(id);
194
+ }, [containerScale, timeZone, xKind]);
195
+ const xScale = zonedScale ?? containerScale;
196
+ // The label formatter this strip falls back to when no explicit `format`
197
+ // shapes it. The container's `formatTime` was resolved in the container's
198
+ // zone; a zoned strip needs the same channel in its own — the grain-aware
199
+ // default, or the container's `timeFormat` specifier re-resolved (a
200
+ // function `timeFormat` receives epoch ms and is used verbatim either way).
201
+ const formatTime = useMemo(() => {
202
+ if (zonedScale === undefined)
203
+ return containerFormatTime;
204
+ const custom = container.timeFormat;
205
+ if (typeof custom === 'function')
206
+ return custom;
207
+ return custom === undefined
208
+ ? zonedScale.readoutFormat(xTickCount)
209
+ : resolveTimeFormat(zonedScale, xTickCount, custom);
210
+ }, [zonedScale, containerFormatTime, container.timeFormat, xTickCount]);
179
211
  // The cursor's x-axis slot: did the mounted cursor in effect register one
180
212
  // (`renderXAxis` — the crosshair's time pill)? While hovering, that is the
181
213
  // **hovered row's** effective cursor — so a per-row override reaches this
package/dist/band.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { BandSeries } from './data.js';
3
- import type { Scale } from './line.js';
3
+ import { type Scale } from './line.js';
4
4
  import type { BandStyle } from './theme.js';
5
5
  import type { LayerDrawStats } from './context.js';
6
6
  import { type DecimateOption } from './decimate.js';
@@ -25,9 +25,17 @@ export declare function bandExtent(band: BandSeries): [number, number] | null;
25
25
  * filled envelope's break wants its own treatment (sharp edge vs. blurred),
26
26
  * still to be designed; for now a band always breaks honestly at a gap.
27
27
  *
28
+ * `boundaries` are trading-axis **session-break** instants (`<BandChart
29
+ * sessionBreaks>`): the envelope is split into per-session runs wherever one
30
+ * falls between two consecutive samples (see {@link sessionRuns}), each run its
31
+ * own closed subpath, so the fill ends at the last pre-close sample and re-starts
32
+ * at the first post-open one — a **scale** break, orthogonal to the NaN **data**
33
+ * gaps handled within each run. With no boundaries the output is identical to a
34
+ * single-pass draw. Mirrors `drawLine`'s treatment exactly.
35
+ *
28
36
  * `band.lower` (a `Float64Array`) is the datum iterable; every accessor reads by
29
37
  * index, so there's no per-point object allocation. `globalAlpha` carries the
30
38
  * opacity and is restored so it doesn't leak into later layers.
31
39
  */
32
- export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): LayerDrawStats;
40
+ export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, boundaries?: readonly number[], decimate?: DecimateOption): LayerDrawStats;
33
41
  //# sourceMappingURL=band.d.ts.map
package/dist/band.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
+ import { sessionRuns } from './line.js';
2
3
  import { cullBandSeries } from './culling.js';
3
4
  import { decimateBand } from './decimate.js';
4
5
  import { gapUnscalable } from './gaps.js';
6
+ /** Shared empty boundary list — passed to `sessionRuns` when a decimated band
7
+ * already carries its session breaks as baked-in `NaN` samples. */
8
+ const EMPTY_BOUNDARIES = [];
5
9
  /**
6
10
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
7
11
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -37,11 +41,19 @@ export function bandExtent(band) {
37
41
  * filled envelope's break wants its own treatment (sharp edge vs. blurred),
38
42
  * still to be designed; for now a band always breaks honestly at a gap.
39
43
  *
44
+ * `boundaries` are trading-axis **session-break** instants (`<BandChart
45
+ * sessionBreaks>`): the envelope is split into per-session runs wherever one
46
+ * falls between two consecutive samples (see {@link sessionRuns}), each run its
47
+ * own closed subpath, so the fill ends at the last pre-close sample and re-starts
48
+ * at the first post-open one — a **scale** break, orthogonal to the NaN **data**
49
+ * gaps handled within each run. With no boundaries the output is identical to a
50
+ * single-pass draw. Mirrors `drawLine`'s treatment exactly.
51
+ *
40
52
  * `band.lower` (a `Float64Array`) is the datum iterable; every accessor reads by
41
53
  * index, so there's no per-point object allocation. `globalAlpha` carries the
42
54
  * opacity and is restored so it doesn't leak into later layers.
43
55
  */
44
- export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
56
+ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, boundaries = [], decimate = true) {
45
57
  const sourceCount = band.length; // pre-cull, pre-decimation (for draw stats)
46
58
  // Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
47
59
  // side) before filling, so a pan strokes O(visible). The solid fill has no
@@ -54,11 +66,13 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
54
66
  // pixels. Gated off a smoothing `curve` (which would distort the per-column
55
67
  // envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
56
68
  // envelope or a domainless test scale, so this stays byte-identical there.
69
+ // The session-break instants ride along so a column never straddles a break
70
+ // and the decimated envelope carries the breaks as baked-in NaN samples.
57
71
  let decimated = false;
58
72
  if (decimate !== false && curve === curveLinear) {
59
73
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
60
74
  const before = band;
61
- band = decimateBand(band, xScale, ctx, k);
75
+ band = decimateBand(band, xScale, ctx, k, boundaries);
62
76
  decimated = band !== before;
63
77
  }
64
78
  // An edge with no position on the y scale becomes an ordinary NaN gap, so the
@@ -73,18 +87,32 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
73
87
  if (gapLower !== band.lower || gapUpper !== band.upper) {
74
88
  band = { ...band, lower: gapLower, upper: gapUpper };
75
89
  }
76
- const gen = d3area()
77
- .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
78
- .x((_, i) => xScale(band.x[i]))
79
- .y0((_, i) => yScale(band.lower[i]))
80
- .y1((_, i) => yScale(band.upper[i]))
81
- .curve(curve)
82
- .context(ctx);
90
+ // Split into independent index runs at each session break; no boundary inside
91
+ // the data ⇒ one run over the whole envelope (the hot path — no slicing, so the
92
+ // draw is byte-identical to the pre-boundary single pass). When the band was
93
+ // decimated, `decimateBand` already baked the breaks in as NaN samples aligned
94
+ // to the break instants, so re-cutting here would mis-attribute the boundary
95
+ // samples — pass `[]` and let the baked-in breaks split the sessions.
96
+ const runs = sessionRuns(band.x, band.length, decimated ? EMPTY_BOUNDARIES : boundaries);
97
+ const singleRun = runs.length === 1;
83
98
  ctx.save();
84
99
  ctx.fillStyle = style.fill;
85
100
  ctx.globalAlpha = style.opacity;
101
+ // One path across every run. Each run's generator opens with its own moveTo
102
+ // (and closes its own polygon), so a run boundary is a clean pen-up — the
103
+ // session break — and a single fill covers them all.
86
104
  ctx.beginPath();
87
- gen(band.lower);
105
+ for (const [s, e] of runs) {
106
+ const gen = d3area()
107
+ .defined((_, j) => Number.isFinite(band.lower[s + j]) &&
108
+ Number.isFinite(band.upper[s + j]))
109
+ .x((_, j) => xScale(band.x[s + j]))
110
+ .y0((_, j) => yScale(band.lower[s + j]))
111
+ .y1((_, j) => yScale(band.upper[s + j]))
112
+ .curve(curve)
113
+ .context(ctx);
114
+ gen(singleRun ? band.lower : band.lower.subarray(s, e));
115
+ }
88
116
  ctx.fill();
89
117
  ctx.restore();
90
118
  return { sourceCount, drawnCount: band.length, decimated };
package/dist/context.d.ts CHANGED
@@ -270,6 +270,20 @@ export interface ContainerFrame {
270
270
  * tick labels) without moving them.
271
271
  */
272
272
  readonly formatReadout?: ((value: number) => string) | undefined;
273
+ /**
274
+ * The IANA zone the time axis renders in — the container's resolved
275
+ * `timeZone` (explicit prop, else the calendar's), canonical id; `undefined`
276
+ * when the axis is in the runtime's local zone. For a consumer's own
277
+ * formatter (`timeFormat` / `cursorFormat` functions receive epoch ms) to
278
+ * read the same zone the ticks do.
279
+ */
280
+ readonly timeZone: string | undefined;
281
+ /**
282
+ * The container's raw `timeFormat` prop, for a strip that must re-resolve it
283
+ * in another zone (`<XAxis timeZone>`): a specifier string is re-resolved
284
+ * against that strip's zoned scale, a function is used verbatim.
285
+ */
286
+ readonly timeFormat: AxisFormat | undefined;
273
287
  /** Whether an explicit container `timeFormat` shaped {@link formatTime}. The
274
288
  * x axis suppresses its boundary (second) label row when it's set — a
275
289
  * custom format owns the whole label, so the ladder mustn't second-line it. */
@@ -224,8 +224,17 @@ export declare function m4Polyline(edges: Float64Array, mn: Float64Array, mx: Fl
224
224
  * `upper` are finite **together** per sample (the paired-percentile shape bands
225
225
  * are built from); a column where only one edge has finite samples would bin a
226
226
  * band segment that no single sample carried.
227
+ *
228
+ * `boundaries` are trading-axis session-break instants (`<BandChart
229
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
230
+ * in-domain instant is unioned into the bucket edges so no column merges two
231
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
232
+ * emitted at the instant so the fill ends at the close and re-starts at the
233
+ * open — otherwise the closing and opening columns would sit as adjacent finite
234
+ * samples and the envelope would flow straight across the collapsed gap. The
235
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
227
236
  */
228
- export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number): BandSeries;
237
+ export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): BandSeries;
229
238
  /**
230
239
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
231
240
  * column** — `open = first`, `high = max`, `low = min`, `close = last` over the
package/dist/decimate.js CHANGED
@@ -400,8 +400,17 @@ export function m4Polyline(edges, mn, mx, first, last, W, breakAt = NO_BREAKS) {
400
400
  * `upper` are finite **together** per sample (the paired-percentile shape bands
401
401
  * are built from); a column where only one edge has finite samples would bin a
402
402
  * band segment that no single sample carried.
403
+ *
404
+ * `boundaries` are trading-axis session-break instants (`<BandChart
405
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
406
+ * in-domain instant is unioned into the bucket edges so no column merges two
407
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
408
+ * emitted at the instant so the fill ends at the close and re-starts at the
409
+ * open — otherwise the closing and opening columns would sit as adjacent finite
410
+ * samples and the envelope would flow straight across the collapsed gap. The
411
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
403
412
  */
404
- export function decimateBand(band, xScale, ctx, k = 2) {
413
+ export function decimateBand(band, xScale, ctx, k = 2, boundaries = []) {
405
414
  if (!shouldDecimateCount(band.length, ctx, k))
406
415
  return band;
407
416
  const dom = scaleDomain(xScale);
@@ -412,18 +421,50 @@ export function decimateBand(band, xScale, ctx, k = 2) {
412
421
  if (invert === null || plotWidthCss === null)
413
422
  return band;
414
423
  const W = deviceBucketCount(ctx);
415
- const edges = pixelEdges(invert, plotWidthCss, W);
424
+ const pixels = pixelEdges(invert, plotWidthCss, W);
425
+ // Session-break instants inside the visible domain — unioned into the edges
426
+ // (so a column never straddles a break) AND marked as explicit break samples.
427
+ // `mergeGapEdges` keeps their exact values, so the set matches the edges.
428
+ const breaks = boundaries.length > 0
429
+ ? boundaries.filter((b) => b > dom[0] && b < dom[1])
430
+ : [];
431
+ const edges = breaks.length > 0 ? mergeGapEdges(pixels, breaks, dom[0], dom[1]) : pixels;
432
+ const buckets = edges.length - 1;
416
433
  const lowerMin = new Float64Column(band.lower, band.length).binBy(band.x, edges, 'min');
417
434
  const upperMax = new Float64Column(band.upper, band.length).binBy(band.x, edges, 'max');
418
- const x = new Float64Array(W);
419
- const lower = new Float64Array(W);
420
- const upper = new Float64Array(W);
421
- for (let b = 0; b < W; b += 1) {
422
- x[b] = (edges[b] + edges[b + 1]) / 2; // column centre
423
- lower[b] = lowerMin[b]; // NaN on an empty column the fill break
424
- upper[b] = upperMax[b];
435
+ const breakAt = breaks.length > 0 ? new Set(breaks) : null;
436
+ // One sample per column + one NaN break slot per session break. `breaks` are
437
+ // strictly inside the domain and `mergeGapEdges` keeps every one, so each
438
+ // lands on some `edges[b]` with `b > 0` and the arrays fill exactly; the
439
+ // subarray trim below is a guard against a provider instant that sorts ahead
440
+ // of the first pixel edge under float rounding, not an expected path.
441
+ const cap = buckets + (breakAt === null ? 0 : breakAt.size);
442
+ const x = new Float64Array(cap);
443
+ const lower = new Float64Array(cap);
444
+ const upper = new Float64Array(cap);
445
+ let n = 0;
446
+ for (let b = 0; b < buckets; b += 1) {
447
+ // Explicit session break: this column opens a new session → end the fill
448
+ // first. A NaN on both edges is the band's own gap signal (`.defined`).
449
+ if (breakAt !== null && b > 0 && breakAt.has(edges[b])) {
450
+ x[n] = edges[b];
451
+ lower[n] = NaN;
452
+ upper[n] = NaN;
453
+ n += 1;
454
+ }
455
+ x[n] = (edges[b] + edges[b + 1]) / 2; // column centre
456
+ lower[n] = lowerMin[b]; // NaN on an empty column → the fill break
457
+ upper[n] = upperMax[b];
458
+ n += 1;
425
459
  }
426
- return { x, lower, upper, length: W };
460
+ return n === cap
461
+ ? { x, lower, upper, length: n }
462
+ : {
463
+ x: x.subarray(0, n),
464
+ lower: lower.subarray(0, n),
465
+ upper: upper.subarray(0, n),
466
+ length: n,
467
+ };
427
468
  }
428
469
  /**
429
470
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
package/dist/index.d.ts CHANGED
@@ -68,8 +68,8 @@ export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
68
68
  export { useChartFrame } from './useChartFrame.js';
69
69
  export type { ChartFrame, ChartFrameRow, ChartBands, ChartBand, } from './useChartFrame.js';
70
70
  export type { ChartXScale } from './context.js';
71
- export { scaleTradingTime } from './tradingTimeScale.js';
72
- export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
71
+ export { scaleTradingTime, identityProvider } from './tradingTimeScale.js';
72
+ export type { TradingTimeScale, DiscontinuityProvider, TradingCalendarLike, ScaleTimeZoneOptions, TimeGrain, } from './tradingTimeScale.js';
73
73
  export { scaleBand } from './bandScale.js';
74
74
  export type { ScaleBand } from './bandScale.js';
75
75
  export { Region, Baseline, Marker, Zone } from './annotations.js';
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ export { useChartLegend } from './useChartLegend.js';
58
58
  // by mirroring the library's own gutter arithmetic — a duplicate that drifts
59
59
  // silently the moment the library changes how a gutter is sized.
60
60
  export { useChartFrame } from './useChartFrame.js';
61
- export { scaleTradingTime } from './tradingTimeScale.js';
61
+ export { scaleTradingTime, identityProvider } from './tradingTimeScale.js';
62
62
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
63
63
  export { scaleBand } from './bandScale.js';
64
64
  // Annotations — user-authored marks in the turquoise register (distinct from the