@pond-ts/charts 0.57.0 → 0.59.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.
Files changed (85) hide show
  1. package/API.md +576 -0
  2. package/CHANGELOG.md +1213 -1
  3. package/dist/AreaChart.d.ts +12 -1
  4. package/dist/AreaChart.js +131 -13
  5. package/dist/BarChart.d.ts +56 -7
  6. package/dist/BarChart.js +263 -39
  7. package/dist/BarList.d.ts +85 -5
  8. package/dist/BarList.js +25 -4
  9. package/dist/BoxList.d.ts +70 -3
  10. package/dist/BoxList.js +21 -7
  11. package/dist/BoxPlot.d.ts +2 -1
  12. package/dist/BoxPlot.js +101 -9
  13. package/dist/Candlestick.d.ts +13 -1
  14. package/dist/Candlestick.js +89 -3
  15. package/dist/ChartContainer.d.ts +36 -48
  16. package/dist/ChartContainer.js +465 -59
  17. package/dist/ChartRow.d.ts +9 -2
  18. package/dist/ChartRow.js +176 -14
  19. package/dist/HeatMap.d.ts +176 -0
  20. package/dist/HeatMap.js +344 -0
  21. package/dist/Layers.d.ts +5 -1
  22. package/dist/Layers.js +1014 -253
  23. package/dist/Legend.js +8 -4
  24. package/dist/LineChart.d.ts +18 -1
  25. package/dist/LineChart.js +165 -4
  26. package/dist/ListTable.d.ts +30 -3
  27. package/dist/ListTable.js +381 -23
  28. package/dist/ScatterChart.d.ts +3 -2
  29. package/dist/ScatterChart.js +68 -4
  30. package/dist/XAxis.js +40 -22
  31. package/dist/YAxis.d.ts +58 -2
  32. package/dist/YAxis.js +3 -1
  33. package/dist/area.d.ts +34 -1
  34. package/dist/area.js +88 -1
  35. package/dist/bars.d.ts +67 -6
  36. package/dist/bars.js +250 -35
  37. package/dist/box.d.ts +2 -2
  38. package/dist/box.js +158 -40
  39. package/dist/brush.d.ts +142 -0
  40. package/dist/brush.js +179 -0
  41. package/dist/child-index.d.ts +27 -0
  42. package/dist/child-index.js +57 -0
  43. package/dist/context.d.ts +870 -39
  44. package/dist/cursors.d.ts +161 -0
  45. package/dist/cursors.js +503 -0
  46. package/dist/data.d.ts +38 -0
  47. package/dist/data.js +43 -0
  48. package/dist/decimate.d.ts +78 -1
  49. package/dist/decimate.js +157 -0
  50. package/dist/format.d.ts +15 -0
  51. package/dist/format.js +16 -1
  52. package/dist/heat.d.ts +163 -0
  53. package/dist/heat.js +659 -0
  54. package/dist/index.d.ts +13 -4
  55. package/dist/index.js +27 -0
  56. package/dist/line.d.ts +137 -0
  57. package/dist/line.js +328 -0
  58. package/dist/ohlc.d.ts +16 -1
  59. package/dist/ohlc.js +93 -4
  60. package/dist/range.d.ts +14 -1
  61. package/dist/range.js +24 -3
  62. package/dist/scatter.d.ts +17 -9
  63. package/dist/scatter.js +221 -33
  64. package/dist/select.d.ts +13 -5
  65. package/dist/select.js +14 -6
  66. package/dist/selection-fixtures.d.ts +174 -0
  67. package/dist/selection-fixtures.js +569 -0
  68. package/dist/selection-stories.d.ts +73 -0
  69. package/dist/selection-stories.js +301 -0
  70. package/dist/selectors.d.ts +316 -0
  71. package/dist/selectors.js +391 -0
  72. package/dist/span.d.ts +122 -0
  73. package/dist/span.js +203 -0
  74. package/dist/sweep.d.ts +154 -0
  75. package/dist/sweep.js +282 -0
  76. package/dist/theme.d.ts +510 -5
  77. package/dist/theme.js +217 -41
  78. package/dist/tracker.d.ts +6 -0
  79. package/dist/tracker.js +6 -0
  80. package/dist/tradingAxis.fixture.d.ts +78 -0
  81. package/dist/tradingAxis.fixture.js +215 -0
  82. package/dist/useChartLegend.js +18 -3
  83. package/dist/yticks.d.ts +3 -0
  84. package/dist/yticks.js +104 -0
  85. package/package.json +6 -5
package/dist/box.js CHANGED
@@ -1,8 +1,28 @@
1
+ import { NO_SPANS, spanMatchesAny } from './span.js';
1
2
  import { barSpanPx } from './range.js';
2
3
  import { visibleSpanRange } from './culling.js';
3
4
  import { decimateBox } from './decimate.js';
4
5
  /** Fraction of the box width the whisker end-caps span (centred on the stem). */
5
6
  const WHISKER_CAP_FRACTION = 0.5;
7
+ /** Stable identity for "no keys" — the common resting case, so a caller that
8
+ * narrows an empty set doesn't hand `drawBox` a fresh array every frame. */
9
+ const NO_KEYS = [];
10
+ /**
11
+ * Is `key` one of `keys`? The set form of the `key === selectedKey` check
12
+ * {@link drawBox} used to make ([PND-MULTISEL] / RFC A4.3).
13
+ *
14
+ * Linear over the set on purpose, the same reasoning `barMatchesAny` records: a
15
+ * selection is a handful of marks a person clicked, not a data structure, so
16
+ * building a `Set` per draw would cost more than it saves — and the common cases
17
+ * (0 or 1 members) short-circuit immediately.
18
+ */
19
+ function includesKey(keys, key) {
20
+ for (let i = 0; i < keys.length; i += 1) {
21
+ if (keys[i] === key)
22
+ return true;
23
+ }
24
+ return false;
25
+ }
6
26
  /**
7
27
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
8
28
  * whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
@@ -110,10 +130,22 @@ export function boxAt(box, px, py, xScale, yScale, gapPx, minWidthPx, offsetPx =
110
130
  */
111
131
  export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx,
112
132
  // Selection / hover highlight, keyed by the box's `x` (its `begin`, matched to
113
- // the container selection's `key` by the caller). `null` none. A selected
114
- // box gets a full-strength bounding outline; a hovered one a fainter one
115
- // the box analog of the bar highlight, drawn without a new theme token.
116
- selectedKey = null, hoveredKey = null, decimate = true) {
133
+ // the container selection's `key` by the caller). **Sets** every box whose
134
+ // key is named lights, so a multi-mark selection or a drag-sweep hover shows
135
+ // all of it, not just its first member ([PND-MULTISEL] / RFC A4.3). Empty ⇒
136
+ // none. A selected box gets a full-strength bounding outline; a hovered one a
137
+ // fainter one — the box analog of the bar highlight, drawn without a new theme
138
+ // token. A box in **both** sets reads as selected (selected outranks hovered,
139
+ // the precedence `drawBars` / `drawStacks` share).
140
+ selectedKeys = NO_KEYS, hoveredKeys = NO_KEYS, decimate = true,
141
+ // Span descriptors covering this layer (interaction RFC A5.2), already
142
+ // narrowed to its `id` (and constant-label `rows` resolved) by the component
143
+ // — see `spansForLayer`. A box is selected when its key is named OR a span
144
+ // contains it: the O(1) half-open test of its `x` (its key) against the
145
+ // span's `x`, and — mirroring the hit's `value` provenance — its `upper`
146
+ // against `y` when present. Suppressed while decimated, like the key match:
147
+ // an aggregate column is not a source box and must not light as one.
148
+ spans = NO_SPANS) {
117
149
  const sourceCount = box.length; // pre-cull, pre-decimation (for draw stats)
118
150
  // Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
119
151
  // the window (+1 each side). Full range when `xScale` has no domain (a stub);
@@ -153,16 +185,91 @@ selectedKey = null, hoveredKey = null, decimate = true) {
153
185
  // q1/q3 are NaN on a range-only box — read them only when there's a body.
154
186
  const yQ1 = hasBox ? yScale(box.q1[i]) : 0;
155
187
  const yQ3 = hasBox ? yScale(box.q3[i]) : 0;
188
+ // The mark's interaction state, resolved **before** the shape branch: a
189
+ // solid box paints it as its fill (bar parity), every shape paints it as
190
+ // the outline below. A decimated aggregate box carries synthetic keys a
191
+ // mark entry can't name — but a span's interval WOULD contain them, so it
192
+ // is gated off explicitly (per-box highlight is meaningless at decimation
193
+ // density, and lighting an aggregate column would claim marks the
194
+ // selection never held).
195
+ const key = box.x[i];
196
+ const isSelected = includesKey(selectedKeys, key) ||
197
+ (!decimated &&
198
+ spans.length > 0 &&
199
+ spanMatchesAny(spans, key, box.upper[i]));
200
+ // Selected outranks hovered on a box that is both — the same precedence the
201
+ // bar paths document, and what the `key === selectedKey` test did before.
202
+ const isHovered = !isSelected && includesKey(hoveredKeys, key);
203
+ // Recede the rest, exactly as `drawBars` does: only with a real selection
204
+ // (marks or spans) in play.
205
+ const dimming = selectedKeys.length > 0 || spans.length > 0;
206
+ // **The tint ladder.** One four-step ladder per state, so a state change is
207
+ // a single palette swap and every mark keeps its position in the quantile
208
+ // read (step 0 body/outer · 1 inner · 2 stroke+whisker · 3 median). The
209
+ // ladder carries meaning in *lightness*, so rotating its hue leaves every
210
+ // relationship between the marks intact — which is why a box can do the
211
+ // plain teal→blue shift a multi-hue stacked bar cannot.
212
+ //
213
+ // Dimming is opacity alone. A single-hue ladder has nothing to muddy into,
214
+ // so there is no desaturated companion of the kind `bar.groupsDimmed` is.
215
+ const states = style.states;
216
+ const ladder = states
217
+ ? isSelected
218
+ ? states.selected
219
+ : isHovered
220
+ ? states.hover
221
+ : states.rest
222
+ : undefined;
223
+ // Applied to *every* mark of the box, so the whole ladder recedes together.
224
+ const stateAlpha = states !== undefined && dimming && !isSelected && !isHovered
225
+ ? states.dimmedOpacity
226
+ : 1;
227
+ // A hairline can't carry a state in hue: at 1px a colour change is nearly
228
+ // invisible, so selection bumps the weight too.
229
+ const strokeW = isSelected && style.selectedStrokeWidth !== undefined
230
+ ? style.selectedStrokeWidth
231
+ : style.strokeWidth;
232
+ const whiskerW = isSelected && style.selectedStrokeWidth !== undefined
233
+ ? style.selectedStrokeWidth
234
+ : style.whiskerWidth;
235
+ // Bracket an op group **only** when the alpha actually differs from 1.
236
+ // Without this every box would emit two extra canvas ops per group even on
237
+ // the legacy path, where `stateAlpha` is always 1 — a draw-sequence change
238
+ // for every existing consumer in exchange for nothing.
239
+ const dimmed = stateAlpha !== 1;
240
+ const withAlpha = (fn) => {
241
+ if (!dimmed)
242
+ return fn();
243
+ ctx.save();
244
+ ctx.globalAlpha = stateAlpha;
245
+ fn();
246
+ ctx.restore();
247
+ };
248
+ // Ladder steps, falling back to the flat tokens when no ladder is set.
249
+ const cFill = ladder?.[0] ?? style.fill;
250
+ const cInner = ladder?.[1] ?? style.fill;
251
+ const cStroke = ladder?.[2] ?? style.stroke;
252
+ const cMedian = ladder?.[3] ?? style.median;
253
+ const cWhisker = ladder?.[2] ?? style.whisker;
254
+ // With a ladder the step colours are opaque by design (lightness is the
255
+ // encoding, not alpha); the legacy path keeps its translucent fill.
256
+ const baseFillAlpha = ladder !== undefined ? 1 : style.fillOpacity;
156
257
  if (shape === 'solid') {
157
258
  // Candlestick: a light outer bar over the full lower→upper spread, then —
158
259
  // when there's a body — a more-prominent inner q1→q3 box on top (same fill
159
260
  // at rising opacity). No stems, no outline.
261
+ // With a ladder the two tiers are two *steps* (0 outer, 1 inner), which
262
+ // is what keeps the tier read identical in every state. Without one they
263
+ // are one colour at two alphas, as before.
160
264
  ctx.save();
161
- ctx.fillStyle = style.fill;
162
- ctx.globalAlpha = style.fillOpacity;
265
+ ctx.fillStyle = cFill;
266
+ ctx.globalAlpha = baseFillAlpha * stateAlpha;
163
267
  ctx.fillRect(x0, yUpper, x1 - x0, yLower - yUpper);
164
268
  if (hasBox) {
165
- ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
269
+ ctx.fillStyle = cInner;
270
+ ctx.globalAlpha =
271
+ (ladder !== undefined ? 1 : Math.min(1, style.fillOpacity * 2)) *
272
+ stateAlpha;
166
273
  ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
167
274
  }
168
275
  ctx.restore();
@@ -171,13 +278,15 @@ selectedKey = null, hoveredKey = null, decimate = true) {
171
278
  // `whisker` / `none`: the graded q1→q3 box fill + outline (body only).
172
279
  if (hasBox) {
173
280
  ctx.save();
174
- ctx.fillStyle = style.fill;
175
- ctx.globalAlpha = style.fillOpacity;
281
+ ctx.fillStyle = cFill;
282
+ ctx.globalAlpha = baseFillAlpha * stateAlpha;
176
283
  ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
177
284
  ctx.restore();
178
- ctx.strokeStyle = style.stroke;
179
- ctx.lineWidth = style.strokeWidth;
180
- ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
285
+ withAlpha(() => {
286
+ ctx.strokeStyle = cStroke;
287
+ ctx.lineWidth = strokeW;
288
+ ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
289
+ });
181
290
  }
182
291
  if (shape === 'whisker') {
183
292
  // Whiskers with end-caps. With a body: two stems (q3→upper, q1→lower).
@@ -188,45 +297,54 @@ selectedKey = null, hoveredKey = null, decimate = true) {
188
297
  const capHalf = capWidthPx !== undefined
189
298
  ? Math.min(capWidthPx, x1 - x0) / 2
190
299
  : ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
191
- ctx.strokeStyle = style.whisker;
192
- ctx.lineWidth = style.whiskerWidth;
193
- ctx.beginPath();
194
- // Upper stem: from the box top (q3) or, range-only, from lower.
195
- ctx.moveTo(mid, hasBox ? yQ3 : yLower);
196
- ctx.lineTo(mid, yUpper);
197
- ctx.moveTo(mid - capHalf, yUpper);
198
- ctx.lineTo(mid + capHalf, yUpper);
199
- // Lower cap (and, with a body, the lower stem q1→lower).
200
- if (hasBox) {
201
- ctx.moveTo(mid, yQ1);
202
- ctx.lineTo(mid, yLower);
203
- }
204
- ctx.moveTo(mid - capHalf, yLower);
205
- ctx.lineTo(mid + capHalf, yLower);
206
- ctx.stroke();
300
+ withAlpha(() => {
301
+ ctx.strokeStyle = cWhisker;
302
+ ctx.lineWidth = whiskerW;
303
+ ctx.beginPath();
304
+ // Upper stem: from the box top (q3) or, range-only, from lower.
305
+ ctx.moveTo(mid, hasBox ? yQ3 : yLower);
306
+ ctx.lineTo(mid, yUpper);
307
+ ctx.moveTo(mid - capHalf, yUpper);
308
+ ctx.lineTo(mid + capHalf, yUpper);
309
+ // Lower cap (and, with a body, the lower stem q1→lower).
310
+ if (hasBox) {
311
+ ctx.moveTo(mid, yQ1);
312
+ ctx.lineTo(mid, yLower);
313
+ }
314
+ ctx.moveTo(mid - capHalf, yLower);
315
+ ctx.lineTo(mid + capHalf, yLower);
316
+ ctx.stroke();
317
+ });
207
318
  }
208
319
  }
209
320
  // The median line across the box, on top — drawn only when the box carries a
210
321
  // median column and `showMedian` is on.
211
322
  if (drawMedian) {
212
323
  const yMedian = yScale(box.median[i]);
213
- ctx.strokeStyle = style.median;
214
- ctx.lineWidth = style.medianWidth;
215
- ctx.beginPath();
216
- ctx.moveTo(x0, yMedian);
217
- ctx.lineTo(x1, yMedian);
218
- ctx.stroke();
324
+ withAlpha(() => {
325
+ ctx.strokeStyle = cMedian;
326
+ ctx.lineWidth = style.medianWidth;
327
+ ctx.beginPath();
328
+ ctx.moveTo(x0, yMedian);
329
+ ctx.lineTo(x1, yMedian);
330
+ ctx.stroke();
331
+ });
219
332
  }
220
333
  // Selection / hover: outline the whole mark (x-slot × whisker extent) so a
221
334
  // click / pointer-over reads back on the canvas. Selected = full strength;
222
- // hovered = fainter. Bracketed so alpha/width don't leak to the next box.
223
- const key = box.x[i];
224
- if (key === selectedKey || key === hoveredKey) {
335
+ // hovered = fainter. **Every** named box lights, not only the first — so a
336
+ // set of pinned boxes, or a sweep hovering several at once, all read back.
337
+ // Bracketed so alpha/width don't leak to the next box.
338
+ // **Without a ladder**, the bounding outline is the whole state cue — the
339
+ // shipped behaviour, kept for any theme that sets no `states`. With one it
340
+ // is superseded and deliberately not drawn: the ladder already moved every
341
+ // mark, and a bounding rect on top would claim the empty slot around a
342
+ // whisker as part of the mark.
343
+ if (ladder === undefined && (isSelected || isHovered)) {
225
344
  ctx.save();
226
345
  ctx.strokeStyle = style.stroke;
227
- ctx.lineWidth =
228
- key === selectedKey ? style.strokeWidth + 1 : style.strokeWidth;
229
- ctx.globalAlpha = key === selectedKey ? 1 : 0.5;
346
+ ctx.lineWidth = isSelected ? style.strokeWidth + 1 : style.strokeWidth;
347
+ ctx.globalAlpha = isSelected ? 1 : 0.5;
230
348
  ctx.strokeRect(x0, yUpper, x1 - x0, yLower - yUpper);
231
349
  ctx.restore();
232
350
  }
@@ -0,0 +1,142 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ContainerFrame, CursorEntry, ResolvedCursorFrame } from './context.js';
3
+ /**
4
+ * The **brush recognizer** — the one engine that arbitrates every drag claim
5
+ * on the plot surface (interaction RFC A1.5 / A2.7).
6
+ *
7
+ * A drag on the plot has several would-be owners, and before this module the
8
+ * ordering that resolved them lived implicitly in `Layers.handlePointerDown`'s
9
+ * statement order. Turning claimants into mounted components
10
+ * (`<RangeCursor>`, later `<MultiSelector>`) makes that ordering **public
11
+ * API**, so it is written down once, here, and `Layers` routes on the answer.
12
+ *
13
+ * ## Precedence — highest claim wins
14
+ *
15
+ * 0. **Mark-edit** (`DragArea` in `annotations.tsx`). DOM-level: an editable
16
+ * annotation's handles sit *above* the plot surface and stop propagation,
17
+ * so the engine never sees the press. Listed so the full order is in one
18
+ * place; it is not a case this resolver returns.
19
+ * 1. **Annotation-create capture** — an armed `creating` tool owns the whole
20
+ * surface (the press starts a draw, never a pan or a range drag).
21
+ * 2. **The sweep** — a mounted `<MultiSelector>` in scope (RFC §8), when the
22
+ * row has a sweep-capable layer. A *selection* gesture is the most
23
+ * specific intent a drag can carry (the selector was deliberately
24
+ * mounted), so it preempts both the range drag and pan; a drag-enabled
25
+ * `<RangeCursor>` competing in the same scope is shadowed, and
26
+ * {@link warnSweepShadowsRangeDrag} says so (A1.5 asked for the
27
+ * arbitration to be written down, not implied). The sweep arms behind
28
+ * `DRAG_SLOP` — a plain click stays a click and selects one mark (§8.1:
29
+ * the two are separated by movement, not modifier).
30
+ * 3. **The range drag** ({@link resolveRangeDrag}) — a drag-enabled
31
+ * `<RangeCursor>` in the hovered row's effective cursor set, else the
32
+ * legacy `cursor="region"` + `onRegionSelect` container props. Preempts
33
+ * pan — unless a `dragModifier` is declared **and pan is enabled**, in
34
+ * which case a plain drag falls through to pan and only a modifier-held
35
+ * drag brushes. (With pan off there is no gesture to share, so the
36
+ * modifier is not enforced.)
37
+ * 4. **Pan** — armed behind `DRAG_SLOP`, so a click never nudges the view.
38
+ * 5. **Nothing** — the press is a potential click; hover/select handle it.
39
+ *
40
+ * The recognizer resolves the claim **at pointer-down**; the per-claim
41
+ * sessions (create preview, range anchor, sweep session, pan anchor) stay in
42
+ * `Layers`, keyed off the refs the claim seeds. `<RangeCursor>` and
43
+ * `<MultiSelector>` both drive the same range-drag session — anchor, bucket
44
+ * snap, the shared band — only what fires on release differs (a span vs.
45
+ * marks), which is A1.5's "one brush engine, two components".
46
+ */
47
+ /** What a completed range drag calls with the released `[start, end]`
48
+ * (axis units, `start ≤ end`). Resolved once at pointer-down — the component
49
+ * path wraps `onDragRelease` (the `{ x }` payload), the legacy path wraps
50
+ * `onRegionSelect` (the bare pair). */
51
+ export interface RangeDrag {
52
+ readonly release: (start: number, end: number) => void;
53
+ /** The modifier the drag needs — only enforced while pan is enabled. */
54
+ readonly modifier: 'shift' | undefined;
55
+ }
56
+ /**
57
+ * Resolve whether a press could start a **range drag**, and who gets the
58
+ * released span. Two sources, component first:
59
+ *
60
+ * 1. **A mounted `<RangeCursor>`** — the hovered row's effective
61
+ * gesture-owning cursor (`owner`), when it carries `onDragRelease` and is
62
+ * not frozen (`enableDrag={false}` — the OFF switch, which also suppresses
63
+ * the legacy fallback: the consumer wired the new API and asked for no
64
+ * gesture). A `<RangeCursor>` without `onDragRelease` has nothing to fire,
65
+ * so it does not claim — the legacy props keep working underneath it
66
+ * during the deprecation window (exactly the step-2 behaviour).
67
+ * 2. **The legacy container props** — `cursor="region"` + `onRegionSelect`
68
+ * (+ `regionSelectModifier`), byte-for-byte today's semantics, including
69
+ * the bare-pair payload.
70
+ *
71
+ * Continuous x only (time or value): a category axis has no span to drag
72
+ * (an ordinal-slot select is a different gesture), so both paths gate on it.
73
+ */
74
+ export declare function resolveRangeDrag(c: Pick<ContainerFrame, 'cursor' | 'onRegionSelect' | 'regionSelectModifier' | 'xKind'>, owner: CursorEntry | undefined): RangeDrag | null;
75
+ /** Who owns the drag a pointer-down might start (see the module doc's
76
+ * precedence order). `'none'` = the press is a potential click only. */
77
+ export type BrushClaim = {
78
+ readonly kind: 'create';
79
+ } | {
80
+ readonly kind: 'sweep';
81
+ } | {
82
+ readonly kind: 'range';
83
+ readonly drag: RangeDrag;
84
+ } | {
85
+ readonly kind: 'pan';
86
+ } | {
87
+ readonly kind: 'none';
88
+ };
89
+ /**
90
+ * The claim decision at pointer-down — pure, so the precedence order is
91
+ * testable without a DOM. Inputs are the already-resolved facts:
92
+ *
93
+ * - `creating` — an annotation tool is armed (claim 1).
94
+ * - `sweep` — a mounted `<MultiSelector>` is in scope AND the row has a
95
+ * sweep-capable layer (claim 2). The sweep preempts the range drag and pan
96
+ * unconditionally — mounting the selector is the intent, and it carries no
97
+ * modifier gate of its own.
98
+ * - `drag` — {@link resolveRangeDrag}'s answer (claim 3), whose `modifier`
99
+ * gates it behind the key **only while pan is enabled**.
100
+ * - `canPan` — this surface has a pan to arm (claim 4): x-pan on a
101
+ * continuous axis, or any y-pan.
102
+ */
103
+ export declare function resolveBrushClaim(opts: {
104
+ readonly creating: boolean;
105
+ readonly sweep?: boolean;
106
+ readonly drag: RangeDrag | null;
107
+ readonly shiftKey: boolean;
108
+ readonly panEnabled: boolean;
109
+ readonly canPan: boolean;
110
+ }): BrushClaim;
111
+ /**
112
+ * Dev-warn (once per plot surface) when a press found BOTH a mounted
113
+ * `<MultiSelector>` and a live range drag (a drag-enabled `<RangeCursor>`, or
114
+ * the legacy region props) competing for it — the sweep wins (see the module
115
+ * doc's precedence), and a silent shadow would hide the loser exactly the way
116
+ * A1.5 said docs alone couldn't.
117
+ */
118
+ export declare function warnSweepShadowsRangeDrag(warned: {
119
+ current: boolean;
120
+ }): void;
121
+ /**
122
+ * The **shared band renderer** — the brush's one visual, so the components
123
+ * driving the engine cannot drift apart (RFC A1.5): `<RangeCursor>` renders
124
+ * it via its spec's `renderPlot` slot, and `<MultiSelector>`'s sweep renders
125
+ * the *same function* from `Layers` while a sweep is live (§8.1 — identical
126
+ * pixels is the design, so there is exactly one place that draws them). The
127
+ * container resolves `f.band` / `f.bandLine`; this only draws them.
128
+ */
129
+ export declare function renderBrushBand(f: ResolvedCursorFrame): ReactNode;
130
+ /**
131
+ * The **2-D brush renderer** — the rect a sweep paints over a `twoD` layer
132
+ * (a scatter, a heat map), the counterpart of {@link renderBrushBand} and
133
+ * drawn from the same `theme.brush` tokens so the two brushes read as one
134
+ * gesture in two dimensionalities.
135
+ *
136
+ * A small `+` sits on each end of the drag diagonal: the corner the press
137
+ * anchored and the corner under the pointer. That is the whole reason
138
+ * {@link ResolvedCursorFrame.rect} arrives unsorted — sorting first would put
139
+ * both crosses on the same diagonal regardless of which way the drag went.
140
+ */
141
+ export declare function renderBrushRect(f: ResolvedCursorFrame): ReactNode;
142
+ //# sourceMappingURL=brush.d.ts.map
package/dist/brush.js ADDED
@@ -0,0 +1,179 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Resolve whether a press could start a **range drag**, and who gets the
4
+ * released span. Two sources, component first:
5
+ *
6
+ * 1. **A mounted `<RangeCursor>`** — the hovered row's effective
7
+ * gesture-owning cursor (`owner`), when it carries `onDragRelease` and is
8
+ * not frozen (`enableDrag={false}` — the OFF switch, which also suppresses
9
+ * the legacy fallback: the consumer wired the new API and asked for no
10
+ * gesture). A `<RangeCursor>` without `onDragRelease` has nothing to fire,
11
+ * so it does not claim — the legacy props keep working underneath it
12
+ * during the deprecation window (exactly the step-2 behaviour).
13
+ * 2. **The legacy container props** — `cursor="region"` + `onRegionSelect`
14
+ * (+ `regionSelectModifier`), byte-for-byte today's semantics, including
15
+ * the bare-pair payload.
16
+ *
17
+ * Continuous x only (time or value): a category axis has no span to drag
18
+ * (an ordinal-slot select is a different gesture), so both paths gate on it.
19
+ */
20
+ export function resolveRangeDrag(c, owner) {
21
+ if (c.xKind !== 'time' && c.xKind !== 'value')
22
+ return null;
23
+ if (owner !== undefined && !owner.legacy && owner.onDragRelease) {
24
+ // Frozen: the gesture is off without unwiring the callback (§6's
25
+ // `enableDrag`-as-disabler) — and the legacy fallback stays off too.
26
+ if (owner.enableDrag === false)
27
+ return null;
28
+ const cb = owner.onDragRelease;
29
+ return {
30
+ release: (start, end) => cb({ x: [start, end] }),
31
+ modifier: owner.dragModifier,
32
+ };
33
+ }
34
+ if (c.cursor === 'region' && c.onRegionSelect !== undefined) {
35
+ const cb = c.onRegionSelect;
36
+ return {
37
+ release: (start, end) => cb([start, end]),
38
+ modifier: c.regionSelectModifier,
39
+ };
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * The claim decision at pointer-down — pure, so the precedence order is
45
+ * testable without a DOM. Inputs are the already-resolved facts:
46
+ *
47
+ * - `creating` — an annotation tool is armed (claim 1).
48
+ * - `sweep` — a mounted `<MultiSelector>` is in scope AND the row has a
49
+ * sweep-capable layer (claim 2). The sweep preempts the range drag and pan
50
+ * unconditionally — mounting the selector is the intent, and it carries no
51
+ * modifier gate of its own.
52
+ * - `drag` — {@link resolveRangeDrag}'s answer (claim 3), whose `modifier`
53
+ * gates it behind the key **only while pan is enabled**.
54
+ * - `canPan` — this surface has a pan to arm (claim 4): x-pan on a
55
+ * continuous axis, or any y-pan.
56
+ */
57
+ export function resolveBrushClaim(opts) {
58
+ if (opts.creating)
59
+ return { kind: 'create' };
60
+ if (opts.sweep === true)
61
+ return { kind: 'sweep' };
62
+ if (opts.drag !== null) {
63
+ const needsShift = opts.drag.modifier === 'shift' && opts.panEnabled;
64
+ if (!needsShift || opts.shiftKey)
65
+ return { kind: 'range', drag: opts.drag };
66
+ // Modifier required but not held → the press falls through to pan.
67
+ }
68
+ if (opts.canPan)
69
+ return { kind: 'pan' };
70
+ return { kind: 'none' };
71
+ }
72
+ /**
73
+ * Dev-warn (once per plot surface) when a press found BOTH a mounted
74
+ * `<MultiSelector>` and a live range drag (a drag-enabled `<RangeCursor>`, or
75
+ * the legacy region props) competing for it — the sweep wins (see the module
76
+ * doc's precedence), and a silent shadow would hide the loser exactly the way
77
+ * A1.5 said docs alone couldn't.
78
+ */
79
+ export function warnSweepShadowsRangeDrag(warned) {
80
+ if (warned.current)
81
+ return;
82
+ warned.current = true;
83
+ console.warn('[pond-charts] a <MultiSelector> and a drag-enabled <RangeCursor> (or ' +
84
+ 'the legacy onRegionSelect props) are both in scope for this plot — ' +
85
+ 'the sweep claims the drag and the range drag never fires. Mount one ' +
86
+ 'drag owner per scope, or freeze the cursor with enableDrag={false}. ' +
87
+ 'See docs/rfcs/interaction.md A1.5 / §8.1.');
88
+ }
89
+ /**
90
+ * The **shared band renderer** — the brush's one visual, so the components
91
+ * driving the engine cannot drift apart (RFC A1.5): `<RangeCursor>` renders
92
+ * it via its spec's `renderPlot` slot, and `<MultiSelector>`'s sweep renders
93
+ * the *same function* from `Layers` while a sweep is live (§8.1 — identical
94
+ * pixels is the design, so there is exactly one place that draws them). The
95
+ * container resolves `f.band` / `f.bandLine`; this only draws them.
96
+ */
97
+ export function renderBrushBand(f) {
98
+ // The cursor ink — the theme's cursor colour, else the axis label colour
99
+ // (same resolution as the cursor presets' `cursorInk`).
100
+ const ink = f.theme.cursor ?? f.theme.axis.label;
101
+ // The band's own colours come from `theme.brush` when the theme sets it.
102
+ // With no `brush` this is the pre-token look exactly: cursor ink at 0.12,
103
+ // no edges — so an existing hand-built theme's band does not shift.
104
+ const brush = f.theme.brush;
105
+ const bandFill = brush?.fill ?? ink;
106
+ const bandOpacity = brush === undefined ? 0.12 : 1;
107
+ const edge = brush?.edge;
108
+ // The transposed band (`sweepAxis: 'y'` — a horizontal bar's bins run down
109
+ // the screen). Same tokens, same two-edges-while-dragging rule, geometry
110
+ // rotated: full width, bounded on y. Rendered from this function rather than
111
+ // a sibling so the promise that there is exactly one brush visual survives
112
+ // the second orientation.
113
+ // Truthiness rather than `!== null`, so a hand-built frame that predates
114
+ // this field (several tests, and any consumer's) keeps the x behaviour
115
+ // instead of crashing on an absent one.
116
+ const by = f.bandY;
117
+ if (by) {
118
+ const y0 = Math.min(by.y0, by.y1);
119
+ const y1 = Math.max(by.y0, by.y1);
120
+ return (_jsxs(_Fragment, { children: [_jsx("rect", { x: 0, y: y0, width: f.plotWidth, height: y1 - y0, fill: bandFill, opacity: bandOpacity }), f.bandDragging &&
121
+ edge !== undefined &&
122
+ [y0, y1].map((y, i) => (_jsx("line", { x1: 0, y1: Math.round(y), x2: f.plotWidth, y2: Math.round(y), stroke: edge, strokeWidth: 1, shapeRendering: "crispEdges" }, i)))] }));
123
+ }
124
+ return (_jsxs(_Fragment, { children: [f.band !== null && (_jsx("rect", { x: f.band.x0, y: 0, width: f.band.x1 - f.band.x0, height: f.rowHeight, fill: bandFill, opacity: bandOpacity })), f.band !== null &&
125
+ f.bandDragging &&
126
+ edge !== undefined &&
127
+ [f.band.x0, f.band.x1].map((x, i) => (_jsx("line", { x1: Math.round(x), y1: 0, x2: Math.round(x), y2: f.rowHeight, stroke: edge, strokeWidth: 1, shapeRendering: "crispEdges" }, i))), f.bandLine && f.cursorX !== null && (_jsx("line", { x1: Math.round(f.cursorX), y1: 0, x2: Math.round(f.cursorX), y2: f.rowHeight, stroke: ink, strokeWidth: 1, shapeRendering: "crispEdges" }))] }));
128
+ }
129
+ /** Half-length of a brush crosshair's arms, in pixels. Small on purpose — it
130
+ * marks a corner, and a full-plot rule at each end of the diagonal would put
131
+ * four lines across a plot the rect is already dividing. */
132
+ const BRUSH_CROSS_PX = 5;
133
+ /** One brush crosshair — the same `+` at rest and at each end of a drag's
134
+ * diagonal, so the resting mark reads as the thing the drag then picks up. */
135
+ function brushCross(key, cx, cy, stroke) {
136
+ const x = Math.round(cx);
137
+ const y = Math.round(cy);
138
+ return (_jsxs("g", { stroke: stroke, strokeWidth: 1, shapeRendering: "crispEdges", children: [_jsx("line", { x1: x - BRUSH_CROSS_PX, y1: y, x2: x + BRUSH_CROSS_PX, y2: y }), _jsx("line", { x1: x, y1: y - BRUSH_CROSS_PX, x2: x, y2: y + BRUSH_CROSS_PX })] }, key));
139
+ }
140
+ /**
141
+ * The **2-D brush renderer** — the rect a sweep paints over a `twoD` layer
142
+ * (a scatter, a heat map), the counterpart of {@link renderBrushBand} and
143
+ * drawn from the same `theme.brush` tokens so the two brushes read as one
144
+ * gesture in two dimensionalities.
145
+ *
146
+ * A small `+` sits on each end of the drag diagonal: the corner the press
147
+ * anchored and the corner under the pointer. That is the whole reason
148
+ * {@link ResolvedCursorFrame.rect} arrives unsorted — sorting first would put
149
+ * both crosses on the same diagonal regardless of which way the drag went.
150
+ */
151
+ export function renderBrushRect(f) {
152
+ const ink = f.theme.cursor ?? f.theme.axis.label;
153
+ const r = f.rect;
154
+ if (r === null) {
155
+ // At rest: one grey `+` at the pointer, in the hovered row only. It is
156
+ // the same mark the drag then pins at its anchor — the gesture reads as
157
+ // picking up what was already under the cursor, rather than swapping one
158
+ // kind of cursor for another.
159
+ return f.restingCross &&
160
+ f.cursorX !== null &&
161
+ f.cursorY !== null &&
162
+ f.rowKey === f.hoveredRowKey
163
+ ? brushCross('rest', f.cursorX, f.cursorY, ink)
164
+ : null;
165
+ }
166
+ const brush = f.theme.brush;
167
+ const fill = brush?.fill ?? ink;
168
+ const opacity = brush === undefined ? 0.12 : 1;
169
+ const edge = brush?.edge ?? ink;
170
+ const x = Math.min(r.x0, r.x1);
171
+ const y = Math.min(r.y0, r.y1);
172
+ const w = Math.abs(r.x1 - r.x0);
173
+ const h = Math.abs(r.y1 - r.y0);
174
+ return (_jsxs(_Fragment, { children: [_jsx("rect", { x: x, y: y, width: w, height: h, fill: fill, opacity: opacity }), _jsx("rect", { x: Math.round(x) + 0.5, y: Math.round(y) + 0.5, width: Math.max(0, Math.round(w) - 1), height: Math.max(0, Math.round(h) - 1), fill: "none", stroke: edge, strokeWidth: 1 }), [
175
+ [r.x0, r.y0],
176
+ [r.x1, r.y1],
177
+ ].map(([cx, cy], i) => brushCross(i, cx, cy, edge))] }));
178
+ }
179
+ //# sourceMappingURL=brush.js.map
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Inject each child's JSX position as an `index` prop, so a child registers its
4
+ * **declaration order** rather than its mount order — and warn (dev, once per
5
+ * component instance) when a `<Fragment>` child swallows that injection.
6
+ *
7
+ * Two components inject this way — `<ChartRow>` into its axes, `<Layers>` into
8
+ * its draw layers — and both are defeated the same way: a fragment accepts no
9
+ * props, so the index stops there and every element inside falls back to its
10
+ * `index = 0` default. What makes it worth a warning rather than a doc note is
11
+ * that the failure is **silent and plausible**: the sort is stable, so the
12
+ * resulting tie resolves to mount order, which on a synchronous tree matches
13
+ * declaration order. The stack looks correct right up to the case the injection
14
+ * exists for — an element toggled on between two others, which lands on top
15
+ * instead of slotting into place.
16
+ *
17
+ * The fragment is deliberately **not** cloned. Cloning it makes React emit its
18
+ * own, vaguer "invalid prop `index` supplied to `React.Fragment`" on top of
19
+ * ours, which is how this class of bug hid in plain sight: the message named the
20
+ * prop but not the consequence, so it read as cosmetic.
21
+ *
22
+ * @param children the component's `children`
23
+ * @param owner the component name, for the warning (e.g. `'<Layers>'`)
24
+ * @param consequence what breaks, in one clause — the part a reader acts on
25
+ */
26
+ export declare function useIndexedChildren(children: ReactNode, owner: string, consequence: string): readonly ReactNode[] | null | undefined;
27
+ //# sourceMappingURL=child-index.d.ts.map