@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/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
@@ -1,12 +1,21 @@
1
- import { type AnnotationSpec } from './context.js';
1
+ import { type AnnotationSpec, type LabelPlacement } from './context.js';
2
2
  /**
3
- * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
4
- * label that would overlap the one to its left drops to the next free lane below,
5
- * so close-in-x labels stack instead of colliding (and a dragged label slides under
6
- * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
7
- * at the left at their own y, don't participate.
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, number>;
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
@@ -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
- * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
154
- * label that would overlap the one to its left drops to the next free lane below,
155
- * so close-in-x labels stack instead of colliding (and a dragged label slides under
156
- * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
157
- * at the left at their own y, don't participate.
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 flags = annotations
161
- .filter((a) => (a.kind === 'marker' || a.kind === 'region') &&
162
- a.label.length > 0 &&
163
- a.xs.length > 0)
164
- .map((a) => {
165
- const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
166
- return {
167
- key: a.key,
168
- left: toPixel(ax),
169
- width: a.label.length * LABEL_CHAR_W + LABEL_PAD,
170
- };
171
- })
172
- .sort((p, q) => p.left - q.left);
173
- const laneEnds = []; // right-px of the last label placed in each lane
174
- const lanes = new Map();
175
- for (const f of flags) {
176
- let lane = 0;
177
- while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left) {
178
- lane += 1;
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
- laneEnds[lane] = f.left + f.width;
181
- lanes.set(f.key, lane);
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 lanes;
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
- const lane = container.labelLanes.get(selfKey) ?? 0;
366
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: 0, 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, onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
367
- +container.xScale.invert(px)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
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: text }))] }));
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: { top: `${y}px`, left: '2px', transform: 'translateY(-50%)' }, children: text }))] }));
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