@pond-ts/charts 0.42.0 → 0.44.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 +164 -1
- package/dist/BarChart.d.ts +24 -2
- package/dist/BarChart.js +106 -23
- package/dist/BoxPlot.d.ts +80 -28
- package/dist/BoxPlot.js +67 -40
- package/dist/CategoryAxis.d.ts +16 -0
- package/dist/CategoryAxis.js +19 -0
- package/dist/ChartContainer.d.ts +56 -1
- package/dist/ChartContainer.js +101 -3
- package/dist/Layers.js +77 -5
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/XAxis.js +41 -2
- package/dist/annotations.d.ts +38 -1
- package/dist/annotations.js +68 -25
- package/dist/bandScale.d.ts +57 -0
- package/dist/bandScale.js +67 -0
- package/dist/bars.d.ts +23 -6
- package/dist/bars.js +47 -15
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +88 -13
- package/dist/data.d.ts +145 -30
- package/dist/data.js +146 -21
- package/dist/index.d.ts +5 -2
- package/dist/index.js +8 -1
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/dist/tracker.d.ts +37 -0
- package/dist/tracker.js +77 -6
- package/package.json +3 -3
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ValueSeries } from 'pond-ts';
|
|
2
|
+
import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
2
3
|
import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
|
|
3
|
-
export interface ScatterChartProps<S extends SeriesSchema> {
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
|
|
5
|
+
/**
|
|
6
|
+
* The source series. A `TimeSeries` scatters against the time axis; a
|
|
7
|
+
* `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
|
|
8
|
+
* for natively value-keyed data — IV marks keyed by strike) against its
|
|
9
|
+
* value axis — the container infers which from the data, no axis-type prop
|
|
10
|
+
* (mirrors `<LineChart>`). Either way the key / axis column supplies each
|
|
11
|
+
* point's x and `column` supplies y.
|
|
12
|
+
*
|
|
13
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
|
|
14
|
+
* so passing `series={s.byValue('dist')}` inline re-registers this layer
|
|
15
|
+
* every render — memoize the projection (`useMemo`) on a frequently
|
|
16
|
+
* re-rendering chart.
|
|
17
|
+
*/
|
|
18
|
+
series: TimeSeries<S> | ValueSeries<VS>;
|
|
6
19
|
/** Name of the numeric value column — each point's y. */
|
|
7
20
|
column: string;
|
|
8
21
|
/**
|
|
@@ -24,6 +37,11 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
24
37
|
* the key the controlled `selected` echo, dedup, and (later) multi-select all
|
|
25
38
|
* match on — so a selection survives a data update where a sample `key` goes
|
|
26
39
|
* stale.
|
|
40
|
+
*
|
|
41
|
+
* A point's identity within the series is its **x** (key / axis value). The
|
|
42
|
+
* key contract allows duplicate x's (equal timestamps; a value-axis plateau
|
|
43
|
+
* from `byValue('cumDist')`) — points sharing an x share identity, so
|
|
44
|
+
* selecting one highlights the last drawn point at that x.
|
|
27
45
|
*/
|
|
28
46
|
id?: string;
|
|
29
47
|
/**
|
|
@@ -59,6 +77,14 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
59
77
|
* dense scatter is noise; this is for a handful of called-out marks.
|
|
60
78
|
*/
|
|
61
79
|
label?: string | boolean;
|
|
80
|
+
/**
|
|
81
|
+
* A **pixel** shift applied to every point's x — zoom-stable. **Default `0`.**
|
|
82
|
+
* For pairing marks that share a key side by side (a call and a put mark at one
|
|
83
|
+
* strike: `offset={-4}` / `offset={+4}`). Pairs with `<BoxPlot offset>`; on the
|
|
84
|
+
* scatter the shift is exact — both the draw and the click hit-test move
|
|
85
|
+
* together, so a nudged point still selects.
|
|
86
|
+
*/
|
|
87
|
+
offset?: number;
|
|
62
88
|
/**
|
|
63
89
|
* @internal Declaration position among the `<Layers>` children, injected by
|
|
64
90
|
* `Layers` so z-order follows JSX order. Do not set.
|
|
@@ -66,7 +92,8 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
66
92
|
index?: number;
|
|
67
93
|
}
|
|
68
94
|
/**
|
|
69
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
95
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
96
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
70
97
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
71
98
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
72
99
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -92,5 +119,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
92
119
|
* </Layers>
|
|
93
120
|
* ```
|
|
94
121
|
*/
|
|
95
|
-
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
122
|
+
export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
|
|
96
123
|
//# sourceMappingURL=ScatterChart.d.ts.map
|
package/dist/ScatterChart.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ValueSeries } from 'pond-ts';
|
|
3
|
+
import { fromTimeSeries, fromValueSeries } from './data.js';
|
|
3
4
|
import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
|
|
4
5
|
import { resolveEncoding, } from './encoding.js';
|
|
5
6
|
import { ContainerContext, LayersContext } from './context.js';
|
|
6
7
|
import { useSlotKey } from './use-slot-key.js';
|
|
7
8
|
/**
|
|
8
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
9
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
10
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
9
11
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
10
12
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
11
13
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -31,7 +33,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
31
33
|
* </Layers>
|
|
32
34
|
* ```
|
|
33
35
|
*/
|
|
34
|
-
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
|
|
36
|
+
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
|
|
35
37
|
const container = useContext(ContainerContext);
|
|
36
38
|
if (container === null) {
|
|
37
39
|
throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
|
|
@@ -40,7 +42,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
40
42
|
if (layers === null) {
|
|
41
43
|
throw new Error('<ScatterChart> must be rendered inside a <Layers>');
|
|
42
44
|
}
|
|
43
|
-
const cs = useMemo(() =>
|
|
45
|
+
const cs = useMemo(() => series instanceof ValueSeries
|
|
46
|
+
? fromValueSeries(series, column)
|
|
47
|
+
: fromTimeSeries(series, column), [series, column]);
|
|
44
48
|
// Styling: semantic identifier → theme scatter style. The single styling
|
|
45
49
|
// channel for the base mark.
|
|
46
50
|
const { scatter } = container.theme;
|
|
@@ -53,13 +57,26 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
53
57
|
// pulls a named numeric column to a Float64Array (gaps NaN) — the same path
|
|
54
58
|
// fromTimeSeries uses; an unknown / non-numeric column throws there (eager,
|
|
55
59
|
// so a typo surfaces at render, not silently as base-styled points).
|
|
56
|
-
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) =>
|
|
60
|
+
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => series instanceof ValueSeries
|
|
61
|
+
? fromValueSeries(series, col).y
|
|
62
|
+
: fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
|
|
57
63
|
// Per-point label accessor: a column name reads that field, `true` reads the
|
|
58
64
|
// plotted column, anything else (false / omitted) ⇒ no labels.
|
|
59
65
|
const labelAt = useMemo(() => {
|
|
60
66
|
if (label === undefined || label === false)
|
|
61
67
|
return undefined;
|
|
62
68
|
const field = label === true ? column : label;
|
|
69
|
+
if (series instanceof ValueSeries) {
|
|
70
|
+
// Columnar read — a ValueSeries has no per-row events. The field is a
|
|
71
|
+
// runtime string, cast onto the schema-literal column name (the same
|
|
72
|
+
// pattern as data.ts' readValueColumn). A gap or an unknown column reads
|
|
73
|
+
// undefined => no label at that point.
|
|
74
|
+
const col = series.column(field);
|
|
75
|
+
return (i) => {
|
|
76
|
+
const v = col?.read(i);
|
|
77
|
+
return v === undefined || v === null ? undefined : String(v);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
63
80
|
return (i) => {
|
|
64
81
|
// series.at(i) is O(1) per row (columnar eventAt cache), so a label per
|
|
65
82
|
// point stays cheap. The field is a runtime string → cast off the literal-
|
|
@@ -71,26 +88,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
71
88
|
return v === undefined || v === null ? undefined : String(v);
|
|
72
89
|
};
|
|
73
90
|
}, [label, column, series]);
|
|
74
|
-
// The point's stable key is its event begin (epoch ms)
|
|
75
|
-
//
|
|
91
|
+
// The point's stable key is its x — the event begin (epoch ms) on a time
|
|
92
|
+
// axis, the axis value on a value axis; either way it's cs.x[i], the key
|
|
93
|
+
// column's begin buffer. Used for selection identity.
|
|
76
94
|
const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
|
|
77
95
|
const entry = useMemo(() => ({
|
|
78
96
|
layer: {
|
|
79
97
|
yExtent: () => scatterExtent(cs),
|
|
80
|
-
|
|
98
|
+
// The container infers the shared x scale's kind + auto-fit domain from
|
|
99
|
+
// its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
|
|
100
|
+
xKind: series instanceof ValueSeries ? 'value' : 'time',
|
|
81
101
|
xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
|
|
82
|
-
sampleAt: (
|
|
102
|
+
sampleAt: (x) => {
|
|
83
103
|
// No readout past the data (tracker policy — the dot snaps to a drawn
|
|
84
|
-
// mark, never extrapolates past the span); bounds from the
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
time > cs.x[cs.length - 1]) {
|
|
104
|
+
// mark, never extrapolates past the span); bounds from the columnar x
|
|
105
|
+
// axis (epoch ms or axis value — the bisect doesn't care).
|
|
106
|
+
if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
|
|
88
107
|
return [];
|
|
89
108
|
}
|
|
90
109
|
// Nearest *drawn* point by index (skips gaps) — O(log N). Reading by
|
|
91
110
|
// index gives the value, the snap-to x, and the encoded colour in one
|
|
92
111
|
// shot, so the readout swatch matches the mark the user sees.
|
|
93
|
-
const i = nearestIndex(cs,
|
|
112
|
+
const i = nearestIndex(cs, x);
|
|
94
113
|
if (i < 0)
|
|
95
114
|
return [];
|
|
96
115
|
return [
|
|
@@ -108,9 +127,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
108
127
|
...(id === undefined
|
|
109
128
|
? {}
|
|
110
129
|
: {
|
|
111
|
-
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
|
|
130
|
+
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
|
|
112
131
|
}),
|
|
113
|
-
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
|
|
132
|
+
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
|
|
114
133
|
},
|
|
115
134
|
axisId: axis,
|
|
116
135
|
index,
|
|
@@ -126,6 +145,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
126
145
|
labelAt,
|
|
127
146
|
font,
|
|
128
147
|
container.selected,
|
|
148
|
+
offset,
|
|
129
149
|
axis,
|
|
130
150
|
index,
|
|
131
151
|
]);
|
package/dist/XAxis.js
CHANGED
|
@@ -8,6 +8,36 @@ const TICK_STRIP = 22;
|
|
|
8
8
|
/** Extra height reserved for an axis `label` line. */
|
|
9
9
|
const LABEL_STRIP = 16;
|
|
10
10
|
const TICK_COUNT = 5;
|
|
11
|
+
/**
|
|
12
|
+
* Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
|
|
13
|
+
* every `stride`-th label (so a kept label has room), and ellipsize one that still
|
|
14
|
+
* overruns its space. `stride` grows with the longest label vs the per-category
|
|
15
|
+
* slot width, so a few short categories keep every full label and many long ones
|
|
16
|
+
* decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
|
|
17
|
+
* for placement; the exact metric is the browser's. Rotation is a later option.
|
|
18
|
+
*/
|
|
19
|
+
function thinCategoryLabels(ticks, plotWidth, fontSize) {
|
|
20
|
+
const n = ticks.length;
|
|
21
|
+
const slot = plotWidth / n; // per-category width in px
|
|
22
|
+
// Before first layout `plotWidth` is 0 → `slot` is 0 and the stride/room math
|
|
23
|
+
// below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
|
|
24
|
+
// the ticks through untouched until a real width arrives.
|
|
25
|
+
if (!(slot > 0))
|
|
26
|
+
return [...ticks];
|
|
27
|
+
const charW = fontSize * 0.62; // ~average glyph advance
|
|
28
|
+
const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
|
|
29
|
+
const stride = Math.max(1, Math.ceil((longest * charW) / slot));
|
|
30
|
+
const room = Math.max(1, Math.floor((slot * stride) / charW));
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < n; i += stride) {
|
|
33
|
+
const s = ticks[i].label;
|
|
34
|
+
out.push({
|
|
35
|
+
x: ticks[i].x,
|
|
36
|
+
label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
11
41
|
/**
|
|
12
42
|
* The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
|
|
13
43
|
* child of {@link ChartContainer}, rendered as DOM chrome (crisp text,
|
|
@@ -39,7 +69,11 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
39
69
|
// (a time specifier through the time scale, a number specifier through the
|
|
40
70
|
// value scale); otherwise the container's shared formatter — the one the
|
|
41
71
|
// cursor readout uses, so a tick and the cursor read identically.
|
|
42
|
-
const fmt =
|
|
72
|
+
const fmt =
|
|
73
|
+
// A category axis labels by name (the container's `formatTime` = the band
|
|
74
|
+
// scale's label lookup); a d3 number/time `format` can't name a category, so
|
|
75
|
+
// it's ignored here (customize the labels in the `categories` data instead).
|
|
76
|
+
format === undefined || xKind === 'category'
|
|
43
77
|
? formatTime
|
|
44
78
|
: xKind === 'time'
|
|
45
79
|
? resolveTimeFormat(xScale, TICK_COUNT, format)
|
|
@@ -90,12 +124,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
90
124
|
markerLanes.set(t.id, lane);
|
|
91
125
|
}
|
|
92
126
|
const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
|
|
93
|
-
const
|
|
127
|
+
const rawTicks = customTicks
|
|
94
128
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
95
129
|
: xScale.ticks(TICK_COUNT).map((d) => ({
|
|
96
130
|
x: xScale(d),
|
|
97
131
|
label: fmt(+d),
|
|
98
132
|
}));
|
|
133
|
+
// A category axis ticks once per category; thin + truncate its labels when they
|
|
134
|
+
// crowd (an explicit `customTicks` axis keeps its labels verbatim).
|
|
135
|
+
const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
|
|
136
|
+
? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
|
|
137
|
+
: rawTicks;
|
|
99
138
|
const onTop = side === 'top';
|
|
100
139
|
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
101
140
|
// they line up with their tick-label neighbours (matches `labelOffset` below).
|
package/dist/annotations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
1
|
+
import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from './context.js';
|
|
2
2
|
/**
|
|
3
3
|
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
4
4
|
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
@@ -16,6 +16,21 @@ import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
|
16
16
|
* label) so the static labels hold their lanes as it crosses them.
|
|
17
17
|
*/
|
|
18
18
|
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
|
|
19
|
+
/**
|
|
20
|
+
* Snap a dragged plot-pixel `px` to the nearest **guideline** within
|
|
21
|
+
* {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
|
|
22
|
+
* boundary** (a session collapse point). Returns the **axis** value to snap to,
|
|
23
|
+
* or `null` if none is near (the caller keeps the raw position). Excludes the
|
|
24
|
+
* dragging mark's own `key`, and reads the same registry the guides draw from,
|
|
25
|
+
* so a drag visibly clicks onto the lines you can see.
|
|
26
|
+
*
|
|
27
|
+
* At a disjoint boundary the close and the next open share a pixel, so the value
|
|
28
|
+
* depends on which side of it the pointer is on (see below). Nearest-pixel wins
|
|
29
|
+
* across both kinds of target, so an annotation sitting *exactly* on a boundary
|
|
30
|
+
* open ties and — processed first — takes it (its own guideline), which is the
|
|
31
|
+
* same instant the right-side heuristic would pick anyway.
|
|
32
|
+
*/
|
|
33
|
+
export declare function snapToGuides(container: ContainerFrame, selfKey: symbol, px: number): number | null;
|
|
19
34
|
/**
|
|
20
35
|
* Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
|
|
21
36
|
* the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
|
|
@@ -28,6 +43,27 @@ export declare function orderRegion(v: number, pivot: number): {
|
|
|
28
43
|
from: number;
|
|
29
44
|
to: number;
|
|
30
45
|
};
|
|
46
|
+
/** The slice of a scale a rigid pixel-move needs: value → pixel and back.
|
|
47
|
+
* `invert` may return a `Date` (a d3 `scaleTime`) — the move coerces with `+`. */
|
|
48
|
+
interface InvertibleScale {
|
|
49
|
+
(value: number): number;
|
|
50
|
+
invert(pixel: number): number | Date;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
|
|
54
|
+
* the box moves rigidly in *pixel* space — each edge's pixel position shifts by
|
|
55
|
+
* the same `dpx`, then inverts back to an axis value.
|
|
56
|
+
*
|
|
57
|
+
* This is the move that stays correct on a **discontinuous** (trading-time) axis:
|
|
58
|
+
* a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
|
|
59
|
+
* when they sit in different gap-contexts, distorting the box as it crosses a
|
|
60
|
+
* collapsed gap. On a continuous (affine) scale it is identical to the value-delta
|
|
61
|
+
* move, so this is a no-op there.
|
|
62
|
+
*/
|
|
63
|
+
export declare function moveRegionByPixels(scale: InvertibleScale, from: number, to: number, dpx: number): {
|
|
64
|
+
from: number;
|
|
65
|
+
to: number;
|
|
66
|
+
};
|
|
31
67
|
export interface MarkerProps {
|
|
32
68
|
/** x position in axis units — epoch ms on a time axis, the value on a value
|
|
33
69
|
* axis. (The generalisation of the mockup's "time line": a mark at an x, time
|
|
@@ -157,4 +193,5 @@ export interface RegionProps {
|
|
|
157
193
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
158
194
|
* flies as a flag off the left edge. */
|
|
159
195
|
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
196
|
+
export {};
|
|
160
197
|
//# sourceMappingURL=annotations.d.ts.map
|
package/dist/annotations.js
CHANGED
|
@@ -257,13 +257,20 @@ function useAnnotationHover(container, id, hovered) {
|
|
|
257
257
|
/** Pixel radius within which a drag snaps to a guideline (another mark's x). */
|
|
258
258
|
const SNAP_PX = 6;
|
|
259
259
|
/**
|
|
260
|
-
* Snap a dragged plot-pixel `px` to the nearest **guideline**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
260
|
+
* Snap a dragged plot-pixel `px` to the nearest **guideline** within
|
|
261
|
+
* {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
|
|
262
|
+
* boundary** (a session collapse point). Returns the **axis** value to snap to,
|
|
263
|
+
* or `null` if none is near (the caller keeps the raw position). Excludes the
|
|
264
|
+
* dragging mark's own `key`, and reads the same registry the guides draw from,
|
|
265
|
+
* so a drag visibly clicks onto the lines you can see.
|
|
266
|
+
*
|
|
267
|
+
* At a disjoint boundary the close and the next open share a pixel, so the value
|
|
268
|
+
* depends on which side of it the pointer is on (see below). Nearest-pixel wins
|
|
269
|
+
* across both kinds of target, so an annotation sitting *exactly* on a boundary
|
|
270
|
+
* open ties and — processed first — takes it (its own guideline), which is the
|
|
271
|
+
* same instant the right-side heuristic would pick anyway.
|
|
265
272
|
*/
|
|
266
|
-
function snapToGuides(container, selfKey, px) {
|
|
273
|
+
export function snapToGuides(container, selfKey, px) {
|
|
267
274
|
// The container's snap toggle gates guideline snapping — off ⇒ the drag keeps
|
|
268
275
|
// its raw position (no clicking onto neighbours).
|
|
269
276
|
if (!container.snap)
|
|
@@ -281,6 +288,23 @@ function snapToGuides(container, selfKey, px) {
|
|
|
281
288
|
}
|
|
282
289
|
}
|
|
283
290
|
}
|
|
291
|
+
// Disjoint boundaries: on a trading-time axis a session close and the next
|
|
292
|
+
// open collapse to the **same pixel**, so a boundary is one snap target with
|
|
293
|
+
// two possible instants. Snap to the one on the side of the boundary the
|
|
294
|
+
// pointer is on — left of it → the pre-gap edge (the previous session's
|
|
295
|
+
// *close*, `clampDown` out of the gap); at/right of it → the post-gap *open*.
|
|
296
|
+
const disc = container.discontinuities;
|
|
297
|
+
if (disc?.boundaries) {
|
|
298
|
+
const [d0, d1] = container.timeRange;
|
|
299
|
+
for (const open of disc.boundaries(d0, d1)) {
|
|
300
|
+
const bpx = container.xScale(open);
|
|
301
|
+
const d = Math.abs(bpx - px);
|
|
302
|
+
if (d < bestDist) {
|
|
303
|
+
bestDist = d;
|
|
304
|
+
best = px < bpx ? disc.clampDown(open - 1) : open;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
284
308
|
return best;
|
|
285
309
|
}
|
|
286
310
|
/**
|
|
@@ -294,6 +318,23 @@ function snapToGuides(container, selfKey, px) {
|
|
|
294
318
|
export function orderRegion(v, pivot) {
|
|
295
319
|
return v <= pivot ? { from: v, to: pivot } : { from: pivot, to: v };
|
|
296
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Translate a region's `[from, to]` by `dpx` **plot-pixels** through `scale`, so
|
|
323
|
+
* the box moves rigidly in *pixel* space — each edge's pixel position shifts by
|
|
324
|
+
* the same `dpx`, then inverts back to an axis value.
|
|
325
|
+
*
|
|
326
|
+
* This is the move that stays correct on a **discontinuous** (trading-time) axis:
|
|
327
|
+
* a shared *value* delta (`from + Δt`) would move the two edges by unequal pixels
|
|
328
|
+
* when they sit in different gap-contexts, distorting the box as it crosses a
|
|
329
|
+
* collapsed gap. On a continuous (affine) scale it is identical to the value-delta
|
|
330
|
+
* move, so this is a no-op there.
|
|
331
|
+
*/
|
|
332
|
+
export function moveRegionByPixels(scale, from, to, dpx) {
|
|
333
|
+
return {
|
|
334
|
+
from: +scale.invert(scale(from) + dpx),
|
|
335
|
+
to: +scale.invert(scale(to) + dpx),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
297
338
|
/** A label chip — the cursor value flag's shape (shared {@link flagChipStyle}:
|
|
298
339
|
* filled, no outline) with text in the annotation register. */
|
|
299
340
|
function Chip({ theme, color, style, children, }) {
|
|
@@ -550,25 +591,27 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
550
591
|
const s = dragRef.current;
|
|
551
592
|
if (s === null)
|
|
552
593
|
return;
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
594
|
+
// Rigid move by the TOTAL pointer *pixel* delta from the press
|
|
595
|
+
// origin — each edge shifts the same pixels through the scale, so
|
|
596
|
+
// the box holds its shape even across a collapsed gap (a shared
|
|
597
|
+
// value-delta would drift the edges apart there).
|
|
598
|
+
const moved = moveRegionByPixels(container.xScale, s.from, s.to, px - s.startPx);
|
|
599
|
+
// Snap either edge to a guideline, shifting BOTH by the same pixel
|
|
600
|
+
// correction so the box keeps its width; snap-independent, so a
|
|
601
|
+
// drag past SNAP_PX releases cleanly.
|
|
602
|
+
const fpx = container.xScale(moved.from);
|
|
603
|
+
const tpx = container.xScale(moved.to);
|
|
604
|
+
const sf = snapToGuides(container, selfKey, fpx);
|
|
605
|
+
const st = snapToGuides(container, selfKey, tpx);
|
|
606
|
+
const d = sf !== null
|
|
607
|
+
? container.xScale(sf) - fpx
|
|
608
|
+
: st !== null
|
|
609
|
+
? container.xScale(st) - tpx
|
|
610
|
+
: 0;
|
|
611
|
+
onChange?.({
|
|
612
|
+
from: +container.xScale.invert(fpx + d),
|
|
613
|
+
to: +container.xScale.invert(tpx + d),
|
|
614
|
+
});
|
|
572
615
|
} }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - 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: () => {
|
|
573
616
|
edgeRef.current = to; // the fixed pivot = the far edge
|
|
574
617
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A d3-scale-shaped **ordinal band scale** for a categorical x-axis — the
|
|
3
|
+
* transpose view's "columns on x" (categorical-axis RFC, Phase 1). It exposes the
|
|
4
|
+
* slice of the d3 scale surface `@pond-ts/charts` actually uses, so it drops in
|
|
5
|
+
* wherever the container's `xScale` goes (the same trick {@link TradingTimeScale}
|
|
6
|
+
* uses for a discontinuous time axis).
|
|
7
|
+
*
|
|
8
|
+
* **Numeric slot-index domain (the load-bearing choice).** The domain is
|
|
9
|
+
* `[0, n]` — one unit slot per category, slot `i` occupying `[i, i+1]` — *not* a
|
|
10
|
+
* `string[]`. So the pixel **mapping stays linear** and the container's numeric
|
|
11
|
+
* domain / auto-fit / `range` pipeline is untouched; a bar layer draws each
|
|
12
|
+
* category with the ordinary `barSpanPx(i, i+1, …)`. The category-ness lives in
|
|
13
|
+
* three methods only:
|
|
14
|
+
*
|
|
15
|
+
* - {@link ScaleBand.ticks} → the band **centres** (`i + 0.5`), one per category;
|
|
16
|
+
* - {@link ScaleBand.invert} → snaps a pixel to the nearest slot's centre (the
|
|
17
|
+
* categorical crosshair UX, and it keeps the `+xScale.invert` call sites happy);
|
|
18
|
+
* - {@link ScaleBand.label} → the category name at a slot (the axis formatter).
|
|
19
|
+
*
|
|
20
|
+
* The category labels are carried alongside for {@link ScaleBand.label}; the
|
|
21
|
+
* numeric domain is authoritative for geometry.
|
|
22
|
+
*/
|
|
23
|
+
export interface ScaleBand {
|
|
24
|
+
/** Slot value (`i` = left edge, `i + 0.5` = centre) → pixel. Linear. */
|
|
25
|
+
(value: number): number;
|
|
26
|
+
/** Pixel → the nearest slot's **centre** value (`i + 0.5`), clamped to a real slot. */
|
|
27
|
+
invert(pixel: number): number;
|
|
28
|
+
/** One tick per category, at its band **centre** (`i + 0.5`). */
|
|
29
|
+
ticks(count?: number): number[];
|
|
30
|
+
/**
|
|
31
|
+
* A formatter mapping a slot value → the category name (the numeric `specifier`
|
|
32
|
+
* is ignored — a category axis labels by name, not a number format). Present so
|
|
33
|
+
* the scale is a safe drop-in wherever the container resolves a `tickFormat`.
|
|
34
|
+
*/
|
|
35
|
+
tickFormat(count?: number, specifier?: string): (value: number) => string;
|
|
36
|
+
/** One slot's width in pixels (`|range| / slots`). The bar's `gap` insets within it. */
|
|
37
|
+
bandwidth(): number;
|
|
38
|
+
/** Slot pitch in pixels — same as {@link bandwidth} (padding is the bar's `gap`). */
|
|
39
|
+
step(): number;
|
|
40
|
+
/** The category name at slot value `v` (`categories[floor(v)]`), or `''`. */
|
|
41
|
+
label(value: number): string;
|
|
42
|
+
domain(): [number, number];
|
|
43
|
+
domain(next: readonly [number, number]): ScaleBand;
|
|
44
|
+
range(): [number, number];
|
|
45
|
+
range(next: readonly [number, number]): ScaleBand;
|
|
46
|
+
copy(): ScaleBand;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a {@link ScaleBand} over an ordered list of category names. Configure like
|
|
50
|
+
* a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
|
|
51
|
+
* container sets `domain([0, n])` from the layer's slot extent and `range([0,
|
|
52
|
+
* plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
|
|
53
|
+
* so the two must agree on count (`categories.length === n`), which they do when
|
|
54
|
+
* both come from the same layer's `xCategories()` / slot extent.
|
|
55
|
+
*/
|
|
56
|
+
export declare function scaleBand(categories: readonly string[]): ScaleBand;
|
|
57
|
+
//# sourceMappingURL=bandScale.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { scaleLinear } from 'd3-scale';
|
|
2
|
+
/**
|
|
3
|
+
* Build a {@link ScaleBand} over an ordered list of category names. Configure like
|
|
4
|
+
* a d3 scale: `scaleBand(tickers).domain([0, n]).range([0, width])` — the
|
|
5
|
+
* container sets `domain([0, n])` from the layer's slot extent and `range([0,
|
|
6
|
+
* plotWidth])`. `categories` supplies the labels; the domain drives the geometry,
|
|
7
|
+
* so the two must agree on count (`categories.length === n`), which they do when
|
|
8
|
+
* both come from the same layer's `xCategories()` / slot extent.
|
|
9
|
+
*/
|
|
10
|
+
export function scaleBand(categories) {
|
|
11
|
+
let domain = [0, Math.max(1, categories.length)];
|
|
12
|
+
let range = [0, 1];
|
|
13
|
+
const lin = scaleLinear();
|
|
14
|
+
const sync = () => lin.domain(domain).range(range);
|
|
15
|
+
/** Slot count = the domain width (the container sets `[0, n]`). */
|
|
16
|
+
const slots = () => Math.max(0, Math.round(domain[1] - domain[0]));
|
|
17
|
+
const scale = ((value) => {
|
|
18
|
+
sync();
|
|
19
|
+
return lin(value);
|
|
20
|
+
});
|
|
21
|
+
scale.invert = (pixel) => {
|
|
22
|
+
sync();
|
|
23
|
+
const v = lin.invert(pixel);
|
|
24
|
+
const n = slots();
|
|
25
|
+
if (n === 0)
|
|
26
|
+
return domain[0];
|
|
27
|
+
// Snap to the nearest slot's centre, clamped to a real slot.
|
|
28
|
+
const i = Math.min(n - 1, Math.max(0, Math.floor(v - domain[0])));
|
|
29
|
+
return domain[0] + i + 0.5;
|
|
30
|
+
};
|
|
31
|
+
scale.ticks = () => {
|
|
32
|
+
const n = slots();
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let i = 0; i < n; i += 1)
|
|
35
|
+
out.push(domain[0] + i + 0.5);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
scale.bandwidth = () => {
|
|
39
|
+
const n = slots();
|
|
40
|
+
if (n === 0)
|
|
41
|
+
return 0;
|
|
42
|
+
return Math.abs((range[1] - range[0]) / n);
|
|
43
|
+
};
|
|
44
|
+
scale.step = scale.bandwidth;
|
|
45
|
+
scale.label = (value) => {
|
|
46
|
+
const i = Math.floor(value - domain[0]);
|
|
47
|
+
return i >= 0 && i < categories.length ? categories[i] : '';
|
|
48
|
+
};
|
|
49
|
+
scale.tickFormat = () => scale.label;
|
|
50
|
+
function domainFn(next) {
|
|
51
|
+
if (next === undefined)
|
|
52
|
+
return [domain[0], domain[1]];
|
|
53
|
+
domain = [next[0], next[1]];
|
|
54
|
+
return scale;
|
|
55
|
+
}
|
|
56
|
+
scale.domain = domainFn;
|
|
57
|
+
function rangeFn(next) {
|
|
58
|
+
if (next === undefined)
|
|
59
|
+
return [range[0], range[1]];
|
|
60
|
+
range = [next[0], next[1]];
|
|
61
|
+
return scale;
|
|
62
|
+
}
|
|
63
|
+
scale.range = rangeFn;
|
|
64
|
+
scale.copy = () => scaleBand(categories).domain(domain).range(range);
|
|
65
|
+
return scale;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=bandScale.js.map
|
package/dist/bars.d.ts
CHANGED
|
@@ -118,18 +118,33 @@ export interface StackStyle {
|
|
|
118
118
|
readonly fills: readonly string[];
|
|
119
119
|
readonly opacity: number;
|
|
120
120
|
readonly outlineWidth: number;
|
|
121
|
+
/**
|
|
122
|
+
* Optional **per-bin** fill override, aligned index-for-index to the bins
|
|
123
|
+
* (bin `b` uses `binFills[b]`), taking precedence over the per-group
|
|
124
|
+
* {@link fills} for that whole bin. This is the single-series band case —
|
|
125
|
+
* colour each bar by its category (heart-rate / power zones, value bands) —
|
|
126
|
+
* so it's normally paired with a `G === 1` stack. A `null`/`undefined` entry
|
|
127
|
+
* falls back to the group fill.
|
|
128
|
+
*/
|
|
129
|
+
readonly binFills?: readonly (string | undefined)[];
|
|
121
130
|
}
|
|
122
131
|
/** The narrowed selection / hover identity a stacked segment matches against:
|
|
123
|
-
* the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
|
|
132
|
+
* the series `id`, the bin's `begin` (its `key`), and the group (its `label`).
|
|
133
|
+
* When the series carries `marks` (the categorical axis), the match keys on the
|
|
134
|
+
* stable `mark` (the column name) instead of the `key` slot index. */
|
|
124
135
|
export interface StackMark {
|
|
125
136
|
readonly id: string;
|
|
126
137
|
readonly key: number;
|
|
127
138
|
readonly label: string;
|
|
139
|
+
readonly mark?: string;
|
|
128
140
|
}
|
|
129
141
|
/**
|
|
130
|
-
* The `[min, max]` extent of the **value (stacked) axis
|
|
131
|
-
* where `maxTotal` is the tallest bin's summed finite
|
|
132
|
-
*
|
|
142
|
+
* The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
|
|
143
|
+
* stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
|
|
144
|
+
* non-negative segments. For a **single-group** series (`G === 1` — the plain /
|
|
145
|
+
* categorical bar case) it spans the values' own `[min, max]`, so a **negative**
|
|
146
|
+
* bar's floor is in the domain (segments below the baseline stay visible). `0` is
|
|
147
|
+
* always pulled in so the bars rest on a visible baseline (the bar analog of
|
|
133
148
|
* {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
|
|
134
149
|
* has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
|
|
135
150
|
* auto-fit for a horizontal one.
|
|
@@ -162,8 +177,10 @@ export declare function segmentRect(ss: StackedBarSeries, b: number, g: number,
|
|
|
162
177
|
/**
|
|
163
178
|
* Fill every segment of every bin in `ss`, stacking each bin's groups from the
|
|
164
179
|
* value baseline outward (bottom → top vertical, left → right horizontal). A gap
|
|
165
|
-
* (non-finite
|
|
166
|
-
* total, so the segments above it close the space
|
|
180
|
+
* (non-finite, or a negative segment of a true multi-group stack) is skipped and
|
|
181
|
+
* adds nothing to the running total, so the segments above it close the space; a
|
|
182
|
+
* single-group series draws its negative bars below the baseline (see
|
|
183
|
+
* {@link segmentRect}). A segment matching the current
|
|
167
184
|
* `selection` (same series `id`, bin `key` **and** group `label`) draws in its
|
|
168
185
|
* group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
|
|
169
186
|
* without the outline; all others use the flat `fill`. `globalAlpha` carries the
|