@pond-ts/charts 0.44.1 → 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 +94 -1
- package/dist/ChartContainer.js +18 -7
- package/dist/LineChart.d.ts +15 -1
- package/dist/LineChart.js +17 -2
- package/dist/XAxis.js +27 -1
- package/dist/context.d.ts +4 -0
- 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 -26
- package/dist/tradingTimeScale.js +80 -88
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ 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
|
|
12
13
|
[0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
|
|
13
14
|
[0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
|
|
14
15
|
[0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
|
|
@@ -43,6 +44,98 @@ and type-level changes; patch bumps are strictly additive.
|
|
|
43
44
|
|
|
44
45
|
## [Unreleased]
|
|
45
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
|
+
|
|
46
139
|
## [0.44.1] — 2026-07-13
|
|
47
140
|
|
|
48
141
|
A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
|
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';
|
|
@@ -335,10 +335,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
335
335
|
// The shared x-side tick count — labels, x gridlines, session dividers, and
|
|
336
336
|
// `formatTime` all pass this one value, so they derive from the same instants
|
|
337
337
|
// (the alignment previously held by three hardcoded constants agreeing).
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// pre-layout zero width still
|
|
341
|
-
|
|
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'
|
|
342
343
|
? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
|
|
343
344
|
: TIME_TICK_COUNT;
|
|
344
345
|
const { xScale, formatTime } = useMemo(() => {
|
|
@@ -373,7 +374,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
373
374
|
formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
|
|
374
375
|
};
|
|
375
376
|
}
|
|
376
|
-
|
|
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]);
|
|
377
386
|
return {
|
|
378
387
|
xScale: s,
|
|
379
388
|
formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
|
|
@@ -499,6 +508,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
499
508
|
onHoverAnnotation,
|
|
500
509
|
onEditAnnotation,
|
|
501
510
|
formatTime,
|
|
511
|
+
xFormatCustom: timeFormat !== undefined,
|
|
502
512
|
xTickCount,
|
|
503
513
|
registerTrackerSource,
|
|
504
514
|
unregisterTrackerSource,
|
|
@@ -553,6 +563,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
553
563
|
onHoverAnnotation,
|
|
554
564
|
onEditAnnotation,
|
|
555
565
|
formatTime,
|
|
566
|
+
timeFormat,
|
|
556
567
|
xTickCount,
|
|
557
568
|
registerTrackerSource,
|
|
558
569
|
unregisterTrackerSource,
|
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,6 +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
|
+
/** Extra height reserved for the boundary (second) label row. */
|
|
11
|
+
const BOUNDARY_STRIP = 15;
|
|
10
12
|
/**
|
|
11
13
|
* Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
|
|
12
14
|
* every `stride`-th label (so a kept label has room), and ellipsize one that still
|
|
@@ -126,11 +128,25 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
126
128
|
markerLanes.set(t.id, lane);
|
|
127
129
|
}
|
|
128
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;
|
|
129
144
|
const rawTicks = customTicks
|
|
130
145
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
131
146
|
: xScale.ticks(xTickCount).map((d) => ({
|
|
132
147
|
x: xScale(d),
|
|
133
148
|
label: fmt(+d),
|
|
149
|
+
boundary: boundaryOf?.(+d),
|
|
134
150
|
}));
|
|
135
151
|
// A category axis ticks once per category; thin + truncate its labels when they
|
|
136
152
|
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
@@ -143,7 +159,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
143
159
|
const pillOffset = align === 'right' ? 2 : 6;
|
|
144
160
|
// Per-lane vertical step for stacked pills; grow the strip to fit the stack.
|
|
145
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);
|
|
146
164
|
const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
|
|
165
|
+
(hasBoundary ? BOUNDARY_STRIP : 0) +
|
|
147
166
|
maxPillLane * PILL_LANE_H;
|
|
148
167
|
return (_jsxs("div", { style: {
|
|
149
168
|
position: 'relative',
|
|
@@ -185,7 +204,14 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
185
204
|
[onTop ? 'bottom' : 'top']: `${labelOffset}px`,
|
|
186
205
|
transform: labelTransform,
|
|
187
206
|
whiteSpace: 'nowrap',
|
|
188
|
-
}, 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}`));
|
|
189
215
|
}), label !== undefined && (_jsx("div", { style: {
|
|
190
216
|
position: 'absolute',
|
|
191
217
|
left: 0,
|
package/dist/context.d.ts
CHANGED
|
@@ -151,6 +151,10 @@ 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;
|
|
154
158
|
/**
|
|
155
159
|
* The shared **x-side tick count** — the `count` every x-side `ticks()` /
|
|
156
160
|
* `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
|
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
|
package/dist/line.js
CHANGED
|
@@ -53,20 +53,39 @@ export function yExtent(cs) {
|
|
|
53
53
|
* The generator writes path ops to `ctx`; we bracket with `beginPath`/`stroke`.
|
|
54
54
|
* `cs.y` (a `Float64Array`) is the datum iterable — `y` reads the value, `x`
|
|
55
55
|
* reads `cs.x[i]` by index, so there's no per-point object allocation.
|
|
56
|
+
*
|
|
57
|
+
* **`boundaries`** (default none) are discontinuity instants — a trading-axis
|
|
58
|
+
* session/day/lunch close→open where the line should *break* even though a data
|
|
59
|
+
* point sits on each side (see {@link sessionRuns}). Each run between boundaries
|
|
60
|
+
* draws as its own subpath, so the line ends at the last pre-boundary point and
|
|
61
|
+
* re-starts at the first post-boundary one — a **scale** break, orthogonal to
|
|
62
|
+
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
63
|
+
* output is identical to a single-pass draw.
|
|
56
64
|
*/
|
|
57
|
-
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.y((v) => yScale(v))
|
|
66
|
-
.curve(curve)
|
|
67
|
-
.context(ctx);
|
|
65
|
+
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = []) {
|
|
66
|
+
// Split into independent index runs at each boundary; no boundary inside the
|
|
67
|
+
// data ⇒ one run over the whole series (the hot path — no slicing, so the draw
|
|
68
|
+
// is byte-identical to the pre-boundary single pass).
|
|
69
|
+
const runs = sessionRuns(cs.x, cs.length, boundaries);
|
|
70
|
+
const singleRun = runs.length === 1;
|
|
71
|
+
// Solid pass: one path across every run. Each run's generator opens with its
|
|
72
|
+
// own moveTo, so a run boundary is a clean pen-up — the session break.
|
|
68
73
|
ctx.beginPath();
|
|
69
|
-
|
|
74
|
+
for (const [s, e] of runs) {
|
|
75
|
+
// `none` interpolates interior gaps so the line bridges them — but only
|
|
76
|
+
// *within* a run (a session break is not a dropout to interpolate over);
|
|
77
|
+
// every other mode keeps NaN so d3 breaks the solid path (the inferred
|
|
78
|
+
// bridge, if any, is a separate overlay pass below).
|
|
79
|
+
const seg = singleRun ? cs.y : cs.y.subarray(s, e);
|
|
80
|
+
const ys = gaps === 'none' ? bridgeGaps(seg, e - s) : seg;
|
|
81
|
+
const gen = d3line()
|
|
82
|
+
.defined((v) => Number.isFinite(v))
|
|
83
|
+
.x((_, j) => xScale(cs.x[s + j]))
|
|
84
|
+
.y((v) => yScale(v))
|
|
85
|
+
.curve(curve)
|
|
86
|
+
.context(ctx);
|
|
87
|
+
gen(ys);
|
|
88
|
+
}
|
|
70
89
|
ctx.strokeStyle = style.color;
|
|
71
90
|
ctx.lineWidth = style.width;
|
|
72
91
|
// Per-series dash (a modeled/forecast line reads dashed). Applied only when
|
|
@@ -84,8 +103,15 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
|
|
|
84
103
|
}
|
|
85
104
|
// Overlay bridges for the inferred-gap modes. `dashed` / `step` are faint
|
|
86
105
|
// dashed connectors (gapConnectorOpacity); only `fade` drops to the axis floor.
|
|
106
|
+
// Collect edges **per run** so an inferred bridge never spans a session break
|
|
107
|
+
// (the break wins — no dashed/step/fade connector across a collapsed gap).
|
|
87
108
|
if (gaps === 'dashed' || gaps === 'step' || gaps === 'fade') {
|
|
88
|
-
const edges =
|
|
109
|
+
const edges = [];
|
|
110
|
+
for (const [s, e] of runs) {
|
|
111
|
+
const runEdges = collectGapEdges(e - s, singleRun ? cs.x : cs.x.subarray(s, e), (i) => cs.y[s + i], xScale, (i) => yScale(cs.y[s + i]));
|
|
112
|
+
for (const ed of runEdges)
|
|
113
|
+
edges.push(ed);
|
|
114
|
+
}
|
|
89
115
|
if (gaps === 'dashed') {
|
|
90
116
|
drawGapBridges(ctx, edges, style.color, style.width, gapConnectorOpacity);
|
|
91
117
|
}
|
|
@@ -97,4 +123,43 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
|
|
|
97
123
|
}
|
|
98
124
|
}
|
|
99
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
|
|
128
|
+
* cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
|
|
129
|
+
* discontinuity (a trading session / day / lunch close→open) sits between two
|
|
130
|
+
* consecutive points. A point that lands exactly on a boundary starts the new
|
|
131
|
+
* run (the open). No boundary inside the data (or an empty list) ⇒ a single run
|
|
132
|
+
* over the whole series. This is what turns `<LineChart sessionBreaks>` into a
|
|
133
|
+
* per-session polyline. Pure + O(N).
|
|
134
|
+
*
|
|
135
|
+
* The sweep relies on **ascending** boundaries; the `DiscontinuityProvider`
|
|
136
|
+
* contract doesn't guarantee order, so an unsorted list is sorted defensively
|
|
137
|
+
* (a copy, so the caller's array isn't mutated) rather than silently dropping a
|
|
138
|
+
* break. The list is tiny — one entry per session boundary — so the sort is
|
|
139
|
+
* negligible next to the row sweep.
|
|
140
|
+
*/
|
|
141
|
+
export function sessionRuns(x, length, boundaries) {
|
|
142
|
+
if (boundaries.length === 0 || length === 0)
|
|
143
|
+
return [[0, length]];
|
|
144
|
+
const bounds = boundaries.length > 1 ? [...boundaries].sort((a, b) => a - b) : boundaries;
|
|
145
|
+
const runs = [];
|
|
146
|
+
let start = 0;
|
|
147
|
+
let bi = 0;
|
|
148
|
+
for (let i = 1; i < length; i += 1) {
|
|
149
|
+
const prev = x[i - 1];
|
|
150
|
+
const cur = x[i];
|
|
151
|
+
// Skip boundaries at or before the previous point (already behind the pen).
|
|
152
|
+
while (bi < bounds.length && bounds[bi] <= prev)
|
|
153
|
+
bi += 1;
|
|
154
|
+
if (bi < bounds.length && bounds[bi] <= cur) {
|
|
155
|
+
// A boundary sits in (prev, cur] → break the run before point i.
|
|
156
|
+
runs.push([start, i]);
|
|
157
|
+
start = i;
|
|
158
|
+
while (bi < bounds.length && bounds[bi] <= cur)
|
|
159
|
+
bi += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
runs.push([start, length]);
|
|
163
|
+
return runs;
|
|
164
|
+
}
|
|
100
165
|
//# sourceMappingURL=line.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DiscontinuityProvider } from './tradingTimeScale.js';
|
|
2
|
+
/**
|
|
3
|
+
* The logical tick ladder — grain selection for a time axis. Ticks sit on real
|
|
4
|
+
* calendar/clock units (1s…30s, 1m…30m, 1H…12H, day / week / month / quarter /
|
|
5
|
+
* year — the trading-terminal convention), never on even pixel spacing: the
|
|
6
|
+
* axis walks the ladder finest→coarsest and picks the first grain whose anchor
|
|
7
|
+
* count fits the width-derived cap. The same ladder serves a **disjoint
|
|
8
|
+
* trading-calendar** axis (session opens are the day anchors, hour anchors are
|
|
9
|
+
* generated in live time so they never land in a collapsed gap) and a **plain
|
|
10
|
+
* continuous** axis (an identity provider whose "sessions" are calendar days).
|
|
11
|
+
*
|
|
12
|
+
* Each grain also knows its **boundary grain** — the next-coarser unit its own
|
|
13
|
+
* label doesn't carry (hours → the date, days/weeks → the month, months →
|
|
14
|
+
* the year). The axis renders that as a second label row, once per boundary
|
|
15
|
+
* crossing, so a month row reads `Dec Jan Feb …` with `2026` appearing exactly
|
|
16
|
+
* where the year turns.
|
|
17
|
+
*/
|
|
18
|
+
/** The calendar grain a run of tick anchors is bucketed to. */
|
|
19
|
+
export type TickGranularity = 'second1' | 'second5' | 'second15' | 'second30' | 'minute1' | 'minute5' | 'minute15' | 'minute30' | 'hour1' | 'hour3' | 'hour6' | 'hour12' | 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
20
|
+
/**
|
|
21
|
+
* The local-time bucket key for `t` at grain `g` — two instants in the same
|
|
22
|
+
* day / week / month / quarter / year share a key. Local time (not UTC) so it
|
|
23
|
+
* agrees with the local `scaleTime` label formatter; the exchange's own time
|
|
24
|
+
* zone is unknown to the scale (the deferred refinement), and a session open
|
|
25
|
+
* sits well inside its local day, so runtime-local grouping matches the
|
|
26
|
+
* exchange day in every ordinary case. Hour grains are never bucketed (each
|
|
27
|
+
* anchor is its own tick), so they key by identity.
|
|
28
|
+
*/
|
|
29
|
+
export declare function bucketKey(t: number, g: TickGranularity): number;
|
|
30
|
+
/**
|
|
31
|
+
* Thin an ascending run of **session opens** down to about `count` axis ticks by
|
|
32
|
+
* **calendar grain** — the trading-terminal habit of labelling week / month /
|
|
33
|
+
* year starts rather than an arbitrary every-nth session. Picks the finest grain
|
|
34
|
+
* on the ladder (day → week → month → quarter → year) that yields at most
|
|
35
|
+
* `count` buckets and returns the first open in each; beyond yearly it decimates
|
|
36
|
+
* every-nth so the axis never crowds. Exported so the container can draw session
|
|
37
|
+
* dividers at the same instants the axis labels.
|
|
38
|
+
*
|
|
39
|
+
* `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
|
|
40
|
+
* small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
|
|
41
|
+
* run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
|
|
42
|
+
* ticks). Callers size the cap to the room the labels have — the container
|
|
43
|
+
* derives it from plot width — rather than passing a small constant.
|
|
44
|
+
*
|
|
45
|
+
* This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
|
|
46
|
+
* sub-day rungs.
|
|
47
|
+
*/
|
|
48
|
+
export declare function coarsenCalendar(opens: readonly number[], count: number): {
|
|
49
|
+
ticks: number[];
|
|
50
|
+
granularity: TickGranularity;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The full-ladder grain selection: given the provider, the domain, and the
|
|
54
|
+
* width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
|
|
55
|
+
* then day → week → month → quarter → year (then decimate) and return the
|
|
56
|
+
* first rung that fits.
|
|
57
|
+
* `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
|
|
58
|
+
* caller already has. Sub-day rungs are only reachable when the opens
|
|
59
|
+
* themselves fit — a year of daily sessions never wastes time generating hour
|
|
60
|
+
* anchors.
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): {
|
|
63
|
+
ticks: number[];
|
|
64
|
+
granularity: TickGranularity;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The **boundary grain** for ticks at grain `g` — the next-coarser unit a
|
|
68
|
+
* tick's own label doesn't already carry, rendered as the axis's second label
|
|
69
|
+
* row. Clock labels (`14:00`) need the date; day/week labels (`Feb 02`)
|
|
70
|
+
* already carry the month, so they need only the year — as do month/quarter
|
|
71
|
+
* labels (`Feb`); a year label already says everything.
|
|
72
|
+
*/
|
|
73
|
+
export declare function boundaryGrainFor(g: TickGranularity): TickGranularity | undefined;
|
|
74
|
+
/** d3 time-format specifier for the **major** (first-row) label at grain `g`. */
|
|
75
|
+
export declare function majorFormatFor(g: TickGranularity): string;
|
|
76
|
+
/** d3 time-format specifier for the **boundary** (second-row) label at the
|
|
77
|
+
* boundary grain `g` — a date under clock ticks, the bare year under
|
|
78
|
+
* everything else. Never repeat a unit the first row already shows
|
|
79
|
+
* (`Jan 2026` under a `Jan 05` tick reads as noise). */
|
|
80
|
+
export declare function boundaryFormatFor(g: TickGranularity): string;
|
|
81
|
+
/**
|
|
82
|
+
* Which of `ticks` (at grain `granularity`) carry a boundary label: the first
|
|
83
|
+
* tick always (the reader needs context immediately), then every tick whose
|
|
84
|
+
* boundary-grain bucket differs from the previous tick's — i.e. the first tick
|
|
85
|
+
* of each new day / year. Returns the boundary-flagged tick values;
|
|
86
|
+
* empty when the grain has no boundary row (year grain).
|
|
87
|
+
*/
|
|
88
|
+
export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity): number[];
|
|
89
|
+
//# sourceMappingURL=tickLadder.d.ts.map
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const SEC_MS = 1_000;
|
|
2
|
+
const MIN_MS = 60_000;
|
|
3
|
+
const HOUR_MS = 3_600_000;
|
|
4
|
+
/** The sub-day rungs, finest first, with their clock step — the 1/5/15/30
|
|
5
|
+
* second and minute steps terminals use, then the hour steps. */
|
|
6
|
+
const SUB_DAY_GRAINS = [
|
|
7
|
+
{ g: 'second1', step: 1 * SEC_MS },
|
|
8
|
+
{ g: 'second5', step: 5 * SEC_MS },
|
|
9
|
+
{ g: 'second15', step: 15 * SEC_MS },
|
|
10
|
+
{ g: 'second30', step: 30 * SEC_MS },
|
|
11
|
+
{ g: 'minute1', step: 1 * MIN_MS },
|
|
12
|
+
{ g: 'minute5', step: 5 * MIN_MS },
|
|
13
|
+
{ g: 'minute15', step: 15 * MIN_MS },
|
|
14
|
+
{ g: 'minute30', step: 30 * MIN_MS },
|
|
15
|
+
{ g: 'hour1', step: 1 * HOUR_MS },
|
|
16
|
+
{ g: 'hour3', step: 3 * HOUR_MS },
|
|
17
|
+
{ g: 'hour6', step: 6 * HOUR_MS },
|
|
18
|
+
{ g: 'hour12', step: 12 * HOUR_MS },
|
|
19
|
+
];
|
|
20
|
+
/** Whether `g` is one of the sub-day (clock-step) rungs. */
|
|
21
|
+
function isSubDay(g) {
|
|
22
|
+
return g !== 'day' && SUB_DAY_GRAINS.some((r) => r.g === g);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The local-time bucket key for `t` at grain `g` — two instants in the same
|
|
26
|
+
* day / week / month / quarter / year share a key. Local time (not UTC) so it
|
|
27
|
+
* agrees with the local `scaleTime` label formatter; the exchange's own time
|
|
28
|
+
* zone is unknown to the scale (the deferred refinement), and a session open
|
|
29
|
+
* sits well inside its local day, so runtime-local grouping matches the
|
|
30
|
+
* exchange day in every ordinary case. Hour grains are never bucketed (each
|
|
31
|
+
* anchor is its own tick), so they key by identity.
|
|
32
|
+
*/
|
|
33
|
+
export function bucketKey(t, g) {
|
|
34
|
+
if (isSubDay(g))
|
|
35
|
+
return t;
|
|
36
|
+
const d = new Date(t);
|
|
37
|
+
switch (g) {
|
|
38
|
+
case 'day':
|
|
39
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
40
|
+
case 'week': {
|
|
41
|
+
const dow = (d.getDay() + 6) % 7; // 0 = Monday
|
|
42
|
+
// Local midnight of this week's Monday (Date normalizes a negative date).
|
|
43
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
|
|
44
|
+
}
|
|
45
|
+
case 'month':
|
|
46
|
+
return d.getFullYear() * 12 + d.getMonth();
|
|
47
|
+
case 'quarter':
|
|
48
|
+
return d.getFullYear() * 4 + Math.floor(d.getMonth() / 3);
|
|
49
|
+
case 'year':
|
|
50
|
+
return d.getFullYear();
|
|
51
|
+
default:
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The first instant of each distinct `g`-bucket in the ascending list `opens`. */
|
|
56
|
+
function firstOfEachBucket(opens, g) {
|
|
57
|
+
const out = [];
|
|
58
|
+
let prev;
|
|
59
|
+
for (const t of opens) {
|
|
60
|
+
const k = bucketKey(t, g);
|
|
61
|
+
if (k !== prev) {
|
|
62
|
+
out.push(t);
|
|
63
|
+
prev = k;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const COARSENING_LADDER = [
|
|
69
|
+
'week',
|
|
70
|
+
'month',
|
|
71
|
+
'quarter',
|
|
72
|
+
'year',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Thin an ascending run of **session opens** down to about `count` axis ticks by
|
|
76
|
+
* **calendar grain** — the trading-terminal habit of labelling week / month /
|
|
77
|
+
* year starts rather than an arbitrary every-nth session. Picks the finest grain
|
|
78
|
+
* on the ladder (day → week → month → quarter → year) that yields at most
|
|
79
|
+
* `count` buckets and returns the first open in each; beyond yearly it decimates
|
|
80
|
+
* every-nth so the axis never crowds. Exported so the container can draw session
|
|
81
|
+
* dividers at the same instants the axis labels.
|
|
82
|
+
*
|
|
83
|
+
* `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
|
|
84
|
+
* small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
|
|
85
|
+
* run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
|
|
86
|
+
* ticks). Callers size the cap to the room the labels have — the container
|
|
87
|
+
* derives it from plot width — rather than passing a small constant.
|
|
88
|
+
*
|
|
89
|
+
* This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
|
|
90
|
+
* sub-day rungs.
|
|
91
|
+
*/
|
|
92
|
+
export function coarsenCalendar(opens, count) {
|
|
93
|
+
if (opens.length <= count)
|
|
94
|
+
return { ticks: [...opens], granularity: 'day' };
|
|
95
|
+
for (const g of COARSENING_LADDER) {
|
|
96
|
+
const ticks = firstOfEachBucket(opens, g);
|
|
97
|
+
if (ticks.length <= count)
|
|
98
|
+
return { ticks, granularity: g };
|
|
99
|
+
}
|
|
100
|
+
// Coarser than yearly isn't a calendar grain — decimate the year starts.
|
|
101
|
+
const yearly = firstOfEachBucket(opens, 'year');
|
|
102
|
+
const step = Math.ceil(yearly.length / count);
|
|
103
|
+
return {
|
|
104
|
+
ticks: yearly.filter((_, i) => i % step === 0),
|
|
105
|
+
granularity: 'year',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The first clock-aligned `stepMs` multiple at or after `t`, relative to `t`'s
|
|
109
|
+
* own local midnight — so a 3-hour step lands on 00:00 / 03:00 / 06:00 local,
|
|
110
|
+
* whatever the session open was. Fixed-ms stepping from midnight, so on a DST
|
|
111
|
+
* transition day the later anchors drift off the wall-clock grid by the shift
|
|
112
|
+
* (labels stay truthful — they format the real instant); exchange-tz grain is
|
|
113
|
+
* the already-deferred refinement. */
|
|
114
|
+
function nextAligned(t, stepMs) {
|
|
115
|
+
const d = new Date(t);
|
|
116
|
+
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
117
|
+
return midnight + Math.ceil((t - midnight) / stepMs) * stepMs;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The sub-day anchors at `stepMs`: each session open, plus each clock-aligned
|
|
121
|
+
* step instant strictly inside that session's **live** span. (The caller may
|
|
122
|
+
* still drop the very first anchor as a cramped lead — see {@link buildTicks}.) An instant is
|
|
123
|
+
* in-session iff live distance-then-offset round-trips it — so a lunch-break
|
|
124
|
+
* gap, an early close, or a collapsed overnight never gets an anchor, and no
|
|
125
|
+
* new provider surface is needed. Bails once `cap` is exceeded (the caller
|
|
126
|
+
* only needs to know the grain doesn't fit).
|
|
127
|
+
*/
|
|
128
|
+
function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (let i = 0; i < opens.length; i++) {
|
|
131
|
+
const open = opens[i];
|
|
132
|
+
const end = i + 1 < opens.length ? opens[i + 1] : domainEnd;
|
|
133
|
+
out.push(open);
|
|
134
|
+
for (let t = nextAligned(open + 1, stepMs); t < end; t += stepMs) {
|
|
135
|
+
if (provider.offset(open, provider.distance(open, t)) === t) {
|
|
136
|
+
out.push(t);
|
|
137
|
+
if (out.length > cap)
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The full-ladder grain selection: given the provider, the domain, and the
|
|
146
|
+
* width-derived `cap`, walk the clock rungs (1s … 30s, 1m … 30m, 1h … 12h)
|
|
147
|
+
* then day → week → month → quarter → year (then decimate) and return the
|
|
148
|
+
* first rung that fits.
|
|
149
|
+
* `opens` are the session-open anchors (`[domain start, ...boundaries]`) the
|
|
150
|
+
* caller already has. Sub-day rungs are only reachable when the opens
|
|
151
|
+
* themselves fit — a year of daily sessions never wastes time generating hour
|
|
152
|
+
* anchors.
|
|
153
|
+
*/
|
|
154
|
+
export function buildTicks(provider, opens, domainEnd, cap) {
|
|
155
|
+
const result = (() => {
|
|
156
|
+
if (opens.length <= cap) {
|
|
157
|
+
for (const { g, step } of SUB_DAY_GRAINS) {
|
|
158
|
+
const ticks = stepAnchors(provider, opens, domainEnd, step, cap);
|
|
159
|
+
// A clock rung must earn its labels: if it adds no intraday anchor
|
|
160
|
+
// beyond the opens themselves, it's really day grain (a row of
|
|
161
|
+
// "09:30"s under every session is a worse day axis, not a clock axis).
|
|
162
|
+
if (ticks.length <= cap && ticks.length > opens.length)
|
|
163
|
+
return { ticks, granularity: g };
|
|
164
|
+
}
|
|
165
|
+
return { ticks: [...opens], granularity: 'day' };
|
|
166
|
+
}
|
|
167
|
+
return coarsenCalendar(opens, cap);
|
|
168
|
+
})();
|
|
169
|
+
// Round anchors to integer milliseconds: a pan/zoom domain comes from
|
|
170
|
+
// `scale.invert(pixel)` and is fractional, and a fractional anchor breaks
|
|
171
|
+
// the label pipeline — formatters pass through `new Date(ms)`, which
|
|
172
|
+
// truncates, so the instant no longer matches its own anchor set and the
|
|
173
|
+
// label falls through to the d3 multi-scale default (a bare `.259`
|
|
174
|
+
// millisecond tick). Sub-ms precision is invisible at any ladder grain.
|
|
175
|
+
result.ticks = result.ticks.map((t) => Math.round(t));
|
|
176
|
+
// Drop a cramped **leading partial-period** anchor: the first tick is the
|
|
177
|
+
// domain start, which usually sits mid-period (a "1Y back from today" view
|
|
178
|
+
// starts mid-month), so it can land arbitrarily close to the first full
|
|
179
|
+
// period start and the two labels collide (the classic "Jun 23Jul 07"
|
|
180
|
+
// pile-up). When the lead gap is under half a typical period (in **live**
|
|
181
|
+
// time, so a collapsed weekend doesn't fake a gap), the partial anchor
|
|
182
|
+
// isn't earning its label — the boundary row moves to the next tick.
|
|
183
|
+
const t = result.ticks;
|
|
184
|
+
if (t.length >= 3 &&
|
|
185
|
+
provider.distance(t[0], t[1]) < 0.5 * provider.distance(t[1], t[2])) {
|
|
186
|
+
t.shift();
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The **boundary grain** for ticks at grain `g` — the next-coarser unit a
|
|
192
|
+
* tick's own label doesn't already carry, rendered as the axis's second label
|
|
193
|
+
* row. Clock labels (`14:00`) need the date; day/week labels (`Feb 02`)
|
|
194
|
+
* already carry the month, so they need only the year — as do month/quarter
|
|
195
|
+
* labels (`Feb`); a year label already says everything.
|
|
196
|
+
*/
|
|
197
|
+
export function boundaryGrainFor(g) {
|
|
198
|
+
if (isSubDay(g))
|
|
199
|
+
return 'day';
|
|
200
|
+
switch (g) {
|
|
201
|
+
case 'day':
|
|
202
|
+
case 'week':
|
|
203
|
+
case 'month':
|
|
204
|
+
case 'quarter':
|
|
205
|
+
return 'year';
|
|
206
|
+
default:
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** d3 time-format specifier for the **major** (first-row) label at grain `g`. */
|
|
211
|
+
export function majorFormatFor(g) {
|
|
212
|
+
switch (g) {
|
|
213
|
+
case 'second1':
|
|
214
|
+
case 'second5':
|
|
215
|
+
case 'second15':
|
|
216
|
+
case 'second30':
|
|
217
|
+
return '%H:%M:%S';
|
|
218
|
+
case 'minute1':
|
|
219
|
+
case 'minute5':
|
|
220
|
+
case 'minute15':
|
|
221
|
+
case 'minute30':
|
|
222
|
+
case 'hour1':
|
|
223
|
+
case 'hour3':
|
|
224
|
+
case 'hour6':
|
|
225
|
+
case 'hour12':
|
|
226
|
+
return '%H:%M';
|
|
227
|
+
case 'day':
|
|
228
|
+
case 'week':
|
|
229
|
+
return '%b %d';
|
|
230
|
+
case 'month':
|
|
231
|
+
case 'quarter':
|
|
232
|
+
return '%b';
|
|
233
|
+
case 'year':
|
|
234
|
+
return '%Y';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** d3 time-format specifier for the **boundary** (second-row) label at the
|
|
238
|
+
* boundary grain `g` — a date under clock ticks, the bare year under
|
|
239
|
+
* everything else. Never repeat a unit the first row already shows
|
|
240
|
+
* (`Jan 2026` under a `Jan 05` tick reads as noise). */
|
|
241
|
+
export function boundaryFormatFor(g) {
|
|
242
|
+
return g === 'day' ? '%b %d' : '%Y';
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Which of `ticks` (at grain `granularity`) carry a boundary label: the first
|
|
246
|
+
* tick always (the reader needs context immediately), then every tick whose
|
|
247
|
+
* boundary-grain bucket differs from the previous tick's — i.e. the first tick
|
|
248
|
+
* of each new day / year. Returns the boundary-flagged tick values;
|
|
249
|
+
* empty when the grain has no boundary row (year grain).
|
|
250
|
+
*/
|
|
251
|
+
export function boundaryTicks(ticks, granularity) {
|
|
252
|
+
const bg = boundaryGrainFor(granularity);
|
|
253
|
+
if (bg === undefined)
|
|
254
|
+
return [];
|
|
255
|
+
const out = [];
|
|
256
|
+
let prev;
|
|
257
|
+
for (const t of ticks) {
|
|
258
|
+
const k = bucketKey(t, bg);
|
|
259
|
+
if (prev === undefined || k !== prev)
|
|
260
|
+
out.push(t);
|
|
261
|
+
prev = k;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=tickLadder.js.map
|
|
@@ -47,12 +47,16 @@ export interface TradingCalendarLike {
|
|
|
47
47
|
* wherever the container's `xScale` goes.
|
|
48
48
|
*
|
|
49
49
|
* Ticks are **calendar-aware** when the provider enumerates its gaps: `.ticks`
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* with the d3 multi-scale default.
|
|
55
|
-
*
|
|
50
|
+
* walks the logical ladder (hour1 → hour3 → hour6 → hour12 → day → week →
|
|
51
|
+
* month → quarter → year) and returns the finest rung that fits `count`, and
|
|
52
|
+
* `.tickFormat` labels each anchor at that grain (`%H:%M` for hours, `%b %d`
|
|
53
|
+
* for days/weeks, `%b` for months/quarters, `%Y` for years) while formatting
|
|
54
|
+
* any other instant (the cursor readout) with the d3 multi-scale default. The
|
|
55
|
+
* coarser context a label drops lives on `.tickBoundaries` — the second-row
|
|
56
|
+
* boundary labels (the date over a clock axis, the year over a day / week /
|
|
57
|
+
* month axis), one per boundary crossing plus the first tick.
|
|
58
|
+
* Without a provider `boundaries` method it falls back to interior even-spaced
|
|
59
|
+
* time ticks.
|
|
56
60
|
*
|
|
57
61
|
* **Out-of-domain behavior.** Within the calendar the scale extrapolates like a
|
|
58
62
|
* normal scale — a live instant *before* the domain start maps to a negative
|
|
@@ -67,37 +71,36 @@ export interface TradingTimeScale {
|
|
|
67
71
|
invert(pixel: number): number;
|
|
68
72
|
ticks(count?: number): number[];
|
|
69
73
|
tickFormat(count?: number, specifier?: string): (date: Date) => string;
|
|
74
|
+
/**
|
|
75
|
+
* The **second-row** (boundary) label for a tick value, or `undefined` for
|
|
76
|
+
* ticks that don't open a new boundary period. Same grain selection as
|
|
77
|
+
* {@link ticks} at the same `count`, so the rows agree: the first tick and
|
|
78
|
+
* each tick starting a new day / year (whichever is the next-coarser
|
|
79
|
+
* unit the first-row label omits) carry the label; year-grain ticks have no
|
|
80
|
+
* second row.
|
|
81
|
+
*/
|
|
82
|
+
tickBoundaries(count?: number): (value: number) => string | undefined;
|
|
70
83
|
domain(): [number, number];
|
|
71
84
|
domain(next: readonly [number, number]): TradingTimeScale;
|
|
72
85
|
range(): [number, number];
|
|
73
86
|
range(next: readonly [number, number]): TradingTimeScale;
|
|
74
87
|
copy(): TradingTimeScale;
|
|
75
88
|
}
|
|
76
|
-
|
|
77
|
-
type TickGranularity
|
|
89
|
+
export { coarsenCalendar } from './tickLadder.js';
|
|
90
|
+
export type { TickGranularity } from './tickLadder.js';
|
|
78
91
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
|
|
88
|
-
* small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
|
|
89
|
-
* run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
|
|
90
|
-
* ticks). Callers size the cap to the room the labels have — the container
|
|
91
|
-
* derives it from plot width — rather than passing a small constant.
|
|
92
|
+
* The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
|
|
93
|
+
* time, and every local midnight is a "session open". Backing a plain
|
|
94
|
+
* continuous time axis with `scaleTradingTime(identityProvider())` runs it
|
|
95
|
+
* through the same logical tick ladder as a trading-calendar axis — calendar
|
|
96
|
+
* days are the day anchors, so a year of data ticks on month starts and an
|
|
97
|
+
* afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
|
|
98
|
+
* default.
|
|
92
99
|
*/
|
|
93
|
-
export declare function
|
|
94
|
-
ticks: number[];
|
|
95
|
-
granularity: TickGranularity;
|
|
96
|
-
};
|
|
100
|
+
export declare function identityProvider(): DiscontinuityProvider;
|
|
97
101
|
/**
|
|
98
102
|
* Build a {@link TradingTimeScale} over the given discontinuity `provider`.
|
|
99
103
|
* Configure like a d3 scale: `scaleTradingTime(provider).domain([t0, t1]).range([0, width])`.
|
|
100
104
|
*/
|
|
101
105
|
export declare function scaleTradingTime(provider: DiscontinuityProvider): TradingTimeScale;
|
|
102
|
-
export {};
|
|
103
106
|
//# sourceMappingURL=tradingTimeScale.d.ts.map
|
package/dist/tradingTimeScale.js
CHANGED
|
@@ -1,79 +1,39 @@
|
|
|
1
1
|
import { scaleTime } from 'd3-scale';
|
|
2
|
+
import { boundaryFormatFor, boundaryGrainFor, boundaryTicks, buildTicks, majorFormatFor, } from './tickLadder.js';
|
|
3
|
+
// Grain selection lives in `tickLadder.ts` (the full hour1…year ladder plus
|
|
4
|
+
// the boundary-row helpers); re-exported here so existing imports keep working.
|
|
5
|
+
export { coarsenCalendar } from './tickLadder.js';
|
|
2
6
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The trivial gap-free {@link DiscontinuityProvider}: live time **is** wall
|
|
8
|
+
* time, and every local midnight is a "session open". Backing a plain
|
|
9
|
+
* continuous time axis with `scaleTradingTime(identityProvider())` runs it
|
|
10
|
+
* through the same logical tick ladder as a trading-calendar axis — calendar
|
|
11
|
+
* days are the day anchors, so a year of data ticks on month starts and an
|
|
12
|
+
* afternoon ticks on clock-aligned hours, instead of d3's mixed multi-scale
|
|
13
|
+
* default.
|
|
9
14
|
*/
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const out = [];
|
|
31
|
-
let prev;
|
|
32
|
-
for (const t of opens) {
|
|
33
|
-
const k = bucketKey(t, g);
|
|
34
|
-
if (k !== prev) {
|
|
35
|
-
out.push(t);
|
|
36
|
-
prev = k;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
const COARSENING_LADDER = [
|
|
42
|
-
'week',
|
|
43
|
-
'month',
|
|
44
|
-
'quarter',
|
|
45
|
-
'year',
|
|
46
|
-
];
|
|
47
|
-
/**
|
|
48
|
-
* Thin an ascending run of **session opens** down to about `count` axis ticks by
|
|
49
|
-
* **calendar grain** — the trading-terminal habit of labelling week / month /
|
|
50
|
-
* year starts rather than an arbitrary every-nth session. Picks the finest grain
|
|
51
|
-
* on the ladder (session → week → month → quarter → year) that yields at most
|
|
52
|
-
* `count` buckets and returns the first open in each; beyond yearly it decimates
|
|
53
|
-
* every-nth so the axis never crowds. Exported so the container can draw session
|
|
54
|
-
* dividers at the same instants the axis labels.
|
|
55
|
-
*
|
|
56
|
-
* `count` is a **cap**, not a target: grains jump by 4–12× up the ladder, so a
|
|
57
|
-
* small fixed count over-coarsens long spans (a mid-year-anchored 12-month daily
|
|
58
|
-
* run spans 6 quarter buckets — capped at 5 it collapses to year grain, 2
|
|
59
|
-
* ticks). Callers size the cap to the room the labels have — the container
|
|
60
|
-
* derives it from plot width — rather than passing a small constant.
|
|
61
|
-
*/
|
|
62
|
-
export function coarsenCalendar(opens, count) {
|
|
63
|
-
if (opens.length <= count)
|
|
64
|
-
return { ticks: [...opens], granularity: 'session' };
|
|
65
|
-
for (const g of COARSENING_LADDER) {
|
|
66
|
-
const ticks = firstOfEachBucket(opens, g);
|
|
67
|
-
if (ticks.length <= count)
|
|
68
|
-
return { ticks, granularity: g };
|
|
69
|
-
}
|
|
70
|
-
// Coarser than yearly isn't a calendar grain — decimate the year starts.
|
|
71
|
-
const yearly = firstOfEachBucket(opens, 'year');
|
|
72
|
-
const step = Math.ceil(yearly.length / count);
|
|
73
|
-
return {
|
|
74
|
-
ticks: yearly.filter((_, i) => i % step === 0),
|
|
75
|
-
granularity: 'year',
|
|
15
|
+
export function identityProvider() {
|
|
16
|
+
const self = {
|
|
17
|
+
clampUp: (t) => t,
|
|
18
|
+
clampDown: (t) => t,
|
|
19
|
+
distance: (from, to) => to - from,
|
|
20
|
+
offset: (v, amount) => v + amount,
|
|
21
|
+
copy: () => self,
|
|
22
|
+
boundaries: (from, to) => {
|
|
23
|
+
const out = [];
|
|
24
|
+
const d = new Date(from);
|
|
25
|
+
// First local midnight strictly after `from`; step by calendar day (not
|
|
26
|
+
// 24h) so DST transitions stay on midnight.
|
|
27
|
+
let cur = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
|
|
28
|
+
while (cur.getTime() < to) {
|
|
29
|
+
if (cur.getTime() > from)
|
|
30
|
+
out.push(cur.getTime());
|
|
31
|
+
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
76
35
|
};
|
|
36
|
+
return self;
|
|
77
37
|
}
|
|
78
38
|
/**
|
|
79
39
|
* Build a {@link TradingTimeScale} over the given discontinuity `provider`.
|
|
@@ -103,17 +63,35 @@ export function scaleTradingTime(provider) {
|
|
|
103
63
|
const bounds = provider.boundaries?.(domain[0], domain[1]) ?? [];
|
|
104
64
|
return [domain[0], ...bounds];
|
|
105
65
|
};
|
|
66
|
+
/** Whether the provider has calendar structure to ladder on. Without a
|
|
67
|
+
* `boundaries` method there are no anchors — the even-spacing fallback. */
|
|
68
|
+
const hasCalendar = () => provider.boundaries !== undefined;
|
|
69
|
+
/** The ladder result for this domain at `count` — the single source `ticks`,
|
|
70
|
+
* `tickFormat`, and `tickBoundaries` all derive from, so the three agree.
|
|
71
|
+
* Memoized on `(domain, count)`: the three callers (plus gridlines /
|
|
72
|
+
* dividers) hit the same resolution per render, and on a wide continuous
|
|
73
|
+
* domain re-walking every day-open is the expensive part. */
|
|
74
|
+
let laddered = null;
|
|
75
|
+
const resolved = (count) => {
|
|
76
|
+
const key = `${domain[0]}:${domain[1]}:${count}`;
|
|
77
|
+
if (laddered?.key !== key) {
|
|
78
|
+
laddered = {
|
|
79
|
+
key,
|
|
80
|
+
value: buildTicks(provider, sessionOpens(), domain[1], count),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return laddered.value;
|
|
84
|
+
};
|
|
106
85
|
scale.ticks = (count = 10) => {
|
|
107
86
|
const live = totalLive();
|
|
108
87
|
if (live <= 0 || count < 1)
|
|
109
88
|
return [domain[0]];
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// No boundaries (a single session / no calendar): fall back to interior
|
|
89
|
+
// Calendar-aware: walk the logical ladder over the session-open anchors
|
|
90
|
+
// (hour steps inside sessions, then day / week / month / quarter / year
|
|
91
|
+
// starts) — the trading-terminal look, never an arbitrary every-nth.
|
|
92
|
+
if (hasCalendar())
|
|
93
|
+
return resolved(count).ticks;
|
|
94
|
+
// No boundaries (no calendar structure at all): fall back to interior
|
|
117
95
|
// even-spaced ticks — endpoints excluded so none sits on the plot edge.
|
|
118
96
|
const out = [];
|
|
119
97
|
for (let i = 1; i < count; i++) {
|
|
@@ -124,20 +102,34 @@ export function scaleTradingTime(provider) {
|
|
|
124
102
|
scale.tickFormat = (count = 10, specifier) => {
|
|
125
103
|
if (specifier !== undefined)
|
|
126
104
|
return base.tickFormat(count, specifier);
|
|
127
|
-
const opens = sessionOpens();
|
|
128
105
|
const defFmt = base.tickFormat(count);
|
|
129
|
-
if (
|
|
106
|
+
if (!hasCalendar())
|
|
130
107
|
return defFmt; // no calendar → d3 multi-scale default
|
|
131
|
-
// Anchor
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
108
|
+
// Anchor labels at the grain {@link ticks} chose — one uniform format per
|
|
109
|
+
// grain (hours as `%H:%M`, days/weeks as `%b %d`, months/quarters as `%b`,
|
|
110
|
+
// years as `%Y`); the coarser context the label omits is the second row
|
|
111
|
+
// ({@link tickBoundaries}). Any other instant — a cursor readout — uses
|
|
112
|
+
// the d3 multi-scale default. Same grain as {@link ticks}, so labels and
|
|
113
|
+
// the dividers drawn at these instants agree.
|
|
114
|
+
const { ticks, granularity } = resolved(count);
|
|
137
115
|
const anchors = new Set(ticks);
|
|
138
|
-
const anchorFmt = base.tickFormat(count, granularity
|
|
116
|
+
const anchorFmt = base.tickFormat(count, majorFormatFor(granularity));
|
|
139
117
|
return (d) => (anchors.has(+d) ? anchorFmt(d) : defFmt(d));
|
|
140
118
|
};
|
|
119
|
+
scale.tickBoundaries = (count = 10) => {
|
|
120
|
+
if (!hasCalendar())
|
|
121
|
+
return () => undefined;
|
|
122
|
+
const { ticks, granularity } = resolved(count);
|
|
123
|
+
const bg = boundaryGrainFor(granularity);
|
|
124
|
+
if (bg === undefined)
|
|
125
|
+
return () => undefined;
|
|
126
|
+
const fmt = base.tickFormat(count, boundaryFormatFor(bg));
|
|
127
|
+
const labelled = new Map();
|
|
128
|
+
for (const t of boundaryTicks(ticks, granularity)) {
|
|
129
|
+
labelled.set(t, fmt(new Date(t)));
|
|
130
|
+
}
|
|
131
|
+
return (value) => labelled.get(value);
|
|
132
|
+
};
|
|
141
133
|
function domainFn(next) {
|
|
142
134
|
if (next === undefined)
|
|
143
135
|
return [domain[0], domain[1]];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.45.0",
|
|
42
|
+
"pond-ts": "^0.45.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|