@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.
- package/API.md +576 -0
- package/CHANGELOG.md +1213 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +263 -39
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +36 -48
- package/dist/ChartContainer.js +465 -59
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +176 -14
- package/dist/HeatMap.d.ts +176 -0
- package/dist/HeatMap.js +344 -0
- package/dist/Layers.d.ts +5 -1
- package/dist/Layers.js +1014 -253
- package/dist/Legend.js +8 -4
- package/dist/LineChart.d.ts +18 -1
- package/dist/LineChart.js +165 -4
- package/dist/ListTable.d.ts +30 -3
- package/dist/ListTable.js +381 -23
- package/dist/ScatterChart.d.ts +3 -2
- package/dist/ScatterChart.js +68 -4
- package/dist/XAxis.js +40 -22
- package/dist/YAxis.d.ts +58 -2
- package/dist/YAxis.js +3 -1
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +67 -6
- package/dist/bars.js +250 -35
- package/dist/box.d.ts +2 -2
- package/dist/box.js +158 -40
- package/dist/brush.d.ts +142 -0
- package/dist/brush.js +179 -0
- package/dist/child-index.d.ts +27 -0
- package/dist/child-index.js +57 -0
- package/dist/context.d.ts +870 -39
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.js +16 -1
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -0
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/range.d.ts +14 -1
- package/dist/range.js +24 -3
- package/dist/scatter.d.ts +17 -9
- package/dist/scatter.js +221 -33
- package/dist/select.d.ts +13 -5
- package/dist/select.js +14 -6
- package/dist/selection-fixtures.d.ts +174 -0
- package/dist/selection-fixtures.js +569 -0
- package/dist/selection-stories.d.ts +73 -0
- package/dist/selection-stories.js +301 -0
- package/dist/selectors.d.ts +316 -0
- package/dist/selectors.js +391 -0
- package/dist/span.d.ts +122 -0
- package/dist/span.js +203 -0
- package/dist/sweep.d.ts +154 -0
- package/dist/sweep.js +282 -0
- package/dist/theme.d.ts +510 -5
- package/dist/theme.js +217 -41
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- package/dist/yticks.d.ts +3 -0
- package/dist/yticks.js +104 -0
- 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).
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
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 =
|
|
162
|
-
ctx.globalAlpha =
|
|
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.
|
|
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 =
|
|
175
|
-
ctx.globalAlpha =
|
|
281
|
+
ctx.fillStyle = cFill;
|
|
282
|
+
ctx.globalAlpha = baseFillAlpha * stateAlpha;
|
|
176
283
|
ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
|
|
177
284
|
ctx.restore();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/brush.d.ts
ADDED
|
@@ -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
|