@pond-ts/charts 0.45.0 → 0.46.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 +50 -1
- package/dist/ChartContainer.js +1 -1
- package/dist/XAxis.d.ts +27 -1
- package/dist/XAxis.js +113 -22
- package/dist/YAxis.d.ts +9 -1
- package/dist/YAxis.js +4 -4
- package/dist/annotations.d.ts +1 -1
- package/dist/annotations.js +17 -6
- package/dist/derivedTicks.d.ts +36 -0
- package/dist/derivedTicks.js +92 -0
- package/dist/index.d.ts +1 -0
- package/dist/tickLadder.d.ts +9 -6
- package/dist/tickLadder.js +32 -11
- package/dist/tradingTimeScale.d.ts +13 -4
- package/dist/tradingTimeScale.js +11 -1
- 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.46.0...HEAD
|
|
12
|
+
[0.46.0]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...v0.46.0
|
|
12
13
|
[0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
|
|
13
14
|
[0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
|
|
14
15
|
[0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
|
|
@@ -44,6 +45,54 @@ and type-level changes; patch bumps are strictly additive.
|
|
|
44
45
|
|
|
45
46
|
## [Unreleased]
|
|
46
47
|
|
|
48
|
+
## [0.46.0] — 2026-07-14
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **charts:** the boundary (second-row) axis label's **context** now pins to
|
|
53
|
+
the plot's left edge instead of riding the first tick: it shows the period
|
|
54
|
+
the *domain start* is in, and a crossing label sliding toward the edge
|
|
55
|
+
pushes it off (the sticky-header behavior). On a live sliding window the
|
|
56
|
+
old first-tick anchoring made `Jan 01` hop tick-to-tick as ticks scrolled
|
|
57
|
+
out; pinned, it stays put until the period actually changes. Crossing
|
|
58
|
+
labels (day/year turns) still ride their ticks — including a first tick
|
|
59
|
+
whose period differs from the domain start's. `TradingTimeScale` gains
|
|
60
|
+
`boundaryContext(count)`; `tickBoundaries` now labels crossings only.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- **charts:** a live (sliding-window) time axis no longer flickers between two
|
|
65
|
+
tick grains: the clock-rung choice now derives from the window's live span
|
|
66
|
+
(constant while sliding) instead of the enumerated anchor count, which
|
|
67
|
+
oscillates ±1 with the window's phase and flipped the grain for single
|
|
68
|
+
frames whenever it sat exactly at the width-derived cap.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **charts:** **dual x-axes** — two tick layouts on one shared scale. A second
|
|
73
|
+
`<XAxis>` stacks by declaration order (above/below the plot, either side,
|
|
74
|
+
same side twice); the new **`transform`** prop (`{ to, from }`, exported
|
|
75
|
+
`AxisTransform`) relabels an axis into a derived unit: strike ↔ moneyness on
|
|
76
|
+
a top axis, or a nonlinear BS-delta strip under a std-moneyness chart. Ticks
|
|
77
|
+
are nice derived-unit values chosen by a pixel-aware multi-resolution fill
|
|
78
|
+
(1-2-5 steps, coarsest first, admitted where they keep room), so a span the
|
|
79
|
+
transform compresses gets coarse ticks and a stretched span picks up finer
|
|
80
|
+
ones — and a label-honesty filter drops any tick whose formatted label would
|
|
81
|
+
lie about its position. Gridlines stay on the container's primary ticks; the
|
|
82
|
+
cursor pill on a transformed axis reads in the derived unit. Stories under
|
|
83
|
+
`Charts/Axes/DualX`. Each `<XAxis>` **and `<YAxis>`** also takes a
|
|
84
|
+
per-instance **`color`** (labels, tick marks, rule, title) — the lever that
|
|
85
|
+
distinguishes stacked x strips (a blue delta strip under a grey primary) and
|
|
86
|
+
colours a y axis to match its series (the dual-axis convention).
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **charts:** annotation label chips now clip to the plot: a marker whose pole
|
|
91
|
+
pans off-plot no longer leaves its chip floating in the axis gutter, and a
|
|
92
|
+
partially visible region's chip clamps to the plot's left edge (culled only
|
|
93
|
+
when the region is entirely out of view). The lines/fills were already
|
|
94
|
+
SVG-clipped — only the DOM chips escaped.
|
|
95
|
+
|
|
47
96
|
## [0.45.0] — 2026-07-14
|
|
48
97
|
|
|
49
98
|
### Added
|
package/dist/ChartContainer.js
CHANGED
|
@@ -470,7 +470,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
470
470
|
}, [cursorX, xScale, sources, plotWidth]);
|
|
471
471
|
// Pack overlapping top-flag labels (markers + regions) into stacked lanes so
|
|
472
472
|
// close-in-x labels don't collide; chips read their lane back off the frame.
|
|
473
|
-
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey), [annotations, xScale, draggingKey]);
|
|
473
|
+
const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey, plotWidth), [annotations, xScale, draggingKey]);
|
|
474
474
|
const frame = useMemo(() => ({
|
|
475
475
|
timeRange: [d0, d1],
|
|
476
476
|
width,
|
package/dist/XAxis.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AxisTransform } from './derivedTicks.js';
|
|
1
2
|
import { type AxisFormat } from './format.js';
|
|
2
3
|
export interface XAxisProps {
|
|
3
4
|
/**
|
|
@@ -24,6 +25,31 @@ export interface XAxisProps {
|
|
|
24
25
|
readonly at: number;
|
|
25
26
|
readonly label: string;
|
|
26
27
|
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Relabel this axis into a **derived unit on the same scale** — a second
|
|
30
|
+
* tick layout, not a second scale (the pixel mapping never changes). E.g. a
|
|
31
|
+
* BS-delta strip under a std-moneyness chart (`transform={{ to: sigmaToDelta,
|
|
32
|
+
* from: deltaToSigma }}`) or a moneyness axis over a strike chart
|
|
33
|
+
* (`transform={{ to: (k) => k / spot, from: (m) => m * spot }}`). `to`/`from`
|
|
34
|
+
* are monotonic inverses (either direction); they may be **nonlinear** —
|
|
35
|
+
* ticks are nice derived-unit values at mixed 1-2-5 step sizes, admitted
|
|
36
|
+
* wherever they keep pixel room, so a span the transform compresses gets
|
|
37
|
+
* coarser ticks and a span it stretches gets finer ones. `format` (or the
|
|
38
|
+
* d3 number default) formats the derived values; the cursor pill and marker
|
|
39
|
+
* indicators on this axis read in the derived unit too. Ignored on a
|
|
40
|
+
* category axis; explicit {@link ticks} win. Typically used on a second
|
|
41
|
+
* `<XAxis>` stacked with the primary one — declaration order places the
|
|
42
|
+
* strips; gridlines stay on the container's own (primary) ticks.
|
|
43
|
+
*/
|
|
44
|
+
transform?: AxisTransform;
|
|
45
|
+
/**
|
|
46
|
+
* This axis instance's colour — tick marks, labels, the plot-facing rule,
|
|
47
|
+
* and the `label` title all take it, overriding the theme's `axis.label` /
|
|
48
|
+
* `axis.grid` / `axis.title.color`. The lever that distinguishes stacked
|
|
49
|
+
* dual axes (a blue derived-unit strip under a grey primary). Cursor and
|
|
50
|
+
* marker pills keep their own colours. Omit for the theme's axis colours.
|
|
51
|
+
*/
|
|
52
|
+
color?: string;
|
|
27
53
|
/**
|
|
28
54
|
* Horizontal placement of each tick label relative to its tick.
|
|
29
55
|
* - **`'center'` (default)** — every label centred on its tick. Note the
|
|
@@ -48,5 +74,5 @@ export interface XAxisProps {
|
|
|
48
74
|
*
|
|
49
75
|
* `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
|
|
50
76
|
*/
|
|
51
|
-
export declare function XAxis({ format, label, side, height, ticks: customTicks, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
77
|
+
export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
52
78
|
//# sourceMappingURL=XAxis.d.ts.map
|
package/dist/XAxis.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment, useContext } from 'react';
|
|
3
|
+
import { scaleLinear } from 'd3-scale';
|
|
4
|
+
import { derivedTicks } from './derivedTicks.js';
|
|
3
5
|
import { ContainerContext } from './context.js';
|
|
4
6
|
import { axisPillStyle } from './chip.js';
|
|
5
7
|
import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
|
|
@@ -9,6 +11,10 @@ const TICK_STRIP = 22;
|
|
|
9
11
|
const LABEL_STRIP = 16;
|
|
10
12
|
/** Extra height reserved for the boundary (second) label row. */
|
|
11
13
|
const BOUNDARY_STRIP = 15;
|
|
14
|
+
/** Minimum pixel gap between derived-unit (`transform`) ticks — the room a
|
|
15
|
+
* short numeric label needs plus breathing space, in the spirit of the
|
|
16
|
+
* ladder's per-tick budget (a hair tighter: derived labels are short). */
|
|
17
|
+
const TRANSFORM_TICK_PX = 48;
|
|
12
18
|
/**
|
|
13
19
|
* Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
|
|
14
20
|
* every `stride`-th label (so a kept label has room), and ellipsize one that still
|
|
@@ -49,7 +55,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
|
|
|
49
55
|
*
|
|
50
56
|
* `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
|
|
51
57
|
*/
|
|
52
|
-
export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, align = 'center', } = {}) {
|
|
58
|
+
export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', } = {}) {
|
|
53
59
|
const container = useContext(ContainerContext);
|
|
54
60
|
if (container === null) {
|
|
55
61
|
throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -69,19 +75,37 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
69
75
|
cursorX <= plotWidth;
|
|
70
76
|
const cursorColor = theme.cursor ?? theme.axis.label;
|
|
71
77
|
const annotationColor = theme.annotation?.color ?? '#0d9488';
|
|
78
|
+
// Derived-unit (`transform`) layout: nice ticks in the derived unit at
|
|
79
|
+
// mixed step sizes, admitted where they keep pixel room (see derivedTicks).
|
|
80
|
+
// Explicit `ticks` win; a category axis has no numeric unit to derive from.
|
|
81
|
+
const derived = transform !== undefined && xKind !== 'category' && customTicks === undefined
|
|
82
|
+
? derivedTicks(transform, xScale.domain(), (v) => xScale(v), plotWidth, TRANSFORM_TICK_PX)
|
|
83
|
+
: null;
|
|
84
|
+
// Formatter for derived-unit values — `format` resolved against a u-space
|
|
85
|
+
// linear scale (so `'+.2f'` and the d3 number default both work).
|
|
86
|
+
const uFmt = transform !== undefined && xKind !== 'category'
|
|
87
|
+
? (() => {
|
|
88
|
+
const [d0, d1] = xScale.domain();
|
|
89
|
+
const u = [transform.to(d0), transform.to(d1)].sort((a, b) => a - b);
|
|
90
|
+
return resolveAxisFormat(scaleLinear().domain(u), xTickCount, format);
|
|
91
|
+
})()
|
|
92
|
+
: null;
|
|
72
93
|
// Tick formatter: an explicit `format` is resolved against the axis kind
|
|
73
94
|
// (a time specifier through the time scale, a number specifier through the
|
|
74
95
|
// value scale); otherwise the container's shared formatter — the one the
|
|
75
|
-
// cursor readout uses, so a tick and the cursor read identically.
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
// cursor readout uses, so a tick and the cursor read identically. On a
|
|
97
|
+
// transformed axis every readout (cursor pill, marker indicator) speaks the
|
|
98
|
+
// **derived unit** — the axis's own language.
|
|
99
|
+
const fmt = transform !== undefined && uFmt !== null && xKind !== 'category'
|
|
100
|
+
? (v) => uFmt(transform.to(v))
|
|
101
|
+
: // A category axis labels by name (the container's `formatTime` = the band
|
|
102
|
+
// scale's label lookup); a d3 number/time `format` can't name a category, so
|
|
103
|
+
// it's ignored here (customize the labels in the `categories` data instead).
|
|
104
|
+
format === undefined || xKind === 'category'
|
|
105
|
+
? formatTime
|
|
106
|
+
: xKind === 'time'
|
|
107
|
+
? resolveTimeFormat(xScale, xTickCount, format)
|
|
108
|
+
: resolveAxisFormat(xScale, xTickCount, format);
|
|
85
109
|
// Marker annotations that opted into an axis indicator (`<Marker indicator>`)
|
|
86
110
|
// pin their **time** to this shared x-axis — a pill at `at`, in the annotation
|
|
87
111
|
// colour, reading like a tick. An indicator always shows the axis coordinate
|
|
@@ -136,18 +160,54 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
136
160
|
// custom format owns the whole label, and custom ticks have no grain).
|
|
137
161
|
const boundaryOf = xKind === 'time' &&
|
|
138
162
|
customTicks === undefined &&
|
|
163
|
+
transform === undefined &&
|
|
139
164
|
format === undefined &&
|
|
140
165
|
!container.xFormatCustom &&
|
|
141
166
|
'tickBoundaries' in xScale
|
|
142
167
|
? xScale.tickBoundaries(xTickCount)
|
|
143
168
|
: undefined;
|
|
169
|
+
// The pinned left-edge **context** label — what period the domain starts in
|
|
170
|
+
// (`Jan 01` over an intraday axis, the year over a month axis). A property
|
|
171
|
+
// of the domain, not of any tick: anchoring it to the first tick made it
|
|
172
|
+
// hop tick-to-tick on a live sliding window. Crossing labels ride their
|
|
173
|
+
// ticks and **push it off** the left edge as they approach (below).
|
|
174
|
+
const boundaryContext = boundaryOf !== undefined && 'boundaryContext' in xScale
|
|
175
|
+
? xScale.boundaryContext(xTickCount)
|
|
176
|
+
: undefined;
|
|
177
|
+
// Derived ticks pass a **label-honesty filter**: the fill can descend below
|
|
178
|
+
// the format's resolution (a delta tick at u = 0.498 renders as "+0.50" under
|
|
179
|
+
// `+.2f` — a lie about its position), so a tick survives only when its
|
|
180
|
+
// formatted label parses back to a value that maps to (±1px of) the tick's
|
|
181
|
+
// own pixel. This also caps density at the format's precision and drops
|
|
182
|
+
// would-be duplicate labels. Non-numeric labels (a custom format function)
|
|
183
|
+
// are trusted as-is.
|
|
184
|
+
const honestDerived = () => {
|
|
185
|
+
const out = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const t of derived) {
|
|
188
|
+
const text = uFmt(t.u);
|
|
189
|
+
if (seen.has(text))
|
|
190
|
+
continue;
|
|
191
|
+
const back = parseFloat(text.replace(/\u2212/g, '-').replace(/,/g, ''));
|
|
192
|
+
if (Number.isFinite(back)) {
|
|
193
|
+
const bx = xScale(transform.from(back));
|
|
194
|
+
if (!Number.isFinite(bx) || Math.abs(bx - t.x) > 1)
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
seen.add(text);
|
|
198
|
+
out.push({ x: t.x, label: text });
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
144
202
|
const rawTicks = customTicks
|
|
145
203
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
146
|
-
:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
: derived !== null
|
|
205
|
+
? honestDerived()
|
|
206
|
+
: xScale.ticks(xTickCount).map((d) => ({
|
|
207
|
+
x: xScale(d),
|
|
208
|
+
label: fmt(+d),
|
|
209
|
+
boundary: boundaryOf?.(+d),
|
|
210
|
+
}));
|
|
151
211
|
// A category axis ticks once per category; thin + truncate its labels when they
|
|
152
212
|
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
153
213
|
const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
|
|
@@ -160,7 +220,31 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
160
220
|
// Per-lane vertical step for stacked pills; grow the strip to fit the stack.
|
|
161
221
|
const PILL_LANE_H = theme.font.size + 6;
|
|
162
222
|
// Any boundary label in view grows the strip by one row (like pill lanes do).
|
|
163
|
-
const hasBoundary =
|
|
223
|
+
const hasBoundary = boundaryContext !== undefined ||
|
|
224
|
+
placed.some((t) => t.boundary !== undefined);
|
|
225
|
+
// The pinned context anchors at the plot's left edge (the y-axis line) with
|
|
226
|
+
// the SAME alignment as the tick labels — centred on it in `center` mode
|
|
227
|
+
// (exactly where a first-tick label at x=0 sat, half into the gutter by the
|
|
228
|
+
// same documented rule), left-anchored in `auto`, beside the line in
|
|
229
|
+
// `right`. As the leftmost crossing label slides toward the edge it would
|
|
230
|
+
// collide, so the context CULLS once the crossing label's left edge reaches
|
|
231
|
+
// the context's right edge (plus a gap) — no overlap, and nothing slides
|
|
232
|
+
// loose into the gutter. Widths from the same rough glyph metric the pills
|
|
233
|
+
// use; the crossing's label is centred on its tick.
|
|
234
|
+
const charW = theme.font.size * 0.62;
|
|
235
|
+
const contextWidth = (boundaryContext?.length ?? 0) * charW;
|
|
236
|
+
const contextRight = align === 'center'
|
|
237
|
+
? contextWidth / 2
|
|
238
|
+
: align === 'right'
|
|
239
|
+
? 4 + contextWidth
|
|
240
|
+
: contextWidth;
|
|
241
|
+
const firstCrossing = placed.find((t) => t.boundary !== undefined);
|
|
242
|
+
const crossingLeft = firstCrossing
|
|
243
|
+
? align === 'right'
|
|
244
|
+
? firstCrossing.x + 4
|
|
245
|
+
: firstCrossing.x - (firstCrossing.boundary.length * charW) / 2
|
|
246
|
+
: Infinity;
|
|
247
|
+
const showContext = boundaryContext !== undefined && crossingLeft > contextRight + 6;
|
|
164
248
|
const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
|
|
165
249
|
(hasBoundary ? BOUNDARY_STRIP : 0) +
|
|
166
250
|
maxPillLane * PILL_LANE_H;
|
|
@@ -170,10 +254,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
170
254
|
width: `${plotWidth}px`,
|
|
171
255
|
height: `${stripHeight}px`,
|
|
172
256
|
// The plot-facing edge carries the rule; a top axis rules its bottom.
|
|
173
|
-
[onTop ? 'borderBottom' : 'borderTop']: `1px solid ${theme.axis.grid}`,
|
|
257
|
+
[onTop ? 'borderBottom' : 'borderTop']: `1px solid ${color ?? theme.axis.grid}`,
|
|
174
258
|
fontFamily: theme.font.family,
|
|
175
259
|
fontSize: `${theme.font.size}px`,
|
|
176
|
-
color: theme.axis.label,
|
|
260
|
+
color: color ?? theme.axis.label,
|
|
177
261
|
}, children: [placed.map((t, i) => {
|
|
178
262
|
const isFirst = i === 0;
|
|
179
263
|
const isLast = i === placed.length - 1;
|
|
@@ -197,7 +281,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
197
281
|
[onTop ? 'bottom' : 'top']: 0,
|
|
198
282
|
width: '1px',
|
|
199
283
|
height: `${tickHeight}px`,
|
|
200
|
-
background: theme.axis.grid,
|
|
284
|
+
background: color ?? theme.axis.grid,
|
|
201
285
|
} }), _jsx("div", { style: {
|
|
202
286
|
position: 'absolute',
|
|
203
287
|
left: `${labelLeft}px`,
|
|
@@ -212,7 +296,14 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
212
296
|
whiteSpace: 'nowrap',
|
|
213
297
|
opacity: 0.75,
|
|
214
298
|
}, children: t.boundary }))] }, `${t.x}-${i}`));
|
|
215
|
-
}),
|
|
299
|
+
}), showContext && (_jsx("div", { "data-boundary-label": true, "data-boundary-context": true, style: {
|
|
300
|
+
position: 'absolute',
|
|
301
|
+
left: `${align === 'right' ? 4 : 0}px`,
|
|
302
|
+
[onTop ? 'bottom' : 'top']: `${(align === 'right' ? 2 : 6) + theme.font.size + 3}px`,
|
|
303
|
+
transform: align === 'center' ? 'translateX(-50%)' : 'none',
|
|
304
|
+
whiteSpace: 'nowrap',
|
|
305
|
+
opacity: 0.75,
|
|
306
|
+
}, children: boundaryContext })), label !== undefined && (_jsx("div", { style: {
|
|
216
307
|
position: 'absolute',
|
|
217
308
|
left: 0,
|
|
218
309
|
width: '100%',
|
|
@@ -220,7 +311,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
220
311
|
[onTop ? 'top' : 'bottom']: 0,
|
|
221
312
|
// Themeable axis-title text (shared with the rotated y-axis title).
|
|
222
313
|
fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
|
|
223
|
-
color: theme.axis.title?.color ?? theme.axis.label,
|
|
314
|
+
color: color ?? theme.axis.title?.color ?? theme.axis.label,
|
|
224
315
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
225
316
|
whiteSpace: 'nowrap',
|
|
226
317
|
}, children: label })), markerTags.map((t) => {
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -70,6 +70,14 @@ export interface YAxisProps {
|
|
|
70
70
|
boundaryLabels?: boolean;
|
|
71
71
|
/** Gutter width in CSS pixels (default 50). */
|
|
72
72
|
width?: number;
|
|
73
|
+
/**
|
|
74
|
+
* This axis instance's colour — tick labels and the axis title take it,
|
|
75
|
+
* overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
|
|
76
|
+
* convention of colouring each y axis to match its series (`color`
|
|
77
|
+
* matching the layer's) — busy, but standard. Omit for the theme's axis
|
|
78
|
+
* colours. Presentation-only: it never re-registers the axis.
|
|
79
|
+
*/
|
|
80
|
+
color?: string;
|
|
73
81
|
/**
|
|
74
82
|
* @internal Declaration position among the row's children, injected by
|
|
75
83
|
* `ChartRow` so the first-declared axis stays the default. Do not set.
|
|
@@ -84,5 +92,5 @@ export interface YAxisProps {
|
|
|
84
92
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
85
93
|
* (default: the first axis).
|
|
86
94
|
*/
|
|
87
|
-
export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
95
|
+
export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
88
96
|
//# sourceMappingURL=YAxis.d.ts.map
|
package/dist/YAxis.js
CHANGED
|
@@ -13,7 +13,7 @@ const TICK_COUNT = 5;
|
|
|
13
13
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
14
14
|
* (default: the first axis).
|
|
15
15
|
*/
|
|
16
|
-
export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', index = 0, }) {
|
|
16
|
+
export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
|
|
17
17
|
const container = useContext(ContainerContext);
|
|
18
18
|
if (container === null) {
|
|
19
19
|
throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
|
|
@@ -76,7 +76,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
|
|
|
76
76
|
height: `${row.height}px`,
|
|
77
77
|
fontFamily: theme.font.family,
|
|
78
78
|
fontSize: `${theme.font.size}px`,
|
|
79
|
-
color: theme.axis.label,
|
|
79
|
+
color: color ?? theme.axis.label,
|
|
80
80
|
}, children: [yScale &&
|
|
81
81
|
tickList.map(({ value, label }, i) => {
|
|
82
82
|
// Drop just the top & bottom labels when boundary labels are off
|
|
@@ -102,7 +102,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
|
|
|
102
102
|
// labels' alignment, rather than floating at the outer gutter edge.
|
|
103
103
|
[side === 'left' ? 'right' : 'left']: '4px',
|
|
104
104
|
fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
|
|
105
|
-
color: theme.axis.title?.color ?? theme.axis.label,
|
|
105
|
+
color: color ?? theme.axis.title?.color ?? theme.axis.label,
|
|
106
106
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
107
107
|
whiteSpace: 'nowrap',
|
|
108
108
|
pointerEvents: 'none',
|
|
@@ -116,7 +116,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
|
|
|
116
116
|
alignItems: 'center',
|
|
117
117
|
justifyContent: 'center',
|
|
118
118
|
fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
|
|
119
|
-
color: theme.axis.title?.color ?? theme.axis.label,
|
|
119
|
+
color: color ?? theme.axis.title?.color ?? theme.axis.label,
|
|
120
120
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
121
121
|
pointerEvents: 'none',
|
|
122
122
|
}, children: _jsx("span", { style: {
|
package/dist/annotations.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from '.
|
|
|
15
15
|
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
16
16
|
* label) so the static labels hold their lanes as it crosses them.
|
|
17
17
|
*/
|
|
18
|
-
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
|
|
18
|
+
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null, plotWidth?: number): Map<symbol, LabelPlacement>;
|
|
19
19
|
/**
|
|
20
20
|
* Snap a dragged plot-pixel `px` to the nearest **guideline** within
|
|
21
21
|
* {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
|
package/dist/annotations.js
CHANGED
|
@@ -169,7 +169,7 @@ const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
|
|
|
169
169
|
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
170
170
|
* label) so the static labels hold their lanes as it crosses them.
|
|
171
171
|
*/
|
|
172
|
-
export function computeLabelLanes(annotations, toPixel, draggingKey) {
|
|
172
|
+
export function computeLabelLanes(annotations, toPixel, draggingKey, plotWidth) {
|
|
173
173
|
const out = new Map();
|
|
174
174
|
const byRow = new Map();
|
|
175
175
|
for (const a of annotations) {
|
|
@@ -195,22 +195,33 @@ export function computeLabelLanes(annotations, toPixel, draggingKey) {
|
|
|
195
195
|
markerGroups.set(a.xs[0], [a]);
|
|
196
196
|
}
|
|
197
197
|
else {
|
|
198
|
+
// Lane-pack at the position the chip will *render*: a region panned
|
|
199
|
+
// half off-plot renders clamped to the plot's left edge, and a fully
|
|
200
|
+
// off-plot region's chip is culled — so it must not hold a lane.
|
|
198
201
|
const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
|
|
202
|
+
const bx = a.kind === 'region' ? Math.max(a.xs[0], a.xs[1]) : a.xs[0];
|
|
203
|
+
const rawLeft = toPixel(ax);
|
|
204
|
+
if (plotWidth !== undefined && (rawLeft > plotWidth || toPixel(bx) < 0))
|
|
205
|
+
continue;
|
|
199
206
|
flags.push({
|
|
200
207
|
rep: a.key,
|
|
201
208
|
members: [a.key],
|
|
202
|
-
left:
|
|
209
|
+
left: plotWidth === undefined ? rawLeft : Math.max(rawLeft, 0),
|
|
203
210
|
width: labelWidth(a.label),
|
|
204
211
|
label: a.label,
|
|
205
212
|
});
|
|
206
213
|
}
|
|
207
214
|
}
|
|
208
215
|
for (const [x, group] of markerGroups) {
|
|
216
|
+
// A culled off-plot marker chip must not hold a lane either.
|
|
217
|
+
const px = toPixel(x);
|
|
218
|
+
if (plotWidth !== undefined && (px < 0 || px > plotWidth))
|
|
219
|
+
continue;
|
|
209
220
|
const label = group.map((g) => g.label).join(', ');
|
|
210
221
|
flags.push({
|
|
211
222
|
rep: group[0].key,
|
|
212
223
|
members: group.map((g) => g.key),
|
|
213
|
-
left:
|
|
224
|
+
left: px,
|
|
214
225
|
width: labelWidth(label),
|
|
215
226
|
label,
|
|
216
227
|
});
|
|
@@ -473,7 +484,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
473
484
|
// into a lower lane doesn't leave line poking above it. No label ⇒ full height.
|
|
474
485
|
const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
|
|
475
486
|
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
|
|
476
|
-
+container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
487
|
+
+container.xScale.invert(px)) }))] }), chipLabel && x >= 0 && x <= container.plotWidth && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
477
488
|
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
478
489
|
...flagChipX(x, container.plotWidth),
|
|
479
490
|
}, children: chipLabel }))] }));
|
|
@@ -618,9 +629,9 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
618
629
|
+container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
|
|
619
630
|
edgeRef.current = from; // the fixed pivot = the near edge
|
|
620
631
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
621
|
-
+container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
632
|
+
+container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && left <= container.plotWidth && left + spanW >= 0 && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
622
633
|
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
623
|
-
...flagChipX(left, container.plotWidth),
|
|
634
|
+
...flagChipX(Math.max(left, 0), container.plotWidth),
|
|
624
635
|
}, children: text }))] }));
|
|
625
636
|
}
|
|
626
637
|
//# sourceMappingURL=annotations.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick layout for a **derived-unit axis** — a second labeling of the same
|
|
3
|
+
* scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
|
|
4
|
+
* relabelled as BS delta. The transform may be nonlinear, which stretches or
|
|
5
|
+
* compresses the derived unit across the pixel range — so a single uniform
|
|
6
|
+
* step can't work (uniform delta ticks pile up mid-axis and leave the
|
|
7
|
+
* stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
|
|
8
|
+
* walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
|
|
9
|
+
* candidate tick wherever it keeps `minPx` of room from every tick already
|
|
10
|
+
* placed. A compressed span ends up with coarse ticks, a stretched span picks
|
|
11
|
+
* up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
|
|
12
|
+
* 0.49` out in the wings). A linear transform degenerates to ordinary
|
|
13
|
+
* evenly-spaced nice ticks through the same code path.
|
|
14
|
+
*/
|
|
15
|
+
/** A derived-unit transform: `to`/`from` are monotonic inverses (either
|
|
16
|
+
* direction — a decreasing transform is fine); they may be nonlinear. */
|
|
17
|
+
export interface AxisTransform {
|
|
18
|
+
/** Axis value → derived unit (e.g. strike → moneyness). */
|
|
19
|
+
to(value: number): number;
|
|
20
|
+
/** Derived unit → axis value (inverse of {@link to}). */
|
|
21
|
+
from(unit: number): number;
|
|
22
|
+
}
|
|
23
|
+
/** One derived tick: its value in the derived unit and its plot pixel. */
|
|
24
|
+
export interface DerivedTick {
|
|
25
|
+
readonly u: number;
|
|
26
|
+
readonly x: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compute the derived-unit ticks: nice values in `transform.to`-space at
|
|
30
|
+
* mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
|
|
31
|
+
* mapped pixel keeps `minPx` from every tick already placed (and stays inside
|
|
32
|
+
* `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
|
|
33
|
+
* without a DOM.
|
|
34
|
+
*/
|
|
35
|
+
export declare function derivedTicks(transform: AxisTransform, domain: readonly [number, number], toPixel: (value: number) => number, plotWidth: number, minPx: number): DerivedTick[];
|
|
36
|
+
//# sourceMappingURL=derivedTicks.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick layout for a **derived-unit axis** — a second labeling of the same
|
|
3
|
+
* scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
|
|
4
|
+
* relabelled as BS delta. The transform may be nonlinear, which stretches or
|
|
5
|
+
* compresses the derived unit across the pixel range — so a single uniform
|
|
6
|
+
* step can't work (uniform delta ticks pile up mid-axis and leave the
|
|
7
|
+
* stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
|
|
8
|
+
* walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
|
|
9
|
+
* candidate tick wherever it keeps `minPx` of room from every tick already
|
|
10
|
+
* placed. A compressed span ends up with coarse ticks, a stretched span picks
|
|
11
|
+
* up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
|
|
12
|
+
* 0.49` out in the wings). A linear transform degenerates to ordinary
|
|
13
|
+
* evenly-spaced nice ticks through the same code path.
|
|
14
|
+
*/
|
|
15
|
+
/** The largest 1-2-5 nice step ≤ `span` (so the coarsest level yields at
|
|
16
|
+
* least one interval across the domain). */
|
|
17
|
+
function firstStep(span) {
|
|
18
|
+
const pow = 10 ** Math.floor(Math.log10(span));
|
|
19
|
+
for (const m of [5, 2, 1]) {
|
|
20
|
+
if (m * pow <= span)
|
|
21
|
+
return m * pow;
|
|
22
|
+
}
|
|
23
|
+
return pow / 2; // span < pow can't happen (pow ≤ span), belt-and-braces
|
|
24
|
+
}
|
|
25
|
+
/** The next step down the 1-2-5 ladder: 5→2→1→0.5→0.2→0.1… */
|
|
26
|
+
function nextFiner(step) {
|
|
27
|
+
const pow = 10 ** Math.floor(Math.log10(step));
|
|
28
|
+
const m = Math.round(step / pow);
|
|
29
|
+
if (m === 5)
|
|
30
|
+
return 2 * pow;
|
|
31
|
+
if (m === 2)
|
|
32
|
+
return pow;
|
|
33
|
+
return pow / 2;
|
|
34
|
+
}
|
|
35
|
+
/** Per-level enumeration cap — a backstop against a pathological transform
|
|
36
|
+
* requesting a step so fine the candidate walk explodes. Generous: the
|
|
37
|
+
* reference delta axis enumerates ~100 candidates at its finest level. */
|
|
38
|
+
const MAX_CANDIDATES = 4000;
|
|
39
|
+
/**
|
|
40
|
+
* Compute the derived-unit ticks: nice values in `transform.to`-space at
|
|
41
|
+
* mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
|
|
42
|
+
* mapped pixel keeps `minPx` from every tick already placed (and stays inside
|
|
43
|
+
* `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
|
|
44
|
+
* without a DOM.
|
|
45
|
+
*/
|
|
46
|
+
export function derivedTicks(transform, domain, toPixel, plotWidth, minPx) {
|
|
47
|
+
const ua = transform.to(domain[0]);
|
|
48
|
+
const ub = transform.to(domain[1]);
|
|
49
|
+
const u0 = Math.min(ua, ub);
|
|
50
|
+
const u1 = Math.max(ua, ub);
|
|
51
|
+
if (!Number.isFinite(u0) ||
|
|
52
|
+
!Number.isFinite(u1) ||
|
|
53
|
+
u1 <= u0 ||
|
|
54
|
+
!(plotWidth > 0)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const kept = [];
|
|
58
|
+
const fits = (x) => x >= 0 && x <= plotWidth && kept.every((k) => Math.abs(k.x - x) >= minPx);
|
|
59
|
+
// More ticks than the plot has room for can never be admitted.
|
|
60
|
+
const maxTicks = Math.ceil(plotWidth / minPx) + 2;
|
|
61
|
+
let step = firstStep(u1 - u0);
|
|
62
|
+
// An empty level does NOT end the walk: under a nonlinear transform a
|
|
63
|
+
// pixel-wide gap can have a tiny u-span (the delta wings — no 0.1-grid
|
|
64
|
+
// value lands in [0.4, 0.4987], but 0.45 on the 0.05 grid does), so finer
|
|
65
|
+
// levels may fill where a coarser one placed nothing. Several *consecutive*
|
|
66
|
+
// empty levels mean the remaining gaps' u-spans are being outrun faster
|
|
67
|
+
// than the ladder descends — give up then (plus the enumeration backstop).
|
|
68
|
+
let emptyLevels = 0;
|
|
69
|
+
for (let level = 0; level < 24 && kept.length < maxTicks; level++) {
|
|
70
|
+
const i0 = Math.ceil(u0 / step - 1e-9);
|
|
71
|
+
const i1 = Math.floor(u1 / step + 1e-9);
|
|
72
|
+
if (i1 - i0 > MAX_CANDIDATES)
|
|
73
|
+
break;
|
|
74
|
+
let added = 0;
|
|
75
|
+
for (let i = i0; i <= i1 && kept.length < maxTicks; i++) {
|
|
76
|
+
// Clean the float (0.3, not 0.30000000000000004) — the raw `u` reaches
|
|
77
|
+
// a caller-supplied format function, so it must be presentable.
|
|
78
|
+
const u = Number((i * step).toPrecision(12));
|
|
79
|
+
const x = toPixel(transform.from(u));
|
|
80
|
+
if (Number.isFinite(x) && fits(x)) {
|
|
81
|
+
kept.push({ u, x });
|
|
82
|
+
added += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
emptyLevels = added === 0 && level > 0 ? emptyLevels + 1 : 0;
|
|
86
|
+
if (emptyLevels >= 3)
|
|
87
|
+
break;
|
|
88
|
+
step = nextFiner(step);
|
|
89
|
+
}
|
|
90
|
+
return kept.sort((a, b) => a.x - b.x);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=derivedTicks.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export { YAxis } from './YAxis.js';
|
|
|
28
28
|
export type { YAxisProps } from './YAxis.js';
|
|
29
29
|
export { XAxis } from './XAxis.js';
|
|
30
30
|
export type { XAxisProps } from './XAxis.js';
|
|
31
|
+
export type { AxisTransform } from './derivedTicks.js';
|
|
31
32
|
export { TimeAxis } from './TimeAxis.js';
|
|
32
33
|
export { CategoryAxis } from './CategoryAxis.js';
|
|
33
34
|
export type { AxisFormat } from './format.js';
|
package/dist/tickLadder.d.ts
CHANGED
|
@@ -79,11 +79,14 @@ export declare function majorFormatFor(g: TickGranularity): string;
|
|
|
79
79
|
* (`Jan 2026` under a `Jan 05` tick reads as noise). */
|
|
80
80
|
export declare function boundaryFormatFor(g: TickGranularity): string;
|
|
81
81
|
/**
|
|
82
|
-
* Which of `ticks` (at grain `granularity`) carry a boundary label:
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
82
|
+
* Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
|
|
83
|
+
* whose boundary-grain bucket differs from the previous tick's — i.e. a
|
|
84
|
+
* **crossing**, the first tick of a new day / year. The first tick is *not*
|
|
85
|
+
* automatically flagged: the reader's left-edge context is the pinned
|
|
86
|
+
* {@link TradingTimeScale.boundaryContext} label (a property of the domain
|
|
87
|
+
* start, not of any tick — anchoring it to the first tick made it hop
|
|
88
|
+
* tick-to-tick on a live sliding window). Empty when the grain has no
|
|
89
|
+
* boundary row (year grain).
|
|
87
90
|
*/
|
|
88
|
-
export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity): number[];
|
|
91
|
+
export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): number[];
|
|
89
92
|
//# sourceMappingURL=tickLadder.d.ts.map
|
package/dist/tickLadder.js
CHANGED
|
@@ -154,13 +154,26 @@ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
|
|
|
154
154
|
export function buildTicks(provider, opens, domainEnd, cap) {
|
|
155
155
|
const result = (() => {
|
|
156
156
|
if (opens.length <= cap) {
|
|
157
|
+
// Pick the clock rung from the **live-span estimate**, not the
|
|
158
|
+
// enumerated anchor count. On a live chart the domain slides every
|
|
159
|
+
// frame, and the number of aligned marks inside a sliding window
|
|
160
|
+
// oscillates ±1 with its phase — an exact count sitting at the cap
|
|
161
|
+
// flips the grain back and forth for single frames (the LiveSine
|
|
162
|
+
// flicker). The span is constant while sliding, so the estimate is
|
|
163
|
+
// stable; the enumerated count may then exceed the cap by a tick or
|
|
164
|
+
// two at some phases, which the per-tick pixel budget absorbs.
|
|
165
|
+
const liveSpan = provider.distance(opens[0], domainEnd);
|
|
157
166
|
for (const { g, step } of SUB_DAY_GRAINS) {
|
|
158
|
-
|
|
167
|
+
if (opens.length + Math.floor(liveSpan / step) > cap)
|
|
168
|
+
continue;
|
|
169
|
+
const ticks = stepAnchors(provider, opens, domainEnd, step, cap + 4);
|
|
159
170
|
// A clock rung must earn its labels: if it adds no intraday anchor
|
|
160
171
|
// 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
|
|
162
|
-
|
|
172
|
+
// "09:30"s under every session is a worse day axis, not a clock
|
|
173
|
+
// axis) — and every coarser rung would earn even less.
|
|
174
|
+
if (ticks.length > opens.length)
|
|
163
175
|
return { ticks, granularity: g };
|
|
176
|
+
break;
|
|
164
177
|
}
|
|
165
178
|
return { ticks: [...opens], granularity: 'day' };
|
|
166
179
|
}
|
|
@@ -242,21 +255,29 @@ export function boundaryFormatFor(g) {
|
|
|
242
255
|
return g === 'day' ? '%b %d' : '%Y';
|
|
243
256
|
}
|
|
244
257
|
/**
|
|
245
|
-
* Which of `ticks` (at grain `granularity`) carry a boundary label:
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
258
|
+
* Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
|
|
259
|
+
* whose boundary-grain bucket differs from the previous tick's — i.e. a
|
|
260
|
+
* **crossing**, the first tick of a new day / year. The first tick is *not*
|
|
261
|
+
* automatically flagged: the reader's left-edge context is the pinned
|
|
262
|
+
* {@link TradingTimeScale.boundaryContext} label (a property of the domain
|
|
263
|
+
* start, not of any tick — anchoring it to the first tick made it hop
|
|
264
|
+
* tick-to-tick on a live sliding window). Empty when the grain has no
|
|
265
|
+
* boundary row (year grain).
|
|
250
266
|
*/
|
|
251
|
-
export function boundaryTicks(ticks, granularity) {
|
|
267
|
+
export function boundaryTicks(ticks, granularity, domainStart) {
|
|
252
268
|
const bg = boundaryGrainFor(granularity);
|
|
253
269
|
if (bg === undefined)
|
|
254
270
|
return [];
|
|
255
271
|
const out = [];
|
|
256
|
-
|
|
272
|
+
// Seed with the domain start's bucket when given: a first tick in a
|
|
273
|
+
// different period than the left edge IS a crossing (a 23:55-anchored
|
|
274
|
+
// window whose cramped 23:55 lead was dropped still marks 00:00 as the
|
|
275
|
+
// day turn); a first tick in the same period is not (no tick-hopping
|
|
276
|
+
// context on a live window).
|
|
277
|
+
let prev = domainStart !== undefined ? bucketKey(domainStart, bg) : undefined;
|
|
257
278
|
for (const t of ticks) {
|
|
258
279
|
const k = bucketKey(t, bg);
|
|
259
|
-
if (prev
|
|
280
|
+
if (prev !== undefined && k !== prev)
|
|
260
281
|
out.push(t);
|
|
261
282
|
prev = k;
|
|
262
283
|
}
|
|
@@ -74,12 +74,21 @@ export interface TradingTimeScale {
|
|
|
74
74
|
/**
|
|
75
75
|
* The **second-row** (boundary) label for a tick value, or `undefined` for
|
|
76
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:
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* {@link ticks} at the same `count`, so the rows agree: each tick starting
|
|
78
|
+
* a new day / year (whichever is the next-coarser unit the first-row label
|
|
79
|
+
* omits) carries the label — a **crossing**. The left-edge context (what
|
|
80
|
+
* period the domain starts in) is {@link boundaryContext}, pinned by the
|
|
81
|
+
* axis rather than riding a tick; year-grain ticks have no second row.
|
|
81
82
|
*/
|
|
82
83
|
tickBoundaries(count?: number): (value: number) => string | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* The boundary-row label for the **domain start** — the reader's left-edge
|
|
86
|
+
* context (`Jan 01` over an intraday axis, the year over a month axis),
|
|
87
|
+
* rendered pinned at the plot's left edge. A property of the domain, not of
|
|
88
|
+
* any tick — so it stays put on a live sliding window instead of hopping
|
|
89
|
+
* from tick to tick. `undefined` when the grain has no boundary row.
|
|
90
|
+
*/
|
|
91
|
+
boundaryContext(count?: number): string | undefined;
|
|
83
92
|
domain(): [number, number];
|
|
84
93
|
domain(next: readonly [number, number]): TradingTimeScale;
|
|
85
94
|
range(): [number, number];
|
package/dist/tradingTimeScale.js
CHANGED
|
@@ -125,11 +125,21 @@ export function scaleTradingTime(provider) {
|
|
|
125
125
|
return () => undefined;
|
|
126
126
|
const fmt = base.tickFormat(count, boundaryFormatFor(bg));
|
|
127
127
|
const labelled = new Map();
|
|
128
|
-
for (const t of boundaryTicks(ticks, granularity)) {
|
|
128
|
+
for (const t of boundaryTicks(ticks, granularity, domain[0])) {
|
|
129
129
|
labelled.set(t, fmt(new Date(t)));
|
|
130
130
|
}
|
|
131
131
|
return (value) => labelled.get(value);
|
|
132
132
|
};
|
|
133
|
+
scale.boundaryContext = (count = 10) => {
|
|
134
|
+
if (!hasCalendar())
|
|
135
|
+
return undefined;
|
|
136
|
+
const { granularity } = resolved(count);
|
|
137
|
+
const bg = boundaryGrainFor(granularity);
|
|
138
|
+
if (bg === undefined)
|
|
139
|
+
return undefined;
|
|
140
|
+
const fmt = base.tickFormat(count, boundaryFormatFor(bg));
|
|
141
|
+
return fmt(new Date(domain[0]));
|
|
142
|
+
};
|
|
133
143
|
function domainFn(next) {
|
|
134
144
|
if (next === undefined)
|
|
135
145
|
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.46.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.46.0",
|
|
42
|
+
"pond-ts": "^0.46.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|