@pond-ts/charts 0.62.0 → 0.64.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/API.md +1 -1
- package/CHANGELOG.md +223 -1
- package/dist/BarChart.js +6 -6
- package/dist/ChartContainer.d.ts +72 -0
- package/dist/ChartContainer.js +151 -24
- package/dist/ChartRow.d.ts +39 -3
- package/dist/ChartRow.js +194 -7
- package/dist/Layers.js +25 -8
- package/dist/XAxis.d.ts +6 -0
- package/dist/XAxis.js +81 -3
- package/dist/YAxis.d.ts +92 -2
- package/dist/YAxis.js +268 -6
- package/dist/annotations.js +14 -7
- package/dist/bars.d.ts +21 -8
- package/dist/bars.js +38 -12
- package/dist/chip.d.ts +50 -7
- package/dist/chip.js +66 -9
- package/dist/context.d.ts +134 -7
- package/dist/cursors.js +33 -8
- package/dist/domain.d.ts +18 -0
- package/dist/domain.js +33 -0
- package/dist/select.d.ts +1 -1
- package/dist/select.js +7 -2
- package/dist/tradingTimeScale.d.ts +11 -1
- package/dist/tradingTimeScale.js +53 -7
- package/dist/use-axis-gestures.d.ts +79 -0
- package/dist/use-axis-gestures.js +257 -0
- package/package.json +3 -3
package/dist/chip.js
CHANGED
|
@@ -96,17 +96,74 @@ export function pointerStyle(side, color) {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
|
-
* CSS placing a value pill **on
|
|
100
|
-
* edge at the plot boundary (`plotWidth`)
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
99
|
+
* CSS placing a value pill **on an axis gutter** at `side`: anchor its inner
|
|
100
|
+
* edge at that axis's inner edge — the plot boundary (`plotWidth`) plus the
|
|
101
|
+
* axis's own `offset` out into the gutter — and let it overflow outward (the
|
|
102
|
+
* plot div doesn't clip), lifted with `zIndex` above the sibling axis columns
|
|
103
|
+
* (rendered later in the row) so it covers the tick behind it. The one placement
|
|
104
|
+
* every on-axis pill goes through — the crosshair cursor's value pill, a
|
|
105
|
+
* `<Baseline indicator>`, and {@link YAxisIndicator} — so they cannot drift
|
|
106
|
+
* apart.
|
|
107
|
+
*
|
|
108
|
+
* `offset` is `0` for the innermost axis on a side (the single-axis case, and the
|
|
109
|
+
* behaviour before it existed) and the reserved widths of the axes nearer the
|
|
110
|
+
* plot for one further out — {@link RowFrame.axisOffsets}. Passing it is what
|
|
111
|
+
* puts the pill on the axis whose scale produced the number, rather than on
|
|
112
|
+
* whichever axis happens to sit against the plot. **`YAxisIndicator` does not
|
|
113
|
+
* pass it** and so still lands innermost: it takes an explicit `side` beside its
|
|
114
|
+
* `axis`, and what an offset should mean when those two disagree is unsettled
|
|
115
|
+
* (see `[PND-XHAIRAXIS]` in the charts plan).
|
|
116
|
+
*
|
|
117
|
+
* A pill is deliberately unclipped, so a long formatted value can overflow past
|
|
118
|
+
* the gutter it sits in — further out for an outer-axis pill, which has only its
|
|
119
|
+
* own column left before the container's edge. Sized-to-content and unclipped
|
|
120
|
+
* beats truncating a number, but a very wide readout on a narrow outer axis will
|
|
121
|
+
* spill outside the chart box.
|
|
105
122
|
*/
|
|
106
|
-
export function axisPillX(side, plotWidth) {
|
|
123
|
+
export function axisPillX(side, plotWidth, offset = 0) {
|
|
124
|
+
const inner = plotWidth + offset;
|
|
107
125
|
return side === 'right'
|
|
108
|
-
? { left: `${
|
|
109
|
-
: { right: `${
|
|
126
|
+
? { left: `${inner}px`, zIndex: 3 }
|
|
127
|
+
: { right: `${inner}px`, zIndex: 3 };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The **connector** for a pill placed further out than the innermost axis: a 1px
|
|
131
|
+
* bridge from the plot's `side` edge across `offset` px of gutter to the pill's
|
|
132
|
+
* inner edge, so the in-plot line and its pill read as one object rather than as
|
|
133
|
+
* a value floating in a gutter two columns away. The y-side twin of the
|
|
134
|
+
* crosshair's x-axis time connector, which exists for exactly this reason.
|
|
135
|
+
*
|
|
136
|
+
* The caller positions it vertically (`top` + a `translateY(-50%)`), at the
|
|
137
|
+
* **pill's** centre rather than the raw value's — the two agree except where the
|
|
138
|
+
* pill is clamped inside the row, and a connector attached to the pill is what
|
|
139
|
+
* sells them as one object.
|
|
140
|
+
*
|
|
141
|
+
* In the pill's own colour and above the axis column (`zIndex`, as the pill is),
|
|
142
|
+
* but at **half opacity** — unlike the x-axis time connector, which is solid.
|
|
143
|
+
* The difference is what each one crosses: the time connector runs over an empty
|
|
144
|
+
* strip, while this one runs over *another axis's tick labels* (measured: a
|
|
145
|
+
* connector at a value whose neighbouring axis has a tick at the same height
|
|
146
|
+
* overlaps that label's glyphs). Half opacity keeps the labels legible and reads
|
|
147
|
+
* the bridge as subordinate chrome — the weight the flag cursor's staffs already
|
|
148
|
+
* use for "this line only connects two things I have drawn".
|
|
149
|
+
*
|
|
150
|
+
* Only drawn when `offset > 0`: at offset `0` the pill already touches the plot
|
|
151
|
+
* edge where the line ends, so a connector would be zero-length ink over a tick
|
|
152
|
+
* label for nothing.
|
|
153
|
+
*/
|
|
154
|
+
export function axisPillConnector(side, plotWidth, offset, color) {
|
|
155
|
+
return {
|
|
156
|
+
position: 'absolute',
|
|
157
|
+
...(side === 'right'
|
|
158
|
+
? { left: `${plotWidth}px` }
|
|
159
|
+
: { right: `${plotWidth}px` }),
|
|
160
|
+
width: `${offset}px`,
|
|
161
|
+
height: '1px',
|
|
162
|
+
background: color,
|
|
163
|
+
opacity: 0.5,
|
|
164
|
+
pointerEvents: 'none',
|
|
165
|
+
zIndex: 3,
|
|
166
|
+
};
|
|
110
167
|
}
|
|
111
168
|
/** Gap (px) between a flag chip and its pole — the cursor staff or an annotation's
|
|
112
169
|
* line — so the chip floats just beside the pole rather than sitting on it. */
|
package/dist/context.d.ts
CHANGED
|
@@ -40,7 +40,27 @@ export interface LabelPlacement {
|
|
|
40
40
|
export type ChartXScale = ScaleTime<number, number> | ScaleLinear<number, number> | ScaleLogarithmic<number, number> | ScaleSymLog<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
|
|
41
41
|
export interface ContainerFrame {
|
|
42
42
|
readonly timeRange: readonly [number, number];
|
|
43
|
+
/**
|
|
44
|
+
* The **declared** view — the container's `range` prop, normalized — as
|
|
45
|
+
* against {@link timeRange}, which is where gestures have moved it. The
|
|
46
|
+
* x-axis strip's double-click reset returns here.
|
|
47
|
+
*
|
|
48
|
+
* On a **controlled** chart (one passing `onTimeRangeChange`) the two are the
|
|
49
|
+
* same object of truth by construction: the consumer owns the view, so `range`
|
|
50
|
+
* *is* the panned view and there is no declared home to go back to. The reset
|
|
51
|
+
* is then a no-op, and a consumer who wants one holds their own home range and
|
|
52
|
+
* wires it through `onMouseEvent` — which is exactly the shape of that job.
|
|
53
|
+
*/
|
|
54
|
+
readonly seedRange: readonly [number, number];
|
|
43
55
|
readonly width: number;
|
|
56
|
+
/**
|
|
57
|
+
* Whether the container is managing vertical layout ([PND-HEIGHT]) — it was
|
|
58
|
+
* given a `height` (number or `'auto'`), renders as a flex column, and flex
|
|
59
|
+
* rows have real space to divide. `false` is the classic mode: rows declare
|
|
60
|
+
* pixel heights and the container's height is their sum. A `<ChartRow
|
|
61
|
+
* flex>` reads this to warn when mounted somewhere it can never resolve.
|
|
62
|
+
*/
|
|
63
|
+
readonly managesHeight: boolean;
|
|
44
64
|
readonly theme: ChartTheme;
|
|
45
65
|
/** Plot width in px after the gutters — shared by every row. */
|
|
46
66
|
readonly plotWidth: number;
|
|
@@ -427,6 +447,16 @@ export interface ContainerFrame {
|
|
|
427
447
|
* the **aspect ratio** fixed. The x half stays in domain space, where
|
|
428
448
|
* `bounds`, `minDuration` and the trading-calendar zoom maths live.
|
|
429
449
|
*/
|
|
450
|
+
/**
|
|
451
|
+
* Whether the **axis strips** take gestures, from `<ChartContainer axisPanZoom>`
|
|
452
|
+
* — resolved per dimension, and independent of the plot's own
|
|
453
|
+
* {@link zoomX}/{@link zoomY}. `x` gives the `<XAxis>` strip the canvas
|
|
454
|
+
* gesture (drag pans, wheel zooms); `y` makes each `<YAxis>` gutter scale its
|
|
455
|
+
* own axis. Both default to `false`, so a chart that doesn't ask keeps inert
|
|
456
|
+
* axes however interactive its plot is.
|
|
457
|
+
*/
|
|
458
|
+
readonly axisPanZoomX: boolean;
|
|
459
|
+
readonly axisPanZoomY: boolean;
|
|
430
460
|
/** Which axes the gestures own; pan follows zoom's degrees of freedom. */
|
|
431
461
|
readonly zoomX: boolean;
|
|
432
462
|
readonly zoomY: boolean;
|
|
@@ -792,7 +822,7 @@ export interface RowLayer {
|
|
|
792
822
|
* chart. Layers whose hit target is already the drawn mark (stacks,
|
|
793
823
|
* scatter, boxes, heat cells) ignore `mode`.
|
|
794
824
|
*/
|
|
795
|
-
hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select'): SelectInfo | null;
|
|
825
|
+
hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number, mode?: 'hover' | 'select', baseYScale?: (value: number) => number): SelectInfo | null;
|
|
796
826
|
/**
|
|
797
827
|
* Begin a **sweep session** over this layer's marks — `<MultiSelector>`'s
|
|
798
828
|
* range query (interaction RFC A7.6/A7.7), the range analog of
|
|
@@ -866,8 +896,18 @@ export interface RowLayer {
|
|
|
866
896
|
* {@link LayerDrawStats} (source/drawn counts + whether decimation engaged) so
|
|
867
897
|
* the container can surface them via {@link ContainerProps.onDrawStats}; a
|
|
868
898
|
* layer that returns `void` still contributes its measured `drawMs`.
|
|
869
|
-
|
|
870
|
-
|
|
899
|
+
*
|
|
900
|
+
* `baseYScale` is this axis's *declared* scale — resolved from `min`/`max`/
|
|
901
|
+
* auto-fit, before either the container's or this axis's own pan/zoom pixel
|
|
902
|
+
* transform narrows it (the same scale `row.baseYScales` exposes). A layer
|
|
903
|
+
* whose geometry depends on where the domain sits relative to a fixed value
|
|
904
|
+
* (a bar or stack resting on zero — see `resolveBarBaseline`) reads *that*
|
|
905
|
+
* domain for the decision, not `yScale`'s: the live scale is a *viewport*
|
|
906
|
+
* onto the axis, and a pan sliding that viewport away from zero must not
|
|
907
|
+
* relocate the bars' own baseline out from under them. Only bars currently
|
|
908
|
+
* use it; every other layer type ignores the extra argument.
|
|
909
|
+
*/
|
|
910
|
+
draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number, baseYScale?: (value: number) => number): LayerDrawStats | void;
|
|
871
911
|
}
|
|
872
912
|
/**
|
|
873
913
|
* A layer's per-drag **sweep session** ({@link RowLayer.beginSweep} — RFC
|
|
@@ -1356,15 +1396,24 @@ export type CursorSnapX = 'none' | 'sample' | 'sequence';
|
|
|
1356
1396
|
/**
|
|
1357
1397
|
* One resolved per-series measurement at the cursor — **finished numbers, not
|
|
1358
1398
|
* raw materials** (interaction RFC A2.3): the sample's plot pixels, the axis it
|
|
1359
|
-
* scales against (id + side, so a pill can
|
|
1360
|
-
* already formatted by that axis's
|
|
1361
|
-
* never sees a scale, a format map, or
|
|
1399
|
+
* scales against (id + side + gutter offset + colour, so a pill can sit *on
|
|
1400
|
+
* that* axis in *its* ink), and its value already formatted by that axis's
|
|
1401
|
+
* formatter. A cursor slot draws these; it never sees a scale, a format map, or
|
|
1402
|
+
* an axis-side map.
|
|
1362
1403
|
*/
|
|
1363
1404
|
export interface ResolvedCursorSample {
|
|
1364
1405
|
readonly px: number;
|
|
1365
1406
|
readonly py: number;
|
|
1366
1407
|
readonly axisId: string;
|
|
1367
1408
|
readonly side: 'left' | 'right';
|
|
1409
|
+
/** Distance in px from the plot's `side` edge to this axis's inner edge —
|
|
1410
|
+
* `0` for the innermost axis, the reserved widths of the axes between it and
|
|
1411
|
+
* the plot otherwise ({@link RowFrame.axisOffsets}). What lets an axis-edge
|
|
1412
|
+
* pill land on the axis that measured the value rather than the innermost. */
|
|
1413
|
+
readonly axisOffset: number;
|
|
1414
|
+
/** This axis's own `<YAxis color>`, or `undefined` for the theme's axis ink
|
|
1415
|
+
* ({@link RowFrame.axisColors}) — so an axis-edge pill matches its axis. */
|
|
1416
|
+
readonly axisColor: string | undefined;
|
|
1368
1417
|
readonly formatted: string;
|
|
1369
1418
|
readonly color: string;
|
|
1370
1419
|
readonly label: string;
|
|
@@ -1406,7 +1455,8 @@ export interface ResolvedCursorFrame {
|
|
|
1406
1455
|
readonly flags: readonly ResolvedCursorFlag[];
|
|
1407
1456
|
/**
|
|
1408
1457
|
* The **raw pointer**'s y resolved against the row's default axis — position,
|
|
1409
|
-
* formatted value, and axis side
|
|
1458
|
+
* formatted value, and that axis's placement (side + gutter offset + colour,
|
|
1459
|
+
* as on {@link ResolvedCursorSample}) — or `null` when this row isn't hovered.
|
|
1410
1460
|
* The free (non-snapping) crosshair reads this; it is resolved here because a
|
|
1411
1461
|
* slot has no `yScale.invert` to do it itself.
|
|
1412
1462
|
*/
|
|
@@ -1414,6 +1464,8 @@ export interface ResolvedCursorFrame {
|
|
|
1414
1464
|
readonly py: number;
|
|
1415
1465
|
readonly formatted: string;
|
|
1416
1466
|
readonly side: 'left' | 'right';
|
|
1467
|
+
readonly axisOffset: number;
|
|
1468
|
+
readonly axisColor: string | undefined;
|
|
1417
1469
|
} | null;
|
|
1418
1470
|
/** The range cursor's **band** under the pointer (bucket-snapped via the
|
|
1419
1471
|
* declared sequence, else the drag span), as clamped plot pixels; `null`
|
|
@@ -1661,6 +1713,11 @@ export interface AxisSpec {
|
|
|
1661
1713
|
* target; `undefined` derives the count from the row height (see
|
|
1662
1714
|
* {@link resolveYTickCount}). Ignored when {@link tickValues} is set. */
|
|
1663
1715
|
readonly tickCount: number | undefined;
|
|
1716
|
+
/** This axis's own ink (`<YAxis color>`), or `undefined` for the theme's.
|
|
1717
|
+
* Registered — not merely rendered — because the axis-edge chrome the row
|
|
1718
|
+
* and the cursor draw (the crosshair's value pill) has to match the axis it
|
|
1719
|
+
* sits on, and only the registry knows every axis. */
|
|
1720
|
+
readonly color: string | undefined;
|
|
1664
1721
|
/**
|
|
1665
1722
|
* Declaration position among the row's children, injected by `ChartRow`. The
|
|
1666
1723
|
* row sorts axes by this, so the **first declared** axis is the default
|
|
@@ -1689,6 +1746,52 @@ export interface RowFrame {
|
|
|
1689
1746
|
*/
|
|
1690
1747
|
readonly topInset: number;
|
|
1691
1748
|
readonly yScales: ReadonlyMap<string, YScale>;
|
|
1749
|
+
/**
|
|
1750
|
+
* Each axis's scale **before** any gesture transform — the domain it resolved
|
|
1751
|
+
* to from `min`/`max`/`pad`/auto-fit, which is the space a controlled
|
|
1752
|
+
* consumer's bounds live in.
|
|
1753
|
+
*
|
|
1754
|
+
* {@link yScales} carries the *visible* scales: the uniform
|
|
1755
|
+
* {@link ContainerFrame.yTransform} and the axis's own
|
|
1756
|
+
* {@link axisTransforms} entry have already narrowed them. A gutter gesture
|
|
1757
|
+
* that reported values read off those and had them fed back as `min`/`max`
|
|
1758
|
+
* would have the transforms applied a second time — the visible domain then
|
|
1759
|
+
* diverges from every value the consumer was told.
|
|
1760
|
+
*/
|
|
1761
|
+
readonly baseYScales: ReadonlyMap<string, YScale>;
|
|
1762
|
+
/**
|
|
1763
|
+
* **Per-axis** pixel zoom, keyed by axis id — what a drag on that axis's
|
|
1764
|
+
* gutter produces, layered *under* the container's uniform
|
|
1765
|
+
* {@link ContainerFrame.yTransform}. Identity (`{ k: 1, ty: 0 }`) for any axis
|
|
1766
|
+
* nobody has grabbed, which is every axis until one is.
|
|
1767
|
+
*
|
|
1768
|
+
* The uniform transform exists precisely so a *plot* gesture never has to
|
|
1769
|
+
* answer "which of this row's axes does a vertical drag own?" — see
|
|
1770
|
+
* {@link ContainerFrame.yTransform}. Grabbing one gutter answers it by
|
|
1771
|
+
* construction, and this is where that answer lives. Both are applied the same
|
|
1772
|
+
* way (narrowing the domain to the window the transform makes visible), the
|
|
1773
|
+
* uniform one first, so an axis carrying both reads as an ordinary axis over
|
|
1774
|
+
* the doubly-narrowed window and nothing downstream knows either exists.
|
|
1775
|
+
*
|
|
1776
|
+
* Unlike the uniform transform this is **not** floored at `k ≥ 1`: that floor
|
|
1777
|
+
* stops a plot gesture zooming every axis out past its natural fit into blank
|
|
1778
|
+
* canvas, whereas squashing one axis you deliberately grabbed is the point of
|
|
1779
|
+
* the gesture (and costs nothing — a `k < 1` widens the domain rather than
|
|
1780
|
+
* exposing empty plot).
|
|
1781
|
+
*/
|
|
1782
|
+
readonly axisTransforms: ReadonlyMap<string, {
|
|
1783
|
+
readonly k: number;
|
|
1784
|
+
readonly ty: number;
|
|
1785
|
+
}>;
|
|
1786
|
+
/**
|
|
1787
|
+
* Set one axis's {@link axisTransforms} entry — the y counterpart of
|
|
1788
|
+
* {@link ContainerFrame.applyRange}. Passing identity clears it (the
|
|
1789
|
+
* double-click reset).
|
|
1790
|
+
*/
|
|
1791
|
+
applyAxisTransform(id: string, next: {
|
|
1792
|
+
k: number;
|
|
1793
|
+
ty: number;
|
|
1794
|
+
}): void;
|
|
1692
1795
|
/** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
|
|
1693
1796
|
* against its scale) — used by both the tick labels and the cursor readout, so
|
|
1694
1797
|
* a value reads identically in both. */
|
|
@@ -1705,6 +1808,30 @@ export interface RowFrame {
|
|
|
1705
1808
|
/** The side each axis sits on, keyed by id — so an axis-edge overlay (the
|
|
1706
1809
|
* crosshair value pills) hugs the correct gutter. */
|
|
1707
1810
|
readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
|
|
1811
|
+
/**
|
|
1812
|
+
* How far out in its gutter each axis sits, keyed by id: the px distance from
|
|
1813
|
+
* the plot's edge to that axis's **inner** edge — `0` for the innermost axis
|
|
1814
|
+
* on a side, the sum of the reserved widths of the axes between it and the
|
|
1815
|
+
* plot for the ones beyond it (the slots of {@link ContainerFrame.leftSlots} /
|
|
1816
|
+
* `rightSlots` it sits behind).
|
|
1817
|
+
*
|
|
1818
|
+
* The companion to {@link axisSides}: side alone puts an axis-edge pill on the
|
|
1819
|
+
* *innermost* axis of that side, which is the wrong axis whenever a side
|
|
1820
|
+
* carries more than one — the pill then reads as a value on a scale that never
|
|
1821
|
+
* measured it. Together they place it on the axis that did.
|
|
1822
|
+
*
|
|
1823
|
+
* Keyed by id, so a **mirrored** id (one scale registered on both sides, or a
|
|
1824
|
+
* duplicate) resolves to the **last declared** instance — the same winner
|
|
1825
|
+
* {@link axisSides} picks, deliberately, so that side and offset always
|
|
1826
|
+
* describe one axis. Picking them by different rules would pair one
|
|
1827
|
+
* instance's gutter with another's column, which is exactly the mis-placement
|
|
1828
|
+
* this map exists to remove.
|
|
1829
|
+
*/
|
|
1830
|
+
readonly axisOffsets: ReadonlyMap<string, number>;
|
|
1831
|
+
/** Each axis's own ink (`<YAxis color>`), keyed by id; an axis that sets none
|
|
1832
|
+
* is absent. The other half of matching an axis-edge pill to its axis (see
|
|
1833
|
+
* {@link axisOffsets}) — the pill takes this colour, else the theme's. */
|
|
1834
|
+
readonly axisColors: ReadonlyMap<string, string>;
|
|
1708
1835
|
/** This row's cursor-mode override, or `undefined` to inherit the container's
|
|
1709
1836
|
* default ({@link ContainerFrame.cursor}). */
|
|
1710
1837
|
readonly cursor: CursorMode | undefined;
|
package/dist/cursors.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useContext, useEffect, useMemo } from 'react';
|
|
3
3
|
import { ContainerContext, RowContext, } from './context.js';
|
|
4
4
|
import { renderBrushBand } from './brush.js';
|
|
5
|
-
import { flagChipStyle, flagChipX, axisPillStyle, axisPillX } from './chip.js';
|
|
5
|
+
import { flagChipStyle, flagChipX, axisPillStyle, axisPillX, axisPillConnector, } from './chip.js';
|
|
6
6
|
import { useSlotKey } from './use-slot-key.js';
|
|
7
7
|
import { isDev } from './dev.js';
|
|
8
8
|
/**
|
|
@@ -188,6 +188,12 @@ function buildFlagCursor(o) {
|
|
|
188
188
|
* sample nearest the pointer y in the hovered row — or the first sample when
|
|
189
189
|
* nothing is hovered (a pinned tracker shows a reticle in every row); free
|
|
190
190
|
* mode reads the container-resolved raw-pointer measurement.
|
|
191
|
+
*
|
|
192
|
+
* It carries the picked sample's **axis placement** (side + gutter offset +
|
|
193
|
+
* axis ink) as well as its value, because the reticle reads one series and its
|
|
194
|
+
* pill has to land on *that series' axis*: with two axes on a side, the value
|
|
195
|
+
* belongs to only one of the two scales, and a pill on the other one is a
|
|
196
|
+
* number pinned to a ruler that never measured it.
|
|
191
197
|
*/
|
|
192
198
|
function crosshairPick(f, snap) {
|
|
193
199
|
if (inBoundsX(f) === null)
|
|
@@ -204,7 +210,13 @@ function crosshairPick(f, snap) {
|
|
|
204
210
|
? f.samples[0]
|
|
205
211
|
: null;
|
|
206
212
|
return pick
|
|
207
|
-
? {
|
|
213
|
+
? {
|
|
214
|
+
py: pick.py,
|
|
215
|
+
formatted: pick.formatted,
|
|
216
|
+
side: pick.side,
|
|
217
|
+
axisOffset: pick.axisOffset,
|
|
218
|
+
axisColor: pick.axisColor,
|
|
219
|
+
}
|
|
208
220
|
: null;
|
|
209
221
|
}
|
|
210
222
|
/** `cursor="crosshair"` as a spec: the dashed reticle (renderPlot), the axis
|
|
@@ -223,17 +235,30 @@ function buildCrosshairCursor(o) {
|
|
|
223
235
|
const reticle = crosshairPick(f, o.snap);
|
|
224
236
|
return (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(x), y1: 0, x2: Math.round(x), y2: f.rowHeight, stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: f.plotWidth, y2: Math.round(reticle.py), stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: x, cy: reticle.py, r: 3, fill: ink, stroke: background, strokeWidth: background ? 1 : 0 })] }))] }));
|
|
225
237
|
},
|
|
238
|
+
// The value pill goes **on the reticle's own axis**: its side, its offset
|
|
239
|
+
// out into that gutter (so a second axis on a side gets its own pill
|
|
240
|
+
// position rather than the innermost axis's), and its `<YAxis color>` when
|
|
241
|
+
// it has one — with several axes the pill's ink is what says which scale
|
|
242
|
+
// the number is on. An uncoloured axis keeps the cursor's own ink.
|
|
226
243
|
renderYGutter: (f) => {
|
|
227
244
|
const reticle = crosshairPick(f, o.snap);
|
|
228
245
|
if (reticle === null)
|
|
229
246
|
return null;
|
|
230
247
|
const lh = chipLineHeight(f.theme);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
248
|
+
const ink = reticle.axisColor ?? cursorInk(f.theme);
|
|
249
|
+
// Clamped inside the row like the y-tick labels; the connector shares it
|
|
250
|
+
// so the bridge always meets the pill it belongs to.
|
|
251
|
+
const top = Math.max(lh / 2, Math.min(f.rowHeight - lh / 2, reticle.py));
|
|
252
|
+
return (_jsxs(_Fragment, { children: [reticle.axisOffset > 0 && (_jsx("div", { style: {
|
|
253
|
+
...axisPillConnector(reticle.side, f.plotWidth, reticle.axisOffset, ink),
|
|
254
|
+
top: `${top}px`,
|
|
255
|
+
transform: 'translateY(-50%)',
|
|
256
|
+
} })), _jsx("div", { style: {
|
|
257
|
+
...axisPillStyle(f.theme, ink),
|
|
258
|
+
top: `${top}px`,
|
|
259
|
+
transform: 'translateY(-50%)',
|
|
260
|
+
...axisPillX(reticle.side, f.plotWidth, reticle.axisOffset),
|
|
261
|
+
}, children: reticle.formatted })] }));
|
|
237
262
|
},
|
|
238
263
|
...(o.showTime
|
|
239
264
|
? {
|
package/dist/domain.d.ts
CHANGED
|
@@ -29,6 +29,24 @@ import type { YScaleKind } from './context.js';
|
|
|
29
29
|
* the *decades* spanned rather than of the difference.
|
|
30
30
|
*/
|
|
31
31
|
export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
|
|
32
|
+
/**
|
|
33
|
+
* The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
|
|
34
|
+
* back in as `min`/`max`, re-pad to exactly the domain passed in.
|
|
35
|
+
*
|
|
36
|
+
* **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
|
|
37
|
+
* so a scale's live domain is the **padded** one. Anything that reads a domain
|
|
38
|
+
* off the scale and hands it back as bounds (a y-gutter gesture reporting through
|
|
39
|
+
* `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
|
|
40
|
+
* inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
|
|
41
|
+
* walks outward a notch at a time under a gesture that should be zooming *in*.
|
|
42
|
+
*
|
|
43
|
+
* Both directions preserve the domain's centre, so this is the same arithmetic
|
|
44
|
+
* run backwards: in value space for linear / symlog, in log space for `'log'`
|
|
45
|
+
* (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
|
|
46
|
+
* the identity, and a log domain that is not strictly positive is returned
|
|
47
|
+
* untouched rather than taken through `log10`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function unpadDomain(domain: readonly [number, number], pad?: number, scale?: YScaleKind): [number, number];
|
|
32
50
|
/**
|
|
33
51
|
* Does resolving this axis's domain need its layers' extents walked?
|
|
34
52
|
* `yExtent()` is O(points) per layer, so the caller only pays it when a side
|
package/dist/domain.js
CHANGED
|
@@ -39,6 +39,39 @@ export function resolveYDomain(min, max, extents, pad = 0, scale = 'linear') {
|
|
|
39
39
|
}
|
|
40
40
|
return result;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
|
|
44
|
+
* back in as `min`/`max`, re-pad to exactly the domain passed in.
|
|
45
|
+
*
|
|
46
|
+
* **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
|
|
47
|
+
* so a scale's live domain is the **padded** one. Anything that reads a domain
|
|
48
|
+
* off the scale and hands it back as bounds (a y-gutter gesture reporting through
|
|
49
|
+
* `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
|
|
50
|
+
* inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
|
|
51
|
+
* walks outward a notch at a time under a gesture that should be zooming *in*.
|
|
52
|
+
*
|
|
53
|
+
* Both directions preserve the domain's centre, so this is the same arithmetic
|
|
54
|
+
* run backwards: in value space for linear / symlog, in log space for `'log'`
|
|
55
|
+
* (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
|
|
56
|
+
* the identity, and a log domain that is not strictly positive is returned
|
|
57
|
+
* untouched rather than taken through `log10`.
|
|
58
|
+
*/
|
|
59
|
+
export function unpadDomain(domain, pad = 0, scale = 'linear') {
|
|
60
|
+
const [lo, hi] = domain;
|
|
61
|
+
if (!pad)
|
|
62
|
+
return [lo, hi];
|
|
63
|
+
const shrink = 1 + 2 * pad;
|
|
64
|
+
if (scale === 'log') {
|
|
65
|
+
if (!(lo > 0) || !(hi > lo))
|
|
66
|
+
return [lo, hi];
|
|
67
|
+
const midLog = (Math.log10(lo) + Math.log10(hi)) / 2;
|
|
68
|
+
const halfLog = (Math.log10(hi) - Math.log10(lo)) / (2 * shrink);
|
|
69
|
+
return [10 ** (midLog - halfLog), 10 ** (midLog + halfLog)];
|
|
70
|
+
}
|
|
71
|
+
const mid = (lo + hi) / 2;
|
|
72
|
+
const half = (hi - lo) / (2 * shrink);
|
|
73
|
+
return [mid - half, mid + half];
|
|
74
|
+
}
|
|
42
75
|
/** Smallest positive value a log domain will fall back to when the data offers
|
|
43
76
|
* nothing positive at all. Arbitrary but finite — a log scale has no natural
|
|
44
77
|
* zero to anchor on, and `[0, 1]` (the linear empty-data domain) has no
|
package/dist/select.d.ts
CHANGED
|
@@ -17,5 +17,5 @@ import type { LayerEntry, SelectInfo } from './context.js';
|
|
|
17
17
|
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
18
18
|
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
19
19
|
*/
|
|
20
|
-
export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select'): SelectInfo | null;
|
|
20
|
+
export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select', baseYScaleFor?: (axisId: string | undefined) => ((value: number) => number) | undefined): SelectInfo | null;
|
|
21
21
|
//# sourceMappingURL=select.d.ts.map
|
package/dist/select.js
CHANGED
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
17
17
|
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
18
18
|
*/
|
|
19
|
-
export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover'
|
|
19
|
+
export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover',
|
|
20
|
+
// The axis's declared (pre-pan/zoom) scale — see `RowLayer.hitTest`. Kept
|
|
21
|
+
// alongside `yScaleFor` rather than folded into it: most callers have no
|
|
22
|
+
// base-scale lookup at hand (a bar-free row, a test harness), and every
|
|
23
|
+
// layer but bars ignores the extra argument regardless.
|
|
24
|
+
baseYScaleFor) {
|
|
20
25
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
21
26
|
const entry = entries[i];
|
|
22
27
|
const yScale = yScaleFor(entry.axisId);
|
|
23
28
|
if (yScale === undefined)
|
|
24
29
|
continue;
|
|
25
|
-
const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode);
|
|
30
|
+
const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode, baseYScaleFor?.(entry.axisId));
|
|
26
31
|
if (hit)
|
|
27
32
|
return hit;
|
|
28
33
|
}
|
|
@@ -170,7 +170,7 @@ export interface TradingTimeScale {
|
|
|
170
170
|
* The **date bands** — the segmented second row of the stacked style. One
|
|
171
171
|
* entry per next-coarser calendar period touching the domain (day bands
|
|
172
172
|
* under intraday ticks, month bands under day ticks, year bands under
|
|
173
|
-
* month/quarter ticks), each `{ start, label, shaded }`: the period's start
|
|
173
|
+
* month/quarter ticks), each `{ start, label, showLabel, shaded }`: the period's start
|
|
174
174
|
* instant (the first band's `start` may precede the domain — its label pins
|
|
175
175
|
* at the left edge), the left-aligned label, and a stable zebra `shaded`
|
|
176
176
|
* flag keyed to the band's calendar identity (pan/zoom-invariant). The
|
|
@@ -181,6 +181,16 @@ export interface TradingTimeScale {
|
|
|
181
181
|
bands(count?: number): Array<{
|
|
182
182
|
start: number;
|
|
183
183
|
label: string;
|
|
184
|
+
/**
|
|
185
|
+
* Whether the renderer should DRAW `label`. `false` when this band's start
|
|
186
|
+
* was clamped onto a live instant a tick already labels — the text would
|
|
187
|
+
* collide, but the band itself must still be emitted, because each entry
|
|
188
|
+
* is a segment boundary (the next entry's `start` closes it) and carries
|
|
189
|
+
* that segment's `shaded` parity. Dropping the entry instead deletes the
|
|
190
|
+
* period from the row, letting the previous band stretch across it under
|
|
191
|
+
* the wrong identity and shading.
|
|
192
|
+
*/
|
|
193
|
+
showLabel: boolean;
|
|
184
194
|
shaded: boolean;
|
|
185
195
|
}>;
|
|
186
196
|
domain(): [number, number];
|
package/dist/tradingTimeScale.js
CHANGED
|
@@ -192,22 +192,68 @@ export function scaleTradingTime(provider) {
|
|
|
192
192
|
scale.bands = (count = 10) => {
|
|
193
193
|
if (!hasCalendar())
|
|
194
194
|
return [];
|
|
195
|
-
const
|
|
195
|
+
const { ticks, granularity } = resolved(count);
|
|
196
|
+
const bg = bandGrainFor(granularity);
|
|
196
197
|
if (bg === undefined)
|
|
197
198
|
return []; // year grain — nothing coarser to band
|
|
198
199
|
const fmt = base.tickFormat(count, bandFormatFor(bg));
|
|
199
|
-
const
|
|
200
|
+
const tickSet = new Set(ticks);
|
|
201
|
+
// A band's raw calendar start is a date, not necessarily a LIVE instant —
|
|
202
|
+
// a month or day beginning on a collapsed weekend/holiday clamps onto the
|
|
203
|
+
// next live moment. `bandNext`/`clampUp` are both monotonic, so every raw
|
|
204
|
+
// start that clamps to the SAME live instant is a contiguous run (a
|
|
205
|
+
// holiday week's Sat/Sun/Mon all land on the same Tuesday) — collected
|
|
206
|
+
// here as `{ s, live }` candidates before any label is built, so a run
|
|
207
|
+
// can be resolved as one group rather than emitting (and then trying to
|
|
208
|
+
// retract) a label per member.
|
|
209
|
+
const candidates = [];
|
|
210
|
+
let s = bandStartOf(domain[0], bg);
|
|
200
211
|
// First band starts at (or before) the domain start — the partial left
|
|
201
212
|
// band whose label the renderer pins at x=0; step to each next period
|
|
202
213
|
// start still inside the domain. Bounded loop as a runaway guard.
|
|
203
|
-
let s = bandStartOf(domain[0], bg);
|
|
204
214
|
for (let i = 0; i < 100_000 && s < domain[1]; i++) {
|
|
215
|
+
candidates.push({ s, live: provider.clampUp(s) });
|
|
216
|
+
s = bandNext(s, bg);
|
|
217
|
+
}
|
|
218
|
+
const out = [];
|
|
219
|
+
for (let i = 0; i < candidates.length;) {
|
|
220
|
+
const live = candidates[i].live;
|
|
221
|
+
let j = i;
|
|
222
|
+
while (j < candidates.length && candidates[j].live === live)
|
|
223
|
+
j += 1;
|
|
224
|
+
// One label per run: prefer the member that is ITSELF live (so a
|
|
225
|
+
// Tuesday reopening after a collapsed weekend reads "Tue", not the
|
|
226
|
+
// Saturday whose raw start merely happened to clamp onto it) — a run
|
|
227
|
+
// with no live member (the whole period is inside the gap) falls back
|
|
228
|
+
// to the first, since there is no better candidate. (Every member of
|
|
229
|
+
// the group shares `live` by construction — the test is `c.s ===
|
|
230
|
+
// c.live`, i.e. THIS candidate needed no clamping at all.)
|
|
231
|
+
const rep = candidates.slice(i, j).find((c) => c.s === c.live) ?? candidates[i];
|
|
232
|
+
// The band's raw start routinely coincides with a tick that is ALSO
|
|
233
|
+
// legitimately live (a month band and a day tick both anchored on the
|
|
234
|
+
// 1st is the ordinary stacked-axis look) — that is not the bug. Only a
|
|
235
|
+
// representative that itself needed clamping, and whose landing spot a
|
|
236
|
+
// tick already labels, duplicates one; skip it rather than draw a
|
|
237
|
+
// second, colliding label over the tick's own.
|
|
238
|
+
const collides = rep.s !== live && tickSet.has(live);
|
|
239
|
+
// Emit the band either way. A collision suppresses only its TEXT — the
|
|
240
|
+
// entry is also this period's segment boundary (the next entry's
|
|
241
|
+
// `start` closes it) and owns its zebra parity, so skipping it deletes
|
|
242
|
+
// the period from the row rather than de-duplicating a label: the
|
|
243
|
+
// preceding band then runs on to the next surviving start under its own
|
|
244
|
+
// shading and name. (Aug 1 2026 is a Saturday, so the August band
|
|
245
|
+
// clamps onto Mon Aug 3, which is already a day tick — dropping it left
|
|
246
|
+
// July painted across the whole of August.)
|
|
205
247
|
out.push({
|
|
206
|
-
start:
|
|
207
|
-
|
|
208
|
-
|
|
248
|
+
start: live,
|
|
249
|
+
// Formatted from the representative's own RAW start, not the
|
|
250
|
+
// clamped one — a genuinely-live rep reads its own date; a
|
|
251
|
+
// gap-only run's rep reads whichever raw date it fell back to.
|
|
252
|
+
label: fmt(new Date(rep.s)),
|
|
253
|
+
showLabel: !collides,
|
|
254
|
+
shaded: bandShaded(rep.s, bg),
|
|
209
255
|
});
|
|
210
|
-
|
|
256
|
+
i = j;
|
|
211
257
|
}
|
|
212
258
|
return out;
|
|
213
259
|
};
|