@pond-ts/charts 0.44.1 → 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 +143 -1
- package/dist/ChartContainer.js +19 -8
- package/dist/LineChart.d.ts +15 -1
- package/dist/LineChart.js +17 -2
- package/dist/XAxis.d.ts +27 -1
- package/dist/XAxis.js +138 -21
- 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/context.d.ts +4 -0
- package/dist/derivedTicks.d.ts +36 -0
- package/dist/derivedTicks.js +92 -0
- package/dist/index.d.ts +1 -0
- package/dist/line.d.ts +25 -1
- package/dist/line.js +78 -13
- package/dist/tickLadder.d.ts +92 -0
- package/dist/tickLadder.js +286 -0
- package/dist/tradingTimeScale.d.ts +38 -26
- package/dist/tradingTimeScale.js +90 -88
- package/package.json +3 -3
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
|
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
|
|
@@ -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/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,92 @@
|
|
|
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: 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).
|
|
90
|
+
*/
|
|
91
|
+
export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): number[];
|
|
92
|
+
//# sourceMappingURL=tickLadder.d.ts.map
|