@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 +117 -1
- package/dist/ChartContainer.js +42 -8
- package/dist/Layers.js +12 -7
- package/dist/LineChart.d.ts +15 -1
- package/dist/LineChart.js +17 -2
- package/dist/XAxis.js +34 -6
- package/dist/context.d.ts +15 -0
- package/dist/format.d.ts +4 -2
- package/dist/format.js +7 -3
- package/dist/line.d.ts +25 -1
- package/dist/line.js +78 -13
- package/dist/tickLadder.d.ts +89 -0
- package/dist/tickLadder.js +265 -0
- package/dist/tradingTimeScale.d.ts +29 -20
- package/dist/tradingTimeScale.js +80 -82
- package/package.json +3 -3
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.
|
|
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
|
package/dist/ChartContainer.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
15
|
-
*
|
|
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,
|
|
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,
|
|
374
|
+
formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
|
|
354
375
|
};
|
|
355
376
|
}
|
|
356
|
-
|
|
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,
|
|
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
|
-
/**
|
|
11
|
-
* `TICK_COUNT`, `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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(
|
|
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,
|
package/dist/LineChart.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
80
|
-
: resolveAxisFormat(xScale,
|
|
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(
|
|
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 })
|
|
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()` —
|
|
46
|
-
*
|
|
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()` —
|
|
36
|
-
*
|
|
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
|
|
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
|
|
69
|
+
export declare function sessionRuns(x: Float64Array, length: number, boundaries: readonly number[]): Array<[number, number]>;
|
|
46
70
|
//# sourceMappingURL=line.d.ts.map
|