@pond-ts/charts 0.37.0 → 0.39.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 +103 -3
- package/dist/ChartContainer.d.ts +11 -1
- package/dist/ChartContainer.js +20 -2
- package/dist/ChartRow.js +10 -0
- package/dist/Layers.js +148 -25
- package/dist/XAxis.js +99 -2
- package/dist/annotations.d.ts +41 -10
- package/dist/annotations.js +130 -46
- package/dist/chip.d.ts +36 -0
- package/dist/chip.js +85 -1
- package/dist/context.d.ts +50 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/indicators.d.ts +105 -0
- package/dist/indicators.js +114 -0
- package/dist/tracker.d.ts +1 -1
- package/dist/tracker.js +5 -0
- package/package.json +3 -3
package/dist/XAxis.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment, useContext } from 'react';
|
|
3
3
|
import { ContainerContext } from './context.js';
|
|
4
|
+
import { axisPillStyle } from './chip.js';
|
|
4
5
|
import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
|
|
5
6
|
/** Tick strip height (mark + value label) in CSS px. */
|
|
6
7
|
const TICK_STRIP = 22;
|
|
@@ -23,6 +24,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
23
24
|
throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
|
|
24
25
|
}
|
|
25
26
|
const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
|
|
27
|
+
// The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
|
|
28
|
+
// cursor is live in-bounds, pin the hovered time to this axis (covering the
|
|
29
|
+
// tick behind it), matching the on-axis y value pills the rows draw. Gated on
|
|
30
|
+
// the container default, so a per-row `cursor` override doesn't reach here.
|
|
31
|
+
const cursorX = container.cursorX;
|
|
32
|
+
const showCursorTag = container.cursor === 'crosshair' &&
|
|
33
|
+
cursorX !== null &&
|
|
34
|
+
cursorX >= 0 &&
|
|
35
|
+
cursorX <= plotWidth;
|
|
36
|
+
const cursorColor = theme.cursor ?? theme.axis.label;
|
|
37
|
+
const annotationColor = theme.annotation?.color ?? '#0d9488';
|
|
26
38
|
// Tick formatter: an explicit `format` is resolved against the axis kind
|
|
27
39
|
// (a time specifier through the time scale, a number specifier through the
|
|
28
40
|
// value scale); otherwise the container's shared formatter — the one the
|
|
@@ -32,14 +44,66 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
32
44
|
: xKind === 'time'
|
|
33
45
|
? resolveTimeFormat(xScale, TICK_COUNT, format)
|
|
34
46
|
: resolveAxisFormat(xScale, TICK_COUNT, format);
|
|
47
|
+
// Marker annotations that opted into an axis indicator (`<Marker indicator>`)
|
|
48
|
+
// pin their **time** to this shared x-axis — a pill at `at`, in the annotation
|
|
49
|
+
// colour, reading like a tick. An indicator always shows the axis coordinate
|
|
50
|
+
// (the formatted `at`), never the marker's custom label (that stays the in-plot
|
|
51
|
+
// chip). Skipped when off-plot.
|
|
52
|
+
// Coincident indicator markers (same `at`) show the same time, so one pill
|
|
53
|
+
// stands for the group — dedup by `at` (the first wins), then place.
|
|
54
|
+
const seenAt = new Set();
|
|
55
|
+
const markerTags = container.annotations
|
|
56
|
+
.filter((a) => a.indicator && a.kind === 'marker' && a.xs[0] !== undefined)
|
|
57
|
+
.filter((a) => {
|
|
58
|
+
const at = a.xs[0];
|
|
59
|
+
if (seenAt.has(at))
|
|
60
|
+
return false;
|
|
61
|
+
seenAt.add(at);
|
|
62
|
+
return true;
|
|
63
|
+
})
|
|
64
|
+
.map((a) => {
|
|
65
|
+
const at = a.xs[0];
|
|
66
|
+
return {
|
|
67
|
+
key: a.key,
|
|
68
|
+
id: a.id ?? `marker-at-${at}`,
|
|
69
|
+
x: xScale(at),
|
|
70
|
+
text: fmt(at),
|
|
71
|
+
};
|
|
72
|
+
})
|
|
73
|
+
.filter((t) => t.x >= 0 && t.x <= plotWidth);
|
|
74
|
+
// Stack overlapping marker pills into lanes — they all share this one strip.
|
|
75
|
+
// Greedy left→right; the dragged mark (`draggingKey`) is pinned to lane 0 so the
|
|
76
|
+
// static pills hold their lanes as it crosses them (no reshuffle mid-drag).
|
|
77
|
+
const pillWidth = (text) => text.length * theme.font.size * 0.62 + 10;
|
|
78
|
+
const pillLaneEnds = [];
|
|
79
|
+
const markerLanes = new Map();
|
|
80
|
+
for (const t of [...markerTags].sort((p, q) => p.x - q.x)) {
|
|
81
|
+
if (t.key === container.draggingKey) {
|
|
82
|
+
markerLanes.set(t.id, 0);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const left = t.x - pillWidth(t.text) / 2;
|
|
86
|
+
let lane = 0;
|
|
87
|
+
while (lane < pillLaneEnds.length && pillLaneEnds[lane] + 4 > left)
|
|
88
|
+
lane += 1;
|
|
89
|
+
pillLaneEnds[lane] = left + pillWidth(t.text);
|
|
90
|
+
markerLanes.set(t.id, lane);
|
|
91
|
+
}
|
|
92
|
+
const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
|
|
35
93
|
const placed = customTicks
|
|
36
94
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
37
95
|
: xScale.ticks(TICK_COUNT).map((d) => ({
|
|
38
96
|
x: xScale(d),
|
|
39
97
|
label: fmt(+d),
|
|
40
98
|
}));
|
|
41
|
-
const stripHeight = height ?? TICK_STRIP + (label ? LABEL_STRIP : 0);
|
|
42
99
|
const onTop = side === 'top';
|
|
100
|
+
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
101
|
+
// they line up with their tick-label neighbours (matches `labelOffset` below).
|
|
102
|
+
const pillOffset = align === 'right' ? 2 : 6;
|
|
103
|
+
// Per-lane vertical step for stacked pills; grow the strip to fit the stack.
|
|
104
|
+
const PILL_LANE_H = theme.font.size + 6;
|
|
105
|
+
const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
|
|
106
|
+
maxPillLane * PILL_LANE_H;
|
|
43
107
|
return (_jsxs("div", { style: {
|
|
44
108
|
position: 'relative',
|
|
45
109
|
marginLeft: `${leftGutter}px`,
|
|
@@ -92,6 +156,39 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
92
156
|
color: theme.axis.title?.color ?? theme.axis.label,
|
|
93
157
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
94
158
|
whiteSpace: 'nowrap',
|
|
95
|
-
}, children: label }))
|
|
159
|
+
}, children: label })), markerTags.map((t) => {
|
|
160
|
+
// The pill's lane: stacked below the base row when it would overlap a
|
|
161
|
+
// neighbour; the connector lengthens to reach it.
|
|
162
|
+
const laneY = pillOffset + (markerLanes.get(t.id) ?? 0) * PILL_LANE_H;
|
|
163
|
+
return (_jsxs(Fragment, { children: [_jsx("div", { style: {
|
|
164
|
+
position: 'absolute',
|
|
165
|
+
left: `${t.x}px`,
|
|
166
|
+
[onTop ? 'bottom' : 'top']: 0,
|
|
167
|
+
width: '1px',
|
|
168
|
+
height: `${laneY}px`,
|
|
169
|
+
background: annotationColor,
|
|
170
|
+
zIndex: 2,
|
|
171
|
+
} }), _jsx("div", { style: {
|
|
172
|
+
...axisPillStyle(theme, annotationColor),
|
|
173
|
+
left: `${t.x}px`,
|
|
174
|
+
transform: 'translateX(-50%)',
|
|
175
|
+
[onTop ? 'bottom' : 'top']: `${laneY}px`,
|
|
176
|
+
zIndex: 2,
|
|
177
|
+
}, children: t.text })] }, t.id));
|
|
178
|
+
}), showCursorTag && (_jsxs(Fragment, { children: [_jsx("div", { style: {
|
|
179
|
+
position: 'absolute',
|
|
180
|
+
left: `${cursorX}px`,
|
|
181
|
+
[onTop ? 'bottom' : 'top']: 0,
|
|
182
|
+
width: '1px',
|
|
183
|
+
height: `${pillOffset}px`,
|
|
184
|
+
background: cursorColor,
|
|
185
|
+
zIndex: 3,
|
|
186
|
+
} }), _jsx("div", { style: {
|
|
187
|
+
...axisPillStyle(theme, cursorColor),
|
|
188
|
+
left: `${cursorX}px`,
|
|
189
|
+
transform: 'translateX(-50%)',
|
|
190
|
+
[onTop ? 'bottom' : 'top']: `${pillOffset}px`,
|
|
191
|
+
zIndex: 3,
|
|
192
|
+
}, children: fmt(+xScale.invert(cursorX)) })] }))] }));
|
|
96
193
|
}
|
|
97
194
|
//# sourceMappingURL=XAxis.js.map
|
package/dist/annotations.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { type AnnotationSpec } from './context.js';
|
|
1
|
+
import { type AnnotationSpec, type LabelPlacement } from './context.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
4
|
+
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
5
|
+
* don't participate.
|
|
6
|
+
*
|
|
7
|
+
* Three behaviours:
|
|
8
|
+
* - **Per row** — labels only contend within their own row's top space (a
|
|
9
|
+
* bottom-row label at the same x as a top-row one isn't "in the way").
|
|
10
|
+
* - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
|
|
11
|
+
* together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
|
|
12
|
+
* on one lane, rather than stacking three deep. The first is the representative
|
|
13
|
+
* (its `label` is the joined text); the rest map to `label: null`.
|
|
14
|
+
* - **Greedy stacking** — non-coincident labels that would still overlap drop to
|
|
15
|
+
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
16
|
+
* label) so the static labels hold their lanes as it crosses them.
|
|
8
17
|
*/
|
|
9
|
-
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number): Map<symbol,
|
|
18
|
+
export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
|
|
10
19
|
/**
|
|
11
20
|
* Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
|
|
12
21
|
* the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
|
|
@@ -51,9 +60,16 @@ export interface MarkerProps {
|
|
|
51
60
|
/** Make the marker **editable** (in edit mode): dragging its line reports the
|
|
52
61
|
* new `at` (controlled — wire it back to `at`). The whole line moves. */
|
|
53
62
|
onChange?: (at: number) => void;
|
|
63
|
+
/** Also pin this marker's **time** to the **x-axis** as an on-axis pill (drawn
|
|
64
|
+
* by `<XAxis>` at `at`, in the annotation colour) — the axis-edge counterpart
|
|
65
|
+
* of the near-line chip. Default `false`. The pill always shows the formatted
|
|
66
|
+
* `at` (the axis coordinate), never the custom `label` (which stays the
|
|
67
|
+
* near-line chip) — an indicator reads like a tick. A connector links the
|
|
68
|
+
* marker line to its pill. */
|
|
69
|
+
indicator?: boolean;
|
|
54
70
|
}
|
|
55
71
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
56
|
-
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
72
|
+
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
57
73
|
export interface BaselineProps {
|
|
58
74
|
/** y value in the linked axis's units. */
|
|
59
75
|
value: number;
|
|
@@ -62,6 +78,11 @@ export interface BaselineProps {
|
|
|
62
78
|
/** Chip label. Omit to format `value` with that axis's formatter; pass `false`
|
|
63
79
|
* (or `''`) to render **no label chip**. */
|
|
64
80
|
label?: string | false;
|
|
81
|
+
/** Which side of the chart the near-line label chip sits. **Default `left`.** */
|
|
82
|
+
labelSide?: 'left' | 'right';
|
|
83
|
+
/** Where the label chip sits relative to the line: **`center`** (default) rides
|
|
84
|
+
* on the line, vertically centred; `above` sits just on top of it. */
|
|
85
|
+
labelPosition?: 'center' | 'above';
|
|
65
86
|
/** Stable consumer id — a click reports it via `onSelectAnnotation`. */
|
|
66
87
|
id?: string;
|
|
67
88
|
/** Controlled selection — brightens to the front (level 1). Handles are an
|
|
@@ -82,10 +103,16 @@ export interface BaselineProps {
|
|
|
82
103
|
/** Make the baseline **editable** (in edit mode): dragging it vertically reports
|
|
83
104
|
* the new `value` (controlled — wire it back to `value`). */
|
|
84
105
|
onChange?: (value: number) => void;
|
|
106
|
+
/** Also pin this baseline's **value** to its **y-axis** as an on-axis pill (in
|
|
107
|
+
* the annotation colour) — the axis-edge counterpart of the near-line chip.
|
|
108
|
+
* Default `false`. The pill always shows the formatted `value` (the axis
|
|
109
|
+
* coordinate), never the custom `label` (which stays the near-line chip) — an
|
|
110
|
+
* indicator reads like a tick. */
|
|
111
|
+
indicator?: boolean;
|
|
85
112
|
}
|
|
86
113
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
87
114
|
* Its label anchors at the left, at the line's height. */
|
|
88
|
-
export declare function Baseline({ value, axis, label, id, selected, selectable, hovered, editing, onChange, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
115
|
+
export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
89
116
|
export interface RegionProps {
|
|
90
117
|
/** Start x in axis units (time or value). */
|
|
91
118
|
from: number;
|
|
@@ -122,8 +149,12 @@ export interface RegionProps {
|
|
|
122
149
|
from: number;
|
|
123
150
|
to: number;
|
|
124
151
|
}) => void;
|
|
152
|
+
/** Draw the vertical **side outlines** at `from`/`to`. **Default `true`.**
|
|
153
|
+
* `false` shades the span with no edge lines (fill only) — a soft highlight
|
|
154
|
+
* band. Edit-mode resizing still works (the grab areas are invisible). */
|
|
155
|
+
edges?: boolean;
|
|
125
156
|
}
|
|
126
157
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
127
158
|
* flies as a flag off the left edge. */
|
|
128
|
-
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
159
|
+
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
129
160
|
//# sourceMappingURL=annotations.d.ts.map
|
package/dist/annotations.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { ContainerContext, RowContext, } from './context.js';
|
|
4
|
-
import { flagChipStyle, flagChipX } from './chip.js';
|
|
4
|
+
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
5
5
|
import { useSlotKey } from './use-slot-key.js';
|
|
6
6
|
/**
|
|
7
7
|
* User-authored **annotations** — marks you place *on* a chart, in a register
|
|
@@ -114,7 +114,7 @@ function useAnnotationFrame(name) {
|
|
|
114
114
|
* other rows, order regions, and serve snap targets), keyed by the caller's stable
|
|
115
115
|
* per-instance slot key; unregister on unmount. `xs` should be memoised by the
|
|
116
116
|
* caller so the effect only re-runs when the position actually moves. */
|
|
117
|
-
function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label) {
|
|
117
|
+
function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label, indicator) {
|
|
118
118
|
const { registerAnnotation, unregisterAnnotation } = container;
|
|
119
119
|
useEffect(() => () => unregisterAnnotation(key), [unregisterAnnotation, key]);
|
|
120
120
|
useEffect(() => {
|
|
@@ -128,6 +128,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
|
|
|
128
128
|
selectable,
|
|
129
129
|
editing,
|
|
130
130
|
label,
|
|
131
|
+
indicator,
|
|
131
132
|
});
|
|
132
133
|
}, [
|
|
133
134
|
registerAnnotation,
|
|
@@ -140,6 +141,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
|
|
|
140
141
|
selectable,
|
|
141
142
|
editing,
|
|
142
143
|
label,
|
|
144
|
+
indicator,
|
|
143
145
|
]);
|
|
144
146
|
}
|
|
145
147
|
/** Vertical px between stacked label lanes. */
|
|
@@ -149,38 +151,93 @@ const LANE_H = 22;
|
|
|
149
151
|
const LABEL_CHAR_W = 7;
|
|
150
152
|
const LABEL_PAD = 16;
|
|
151
153
|
const LANE_GAP = 6;
|
|
154
|
+
/** Rough chip width (px) for the overlap model. */
|
|
155
|
+
const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
|
|
152
156
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* Lane placement for the **top-flag** labels (markers + regions). Returns, per
|
|
158
|
+
* slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
|
|
159
|
+
* don't participate.
|
|
160
|
+
*
|
|
161
|
+
* Three behaviours:
|
|
162
|
+
* - **Per row** — labels only contend within their own row's top space (a
|
|
163
|
+
* bottom-row label at the same x as a top-row one isn't "in the way").
|
|
164
|
+
* - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
|
|
165
|
+
* together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
|
|
166
|
+
* on one lane, rather than stacking three deep. The first is the representative
|
|
167
|
+
* (its `label` is the joined text); the rest map to `label: null`.
|
|
168
|
+
* - **Greedy stacking** — non-coincident labels that would still overlap drop to
|
|
169
|
+
* the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
|
|
170
|
+
* label) so the static labels hold their lanes as it crosses them.
|
|
158
171
|
*/
|
|
159
|
-
export function computeLabelLanes(annotations, toPixel) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
a.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
172
|
+
export function computeLabelLanes(annotations, toPixel, draggingKey) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
const byRow = new Map();
|
|
175
|
+
for (const a of annotations) {
|
|
176
|
+
if ((a.kind !== 'marker' && a.kind !== 'region') ||
|
|
177
|
+
a.label.length === 0 ||
|
|
178
|
+
a.xs.length === 0)
|
|
179
|
+
continue;
|
|
180
|
+
const arr = byRow.get(a.rowKey);
|
|
181
|
+
if (arr)
|
|
182
|
+
arr.push(a);
|
|
183
|
+
else
|
|
184
|
+
byRow.set(a.rowKey, [a]);
|
|
185
|
+
}
|
|
186
|
+
for (const specs of byRow.values()) {
|
|
187
|
+
const flags = [];
|
|
188
|
+
const markerGroups = new Map();
|
|
189
|
+
for (const a of specs) {
|
|
190
|
+
if (a.kind === 'marker' && a.key !== draggingKey) {
|
|
191
|
+
const g = markerGroups.get(a.xs[0]);
|
|
192
|
+
if (g)
|
|
193
|
+
g.push(a);
|
|
194
|
+
else
|
|
195
|
+
markerGroups.set(a.xs[0], [a]);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
|
|
199
|
+
flags.push({
|
|
200
|
+
rep: a.key,
|
|
201
|
+
members: [a.key],
|
|
202
|
+
left: toPixel(ax),
|
|
203
|
+
width: labelWidth(a.label),
|
|
204
|
+
label: a.label,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const [x, group] of markerGroups) {
|
|
209
|
+
const label = group.map((g) => g.label).join(', ');
|
|
210
|
+
flags.push({
|
|
211
|
+
rep: group[0].key,
|
|
212
|
+
members: group.map((g) => g.key),
|
|
213
|
+
left: toPixel(x),
|
|
214
|
+
width: labelWidth(label),
|
|
215
|
+
label,
|
|
216
|
+
});
|
|
179
217
|
}
|
|
180
|
-
|
|
181
|
-
|
|
218
|
+
const assign = (f, lane) => {
|
|
219
|
+
out.set(f.rep, { lane, label: f.label });
|
|
220
|
+
for (const m of f.members)
|
|
221
|
+
if (m !== f.rep)
|
|
222
|
+
out.set(m, { lane, label: null });
|
|
223
|
+
};
|
|
224
|
+
// Greedy-pack the static flags; the dragged one is pinned to lane 0.
|
|
225
|
+
const dragged = flags.filter((f) => f.members.length === 1 && f.members[0] === draggingKey);
|
|
226
|
+
flags
|
|
227
|
+
.filter((f) => !dragged.includes(f))
|
|
228
|
+
.sort((p, q) => p.left - q.left)
|
|
229
|
+
.reduce((laneEnds, f) => {
|
|
230
|
+
let lane = 0;
|
|
231
|
+
while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left)
|
|
232
|
+
lane += 1;
|
|
233
|
+
laneEnds[lane] = f.left + f.width;
|
|
234
|
+
assign(f, lane);
|
|
235
|
+
return laneEnds;
|
|
236
|
+
}, []);
|
|
237
|
+
for (const f of dragged)
|
|
238
|
+
assign(f, 0);
|
|
182
239
|
}
|
|
183
|
-
return
|
|
240
|
+
return out;
|
|
184
241
|
}
|
|
185
242
|
/** A mark's hover state, synced both ways with the consumer. The effective hover
|
|
186
243
|
* is the local pointer hover **OR** the controlled `hovered` prop (so a legend row
|
|
@@ -257,7 +314,7 @@ function Pill({ cx, cy, w, h, color, }) {
|
|
|
257
314
|
* **plot-pixel** position on press (`onDragStart`) / move (`onDrag`). With editing
|
|
258
315
|
* off, press / move bubble so a pan reads straight through.
|
|
259
316
|
*/
|
|
260
|
-
function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, }) {
|
|
317
|
+
function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, onDragActive, }) {
|
|
261
318
|
const dragging = useRef(false);
|
|
262
319
|
// Tracks whether this press became a drag (moved past a few px) — a click that
|
|
263
320
|
// didn't drag selects instead of edits. Tracked in *both* modes so a pan-drag
|
|
@@ -282,6 +339,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
282
339
|
return; // edit off: let it bubble (pan reads through)
|
|
283
340
|
e.stopPropagation(); // claim the gesture — don't let the plot start a pan
|
|
284
341
|
dragging.current = true;
|
|
342
|
+
onDragActive?.(true);
|
|
285
343
|
onDragStart?.(p[0], p[1]);
|
|
286
344
|
try {
|
|
287
345
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
@@ -310,6 +368,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
310
368
|
/* ignore */
|
|
311
369
|
}
|
|
312
370
|
dragging.current = false;
|
|
371
|
+
onDragActive?.(false);
|
|
313
372
|
onHover(false);
|
|
314
373
|
},
|
|
315
374
|
// A system gesture takeover fires pointercancel, not pointerup — clear the
|
|
@@ -317,6 +376,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
317
376
|
onPointerCancel: (e) => {
|
|
318
377
|
if (!dragging.current)
|
|
319
378
|
return;
|
|
379
|
+
onDragActive?.(false);
|
|
320
380
|
try {
|
|
321
381
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
322
382
|
}
|
|
@@ -338,7 +398,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
338
398
|
} }));
|
|
339
399
|
}
|
|
340
400
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
341
|
-
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
401
|
+
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
342
402
|
const { container, row, ann } = useAnnotationFrame('Marker');
|
|
343
403
|
const selfKey = useSlotKey();
|
|
344
404
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -350,7 +410,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
350
410
|
const xs = useMemo(() => [at], [at]);
|
|
351
411
|
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the x formatter.
|
|
352
412
|
const text = label === false ? '' : (label ?? container.formatTime(at));
|
|
353
|
-
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
|
|
413
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text, indicator);
|
|
354
414
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
355
415
|
const select = id !== undefined && container.creating === null
|
|
356
416
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -362,16 +422,24 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
362
422
|
const h = row.height;
|
|
363
423
|
const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
|
|
364
424
|
const showHandle = editable && (editing || hovering);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
425
|
+
// Placement decides the lane + the chip text: `chipLabel` is this marker's own
|
|
426
|
+
// label, the merged `"a, b"` when coincident markers fold together, or `null`
|
|
427
|
+
// for a folded-in member (its chip is subsumed by the representative's).
|
|
428
|
+
const placement = container.labelLanes.get(selfKey);
|
|
429
|
+
const lane = placement?.lane ?? 0;
|
|
430
|
+
const chipLabel = placement?.label ?? null;
|
|
431
|
+
// The staff (vertical line) hangs from the top of its flag — so a flag stacked
|
|
432
|
+
// into a lower lane doesn't leave line poking above it. No label ⇒ full height.
|
|
433
|
+
const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
|
|
434
|
+
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) ??
|
|
435
|
+
+container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
368
436
|
top: `${FLAG_TOP + lane * LANE_H}px`,
|
|
369
437
|
...flagChipX(x, container.plotWidth),
|
|
370
|
-
}, children:
|
|
438
|
+
}, children: chipLabel }))] }));
|
|
371
439
|
}
|
|
372
440
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
373
441
|
* Its label anchors at the left, at the line's height. */
|
|
374
|
-
export function Baseline({ value, axis, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
442
|
+
export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
375
443
|
const { container, row, ann } = useAnnotationFrame('Baseline');
|
|
376
444
|
const selfKey = useSlotKey();
|
|
377
445
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -388,7 +456,7 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
|
|
|
388
456
|
// Baselines don't lane-pack (the label anchors at their y, not the top), so
|
|
389
457
|
// this registered string is unused by `computeLabelLanes` — `|| ''` just
|
|
390
458
|
// keeps it a string for `false`/'' (which mean "no label").
|
|
391
|
-
label || '');
|
|
459
|
+
label || '', indicator);
|
|
392
460
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
393
461
|
const select = id !== undefined && container.creating === null
|
|
394
462
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -411,11 +479,27 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
|
|
|
411
479
|
const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
|
|
412
480
|
// Handle pill near the right end (clears the left-anchored label).
|
|
413
481
|
const handleX = w - 14;
|
|
414
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
482
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
483
|
+
top: `${y}px`,
|
|
484
|
+
[labelSide === 'right' ? 'right' : 'left']: '2px',
|
|
485
|
+
// `center` rides on the line; `above` sits its bottom edge on the line.
|
|
486
|
+
transform: labelPosition === 'above'
|
|
487
|
+
? 'translateY(-100%)'
|
|
488
|
+
: 'translateY(-50%)',
|
|
489
|
+
}, children: text })), indicator &&
|
|
490
|
+
(() => {
|
|
491
|
+
const half = container.theme.font.size / 2 + 1;
|
|
492
|
+
return (_jsx("div", { style: {
|
|
493
|
+
...axisPillStyle(container.theme, ann.color),
|
|
494
|
+
top: `${Math.max(half, Math.min(row.height - half, y))}px`,
|
|
495
|
+
transform: 'translateY(-50%)',
|
|
496
|
+
...axisPillX(row.axisSides.get(axisId) ?? 'left', w),
|
|
497
|
+
}, children: fmt ? fmt(value) : String(value) }));
|
|
498
|
+
})()] }));
|
|
415
499
|
}
|
|
416
500
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
417
501
|
* flies as a flag off the left edge. */
|
|
418
|
-
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
502
|
+
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, }) {
|
|
419
503
|
const { container, row, ann } = useAnnotationFrame('Region');
|
|
420
504
|
const selfKey = useSlotKey();
|
|
421
505
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -429,7 +513,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
429
513
|
const text = label === false
|
|
430
514
|
? ''
|
|
431
515
|
: (label ?? `${container.formatTime(from)}–${container.formatTime(to)}`);
|
|
432
|
-
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
|
|
516
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text, false);
|
|
433
517
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
434
518
|
const select = id !== undefined && container.creating === null
|
|
435
519
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -448,7 +532,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
448
532
|
const fillOpacity = ann.fillOpacity *
|
|
449
533
|
rampAt(FILL_MULT, bodyLevel(selectable, editable, hovering, selected));
|
|
450
534
|
const showHandles = editable && (editing || hovering);
|
|
451
|
-
const lane = container.labelLanes.get(selfKey) ?? 0;
|
|
535
|
+
const lane = container.labelLanes.get(selfKey)?.lane ?? 0;
|
|
452
536
|
// Body move-drag: capture the start position + pointer on press, then move by
|
|
453
537
|
// the TOTAL delta from there, so the *raw* position accumulates from a fixed
|
|
454
538
|
// origin. Snap is applied only to the output — never fed back into this
|
|
@@ -460,7 +544,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
460
544
|
// region the other way instead of dead-ending at zero width.
|
|
461
545
|
const edgeRef = useRef(null);
|
|
462
546
|
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
|
|
463
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edge(xa), edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
|
|
547
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: (px) => {
|
|
464
548
|
dragRef.current = { from, to, startPx: px };
|
|
465
549
|
}, onDrag: (px) => {
|
|
466
550
|
const s = dragRef.current;
|
|
@@ -485,10 +569,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
485
569
|
nt = st;
|
|
486
570
|
}
|
|
487
571
|
onChange?.({ from: nf, to: nt });
|
|
488
|
-
} }), 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, onDragStart: () => {
|
|
572
|
+
} }), 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: () => {
|
|
489
573
|
edgeRef.current = to; // the fixed pivot = the far edge
|
|
490
574
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
491
|
-
+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, onDragStart: () => {
|
|
575
|
+
+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: () => {
|
|
492
576
|
edgeRef.current = from; // the fixed pivot = the near edge
|
|
493
577
|
}, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
|
|
494
578
|
+container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
package/dist/chip.d.ts
CHANGED
|
@@ -13,6 +13,42 @@ import type { ChartTheme } from './theme.js';
|
|
|
13
13
|
* contrasting chip background) — a token to settle before this ships.
|
|
14
14
|
*/
|
|
15
15
|
export declare function flagChipStyle(theme: ChartTheme): CSSProperties;
|
|
16
|
+
/**
|
|
17
|
+
* Pick a readable text colour (near-black or white) for text drawn **on top of**
|
|
18
|
+
* `bg`, by its sRGB relative luminance. Handles `#rgb`/`#rrggbb` (the theme
|
|
19
|
+
* palette); any other CSS colour falls back to white. So a saturated blue/red/
|
|
20
|
+
* teal pill gets white text, a pale turquoise pill gets dark text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function contrastText(bg: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* The **axis indicator pill** look — a *solid* filled tag in `color` with
|
|
25
|
+
* auto-contrast text (the ChartIQ / Yahoo price-tag). Distinct from
|
|
26
|
+
* {@link flagChipStyle} (a light in-plot value chip): an on-axis indicator reads
|
|
27
|
+
* as a saturated pill covering the tick, not a floating readout. Note: it does
|
|
28
|
+
* **not** set `lineHeight` — it inherits `normal`, matching a bare tick label, so
|
|
29
|
+
* a pill anchored at the same offset lines up with its tick-label neighbours (a
|
|
30
|
+
* forced lineHeight would shift the text off the tick baseline). Shared by
|
|
31
|
+
* {@link YAxisIndicator}, the crosshair axis pills, and the Baseline/Marker
|
|
32
|
+
* `indicator` pills.
|
|
33
|
+
*/
|
|
34
|
+
export declare function axisPillStyle(theme: ChartTheme, color: string): CSSProperties;
|
|
35
|
+
/**
|
|
36
|
+
* A small triangle on an axis pill's **plot-facing edge**, pointing into the
|
|
37
|
+
* plot at the value (the callout tab). For a `right`-side pill (extending right
|
|
38
|
+
* across the gutter) it sits on the pill's left edge pointing left; for a `left`
|
|
39
|
+
* pill, the mirror. Render as an absolutely-positioned child of the pill (the
|
|
40
|
+
* pill is itself absolute, so it's the containing block); colour matches the pill.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pointerStyle(side: 'left' | 'right', color: string): CSSProperties;
|
|
43
|
+
/**
|
|
44
|
+
* CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
|
|
45
|
+
* edge at the plot boundary (`plotWidth`) and let it overflow outward across the
|
|
46
|
+
* reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
|
|
47
|
+
* sibling axis column (rendered later in the row) so it covers the tick behind
|
|
48
|
+
* it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
|
|
49
|
+
* cursor's per-series value pills, so both sit identically on the axis.
|
|
50
|
+
*/
|
|
51
|
+
export declare function axisPillX(side: 'left' | 'right', plotWidth: number): CSSProperties;
|
|
16
52
|
/**
|
|
17
53
|
* Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
|
|
18
54
|
* `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
|