@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.
- package/AGENTS.md +12 -3
- package/API.md +104 -100
- package/CHANGELOG.md +147 -1
- package/README.md +4 -0
- package/dist/BandChart.d.ts +20 -1
- package/dist/BandChart.js +18 -2
- package/dist/ChartContainer.d.ts +23 -0
- package/dist/ChartContainer.js +15 -4
- package/dist/XAxis.d.ts +13 -1
- package/dist/XAxis.js +35 -3
- package/dist/band.d.ts +10 -2
- package/dist/band.js +38 -10
- package/dist/context.d.ts +14 -0
- package/dist/decimate.d.ts +10 -1
- package/dist/decimate.js +51 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/tickLadder.d.ts +66 -20
- package/dist/tickLadder.js +178 -105
- package/dist/tradingAxis.fixture.d.ts +19 -0
- package/dist/tradingAxis.fixture.js +24 -0
- package/dist/tradingTimeScale.d.ts +44 -3
- package/dist/tradingTimeScale.js +113 -38
- package/package.json +6 -4
package/dist/BandChart.d.ts
CHANGED
|
@@ -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,
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -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
|
package/dist/ChartContainer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/decimate.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
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
|