@pluot/react 0.1.16 → 0.1.17

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.
@@ -0,0 +1,360 @@
1
+ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
2
+ import { throttle } from "lodash-es";
3
+ import { clampToBrushRegion, getBrushGeometry, getClearButtonCenter, getEdgeDragCorners, getVerticesBoundingBox, isDegenerateBrush, isPointInBrush, rectVerticesFromCorners, reprojectBrushState, vertexFromPixels, } from "./brush.js";
4
+ import { NO_BRUSH } from "./types.js";
5
+ /** Cursor movement (in pixels) that cancels a pending long-click, since the user is panning instead. */
6
+ const LONG_CLICK_CANCEL_PX = 4;
7
+ /** Caps how many vertices a lasso drag can produce, per the `brushMode: "Polygon"` contract. */
8
+ const POLYGON_VERTEX_THROTTLE_MS = 40;
9
+ /** Radius (in pixels) of the hover target around the clear button. */
10
+ export const CLEAR_BUTTON_RADIUS_PX = 9;
11
+ /**
12
+ * Implements the brush interactions: long-click to create a rect/lasso, drag a
13
+ * vertex to edit, and click the clear button to cancel.
14
+ *
15
+ * Brushing is controlled when `brush` is a `BrushState` or `undefined`, and
16
+ * uncontrolled when `brush` is `null` (mirroring `cameraMatrix`/`setCameraMatrix`).
17
+ * When controlled, nothing is stored here: updates are emitted via `onBrush`/
18
+ * `onBrushEnd` and the parent is expected to feed them back through `brush`.
19
+ */
20
+ export function useBrush(params) {
21
+ const { containerRef, width, height, marginTop, marginRight, marginBottom, marginLeft, aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix, brushUnitsModeX = "Data", brushUnitsModeY = "Data", brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft, enableBrushCreate = false, enableBrushEdit = false, enableBrushClear = false, brushDelay = 1500, maybeBrushDelay = 250, persistBrush = false, brushMode = "Rect", brush: controlledBrush, onBrush, onBrushEnd, onBrushClear, } = params;
22
+ // `null` (or an omitted prop) means uncontrolled; a BrushState or `NO_BRUSH`
23
+ // means controlled, with `NO_BRUSH` standing for "controlled, nothing brushed".
24
+ const isControlledBrush = controlledBrush !== null && controlledBrush !== undefined;
25
+ const [uncontrolledBrush, setUncontrolledBrush] = useState(undefined);
26
+ const rawBrush = isControlledBrush
27
+ ? (controlledBrush === NO_BRUSH ? undefined : controlledBrush)
28
+ : uncontrolledBrush;
29
+ // A parent may switch between controlled and uncontrolled at runtime. Whatever
30
+ // was stored during an earlier uncontrolled phase is not the current selection,
31
+ // so drop it rather than let it resurface if the brush ever goes back.
32
+ useEffect(() => {
33
+ if (isControlledBrush) {
34
+ setUncontrolledBrush(undefined);
35
+ }
36
+ }, [isControlledBrush]);
37
+ const [pressProgress, setPressProgress] = useState(null);
38
+ const [isBrushHovered, setIsBrushHovered] = useState(false);
39
+ const isBrushingRef = useRef(false);
40
+ const shouldSuppressClickRef = useRef(false);
41
+ // The overlay SVG, so that presses on its handles can be told apart from
42
+ // presses on the plot itself.
43
+ const overlayRef = useRef(null);
44
+ const interactionRef = useRef(null);
45
+ // The in-progress brush, so that incremental updates (e.g. appending lasso
46
+ // vertices) do not depend on a controlled parent having fed state back yet.
47
+ const draftRef = useRef(null);
48
+ // Bookkeeping for the pending long-click, before any brush exists.
49
+ const pendingPressRef = useRef(null);
50
+ const geometry = useMemo(() => getBrushGeometry({
51
+ width, height,
52
+ marginTop, marginRight, marginBottom, marginLeft,
53
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
54
+ brushUnitsModeX, brushUnitsModeY,
55
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
56
+ }), [
57
+ width, height, marginTop, marginRight, marginBottom, marginLeft,
58
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
59
+ brushUnitsModeX, brushUnitsModeY,
60
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
61
+ ]);
62
+ // Re-derive the pixel positions under the current geometry, so that a brush
63
+ // with a "Data" units mode follows the camera as the user zooms/pans.
64
+ const brushState = useMemo(() => (rawBrush ? reprojectBrushState(rawBrush, geometry, brushUnitsModeX, brushUnitsModeY) : undefined), [rawBrush, geometry, brushUnitsModeX, brushUnitsModeY]);
65
+ // Convert a mouse event to a position relative to the top-left of the
66
+ // container, which is the coordinate space of the brush overlay SVG.
67
+ // `offsetX`/`offsetY` are unusable here because a drag may travel over
68
+ // several descendants (or leave the container entirely).
69
+ const getContainerCoords = useCallback((event) => {
70
+ const containerEl = containerRef.current;
71
+ if (!containerEl) {
72
+ return [0, 0];
73
+ }
74
+ const rect = containerEl.getBoundingClientRect();
75
+ return [event.clientX - rect.left, event.clientY - rect.top];
76
+ }, [containerRef]);
77
+ // Push a brush update out: internally when uncontrolled, and to the parent in
78
+ // both cases. Until the Rust-side `Brushable` trait lands there is nothing to
79
+ // snap to, so the snapped state is the state and the `BrushResult` is unused.
80
+ const emitBrush = useEffectEvent((nextBrush, isEnd) => {
81
+ // The draft is always advanced, since the rest of the drag builds on it...
82
+ draftRef.current = nextBrush;
83
+ // ...but a brush that spans nothing is not a selection. Committing one would
84
+ // strand a stray dot on screen (four coincident vertex handles) that the user
85
+ // then has to clear, so hold it back until the drag gives it some extent.
86
+ if (isDegenerateBrush(nextBrush)) {
87
+ return;
88
+ }
89
+ if (!isControlledBrush) {
90
+ setUncontrolledBrush(nextBrush);
91
+ }
92
+ if (isEnd) {
93
+ onBrushEnd?.(nextBrush, nextBrush);
94
+ // `persistBrush` only applies when uncontrolled; when controlled, the brush
95
+ // persists for exactly as long as the parent keeps passing it.
96
+ if (!isControlledBrush && !persistBrush) {
97
+ setUncontrolledBrush(undefined);
98
+ draftRef.current = null;
99
+ }
100
+ }
101
+ else {
102
+ onBrush?.(nextBrush, nextBrush);
103
+ }
104
+ });
105
+ const clearBrush = useEffectEvent(() => {
106
+ const cleared = draftRef.current ?? rawBrush;
107
+ draftRef.current = null;
108
+ if (!isControlledBrush) {
109
+ setUncontrolledBrush(undefined);
110
+ }
111
+ setIsBrushHovered(false);
112
+ if (cleared) {
113
+ onBrushClear?.(cleared);
114
+ }
115
+ });
116
+ // --- Drawing a new brush ---
117
+ const startBrush = useEffectEvent((xPixels, yPixels) => {
118
+ const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
119
+ isBrushingRef.current = true;
120
+ interactionRef.current = { kind: "Create", anchorX: x, anchorY: y };
121
+ const vertices = brushMode === "Polygon"
122
+ ? [vertexFromPixels(x, y, geometry)]
123
+ : rectVerticesFromCorners(x, y, x, y, geometry, brushMode);
124
+ emitBrush({ status: "Drawing", shape: brushMode, vertices }, false);
125
+ });
126
+ const appendPolygonVertex = useEffectEvent((xPixels, yPixels) => {
127
+ const draft = draftRef.current;
128
+ if (!draft || draft.shape !== "Polygon") {
129
+ return;
130
+ }
131
+ const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
132
+ emitBrush({ ...draft, vertices: [...draft.vertices, vertexFromPixels(x, y, geometry)] }, false);
133
+ });
134
+ // The lasso samples the cursor on a timer rather than on every mousemove, to
135
+ // keep the vertex count bounded regardless of how slowly the user drags.
136
+ const throttledAppendPolygonVertex = useMemo(() => throttle(appendPolygonVertex, POLYGON_VERTEX_THROTTLE_MS, { leading: true, trailing: true }), []);
137
+ const updateRect = useEffectEvent((xPixels, yPixels) => {
138
+ const interaction = interactionRef.current;
139
+ const draft = draftRef.current;
140
+ if (!interaction || !draft || draft.shape === "Polygon") {
141
+ return;
142
+ }
143
+ const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
144
+ // Every rect-like drag reduces to a fixed corner plus a moving one. While
145
+ // creating, the fixed corner is the press point; while dragging a corner, it
146
+ // is the corner diagonally opposite; while dragging a side, it is a corner of
147
+ // the opposite side, and the cursor drives only one axis of the moving corner.
148
+ const fixedX = interaction.kind === "Create" ? interaction.anchorX : interaction.fixedX;
149
+ const fixedY = interaction.kind === "Create" ? interaction.anchorY : interaction.fixedY;
150
+ const movingX = interaction.kind === "EditEdge" && interaction.axis === "Y" ? interaction.movingX : x;
151
+ const movingY = interaction.kind === "EditEdge" && interaction.axis === "X" ? interaction.movingY : y;
152
+ emitBrush({
153
+ ...draft,
154
+ // For RangeX/RangeY this discards the cross-axis drag, so dragging any
155
+ // corner only ever moves the selected edge.
156
+ vertices: rectVerticesFromCorners(fixedX, fixedY, movingX, movingY, geometry, draft.shape),
157
+ }, false);
158
+ });
159
+ const movePolygonVertex = useEffectEvent((vertexIndex, xPixels, yPixels) => {
160
+ const draft = draftRef.current;
161
+ if (!draft) {
162
+ return;
163
+ }
164
+ const [x, y] = clampToBrushRegion(xPixels, yPixels, geometry);
165
+ const vertices = draft.vertices.map((vertex, i) => (i === vertexIndex ? vertexFromPixels(x, y, geometry) : vertex));
166
+ emitBrush({ ...draft, vertices }, false);
167
+ });
168
+ const endBrush = useEffectEvent(() => {
169
+ throttledAppendPolygonVertex.cancel();
170
+ const draft = draftRef.current;
171
+ interactionRef.current = null;
172
+ isBrushingRef.current = false;
173
+ if (!draft) {
174
+ return;
175
+ }
176
+ shouldSuppressClickRef.current = true;
177
+ emitBrush({ ...draft, status: "Complete" }, true);
178
+ });
179
+ // --- Long-click detection ---
180
+ const cancelPendingPress = useCallback(() => {
181
+ if (pendingPressRef.current) {
182
+ cancelAnimationFrame(pendingPressRef.current.rafId);
183
+ pendingPressRef.current = null;
184
+ setPressProgress(null);
185
+ }
186
+ }, []);
187
+ // Runs once per frame while the button is held, showing the filling wedge from
188
+ // `maybeBrushDelay` onwards and handing off to `startBrush` at `brushDelay`.
189
+ const tickPendingPress = useEffectEvent(() => {
190
+ const pending = pendingPressRef.current;
191
+ if (!pending) {
192
+ return;
193
+ }
194
+ const elapsed = performance.now() - pending.startTime;
195
+ if (elapsed >= brushDelay) {
196
+ const { x, y } = pending;
197
+ cancelPendingPress();
198
+ startBrush(x, y);
199
+ return;
200
+ }
201
+ setPressProgress(elapsed >= maybeBrushDelay
202
+ ? { xPixels: pending.x, yPixels: pending.y, fraction: elapsed / brushDelay }
203
+ : null);
204
+ pending.rafId = requestAnimationFrame(tickPendingPress);
205
+ });
206
+ const mouseDownHandler = useEffectEvent((event) => {
207
+ // Only a primary-button press inside the brushable region can start a brush.
208
+ if (!enableBrushCreate || event.button !== 0 || interactionRef.current) {
209
+ return;
210
+ }
211
+ // Presses on the overlay's own controls (the vertex handles and the clear
212
+ // button) are not attempts to draw a new brush. Their React handlers cannot
213
+ // prevent this: React dispatches from its root, by which point this native
214
+ // listener on an ancestor has already run, so `stopPropagation` is too late.
215
+ if (event.target instanceof Node && overlayRef.current?.contains(event.target)) {
216
+ return;
217
+ }
218
+ const [x, y] = getContainerCoords(event);
219
+ if (x < geometry.brushLeft || x > geometry.brushRight || y < geometry.brushTop || y > geometry.brushBottom) {
220
+ return;
221
+ }
222
+ cancelPendingPress();
223
+ pendingPressRef.current = { x, y, startTime: performance.now(), rafId: requestAnimationFrame(tickPendingPress) };
224
+ });
225
+ const mouseMoveHandler = useEffectEvent((event) => {
226
+ // This listener is on the window, so bail out before reading the container's
227
+ // layout when there is nothing brush-related to track.
228
+ const isTrackingHover = enableBrushClear && brushState !== undefined;
229
+ if (!pendingPressRef.current && !interactionRef.current && !isTrackingHover) {
230
+ return;
231
+ }
232
+ const [x, y] = getContainerCoords(event);
233
+ // Moving before the long-click completes means the user is panning, not brushing.
234
+ const pending = pendingPressRef.current;
235
+ if (pending && Math.hypot(x - pending.x, y - pending.y) > LONG_CLICK_CANCEL_PX) {
236
+ cancelPendingPress();
237
+ }
238
+ const interaction = interactionRef.current;
239
+ if (interaction) {
240
+ if (interaction.kind === "Create") {
241
+ if (brushMode === "Polygon") {
242
+ throttledAppendPolygonVertex(x, y);
243
+ }
244
+ else {
245
+ updateRect(x, y);
246
+ }
247
+ }
248
+ else if (interaction.kind === "EditVertex" && draftRef.current?.shape === "Polygon") {
249
+ movePolygonVertex(interaction.vertexIndex, x, y);
250
+ }
251
+ else {
252
+ updateRect(x, y);
253
+ }
254
+ return;
255
+ }
256
+ // Not dragging: track whether the pointer is over the brush, to decide
257
+ // whether to reveal the clear button.
258
+ if (isTrackingHover && brushState) {
259
+ const clearButtonCenter = getClearButtonCenter(brushState.vertices, CLEAR_BUTTON_RADIUS_PX, geometry);
260
+ // The button sits outside the brush, so it needs its own hover target;
261
+ // otherwise it would vanish as soon as the pointer moved towards it.
262
+ const isOverClearButton = clearButtonCenter !== null
263
+ && Math.hypot(x - clearButtonCenter[0], y - clearButtonCenter[1]) <= CLEAR_BUTTON_RADIUS_PX * 2;
264
+ setIsBrushHovered(isOverClearButton || isPointInBrush(x, y, brushState.vertices));
265
+ }
266
+ });
267
+ const mouseUpHandler = useEffectEvent(() => {
268
+ cancelPendingPress();
269
+ if (interactionRef.current) {
270
+ endBrush();
271
+ }
272
+ });
273
+ const mouseLeaveHandler = useEffectEvent(() => {
274
+ // Only the hover affordance is reset here; an in-flight drag continues,
275
+ // since its listeners are on the window.
276
+ if (!interactionRef.current) {
277
+ cancelPendingPress();
278
+ setIsBrushHovered(false);
279
+ }
280
+ });
281
+ useEffect(() => {
282
+ const containerEl = containerRef.current;
283
+ if (!containerEl) {
284
+ return () => { };
285
+ }
286
+ containerEl.addEventListener("mousedown", mouseDownHandler);
287
+ containerEl.addEventListener("mouseleave", mouseLeaveHandler);
288
+ // Move/up go on the window so a drag that leaves the container still
289
+ // updates (clamped to the brushable region) and still terminates.
290
+ window.addEventListener("mousemove", mouseMoveHandler);
291
+ window.addEventListener("mouseup", mouseUpHandler);
292
+ return () => {
293
+ containerEl.removeEventListener("mousedown", mouseDownHandler);
294
+ containerEl.removeEventListener("mouseleave", mouseLeaveHandler);
295
+ window.removeEventListener("mousemove", mouseMoveHandler);
296
+ window.removeEventListener("mouseup", mouseUpHandler);
297
+ };
298
+ }, [containerRef]);
299
+ useEffect(() => () => {
300
+ cancelPendingPress();
301
+ throttledAppendPolygonVertex.cancel();
302
+ }, [cancelPendingPress, throttledAppendPolygonVertex]);
303
+ // --- Handlers for the overlay's own SVG elements ---
304
+ const vertexMouseDown = useEffectEvent((vertexIndex, event) => {
305
+ if (!enableBrushEdit || !brushState || event.button !== 0) {
306
+ return;
307
+ }
308
+ // `mouseDownHandler` already ignored this press; preventDefault only stops
309
+ // the browser's own text-selection/drag behaviour.
310
+ event.preventDefault();
311
+ cancelPendingPress();
312
+ draftRef.current = brushState;
313
+ isBrushingRef.current = true;
314
+ const oppositeVertex = brushState.vertices[(vertexIndex + 2) % 4];
315
+ interactionRef.current = {
316
+ kind: "EditVertex",
317
+ vertexIndex,
318
+ // Only meaningful for a Rect, where the opposite corner is the pivot.
319
+ fixedX: oppositeVertex?.x_pixels ?? 0,
320
+ fixedY: oppositeVertex?.y_pixels ?? 0,
321
+ };
322
+ });
323
+ const edgeMouseDown = useEffectEvent((edge, event) => {
324
+ if (!enableBrushEdit || !brushState || event.button !== 0) {
325
+ return;
326
+ }
327
+ const boundingBox = getVerticesBoundingBox(brushState.vertices);
328
+ if (!boundingBox) {
329
+ return;
330
+ }
331
+ // See `vertexMouseDown`: preventDefault only stops text selection here.
332
+ event.preventDefault();
333
+ cancelPendingPress();
334
+ draftRef.current = brushState;
335
+ isBrushingRef.current = true;
336
+ interactionRef.current = { kind: "EditEdge", ...getEdgeDragCorners(edge, boundingBox) };
337
+ });
338
+ const clearClick = useEffectEvent((event) => {
339
+ // The overlay is a sibling of the camera element rather than an ancestor,
340
+ // so this click never reaches the picking handler and needs no suppression.
341
+ event.preventDefault();
342
+ clearBrush();
343
+ });
344
+ // Wrap in plain callbacks, since useEffectEvent functions may not be handed to children.
345
+ const onVertexMouseDown = useCallback((vertexIndex, event) => vertexMouseDown(vertexIndex, event), []);
346
+ const onEdgeMouseDown = useCallback((edge, event) => edgeMouseDown(edge, event), []);
347
+ const onClearClick = useCallback((event) => clearClick(event), []);
348
+ return {
349
+ brushState,
350
+ geometry,
351
+ overlayRef,
352
+ pressProgress,
353
+ isBrushHovered: isBrushHovered && enableBrushClear,
354
+ isBrushingRef,
355
+ shouldSuppressClickRef,
356
+ onVertexMouseDown,
357
+ onEdgeMouseDown,
358
+ onClearClick,
359
+ };
360
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pluot/react",
3
3
  "private": false,
4
- "version": "0.1.16",
4
+ "version": "0.1.17",
5
5
  "description": "React component for visualization with Pluot",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Mark Keller",
@@ -36,7 +36,7 @@
36
36
  "mouse-wheel": "^1.0.2",
37
37
  "lz-string": "^1.5.0",
38
38
  "lodash-es": "^4.17.23",
39
- "@pluot/core": "0.1.16"
39
+ "@pluot/core": "0.1.17"
40
40
  },
41
41
  "devDependencies": {
42
42
  "vitest": "^3.2.4",
@@ -44,7 +44,9 @@
44
44
  "@vitejs/plugin-react": "^5.0.0",
45
45
  "jsdom": "^26.1.0",
46
46
  "vitest-canvas-mock": "^0.3.3",
47
- "typescript": "^5.9"
47
+ "typescript": "^5.9",
48
+ "@types/react": "^19.2.14",
49
+ "@types/lodash-es": "^4.17.12"
48
50
  },
49
51
  "peerDependencies": {
50
52
  "react": "^19.0.0"
@@ -0,0 +1,254 @@
1
+ import React, { useId, useMemo, type RefObject } from "react";
2
+ import {
3
+ describeWedgePath, getClearButtonCenter, getEdgeLine, getEditableEdges, getVerticesBoundingBox,
4
+ type BrushEdge, type BrushGeometry,
5
+ } from "./brush.js";
6
+ import type { BrushState } from "./types.js";
7
+ import { CLEAR_BUTTON_RADIUS_PX, type BrushPressProgress } from "./use-brush.js";
8
+
9
+ const VERTEX_HANDLE_RADIUS_PX = 4;
10
+ const PRESS_INDICATOR_RADIUS_PX = 10;
11
+ /** How wide a side's invisible grab target is. Kept generous, since a side is 1.5px of ink. */
12
+ const EDGE_HANDLE_WIDTH_PX = 9;
13
+
14
+ /** Opacity of the brush's fill, relative to `color`; the stroke and handles stay fully opaque. */
15
+ const BRUSH_FILL_OPACITY = 0.15;
16
+ const HANDLE_FILL = "#ffffff";
17
+ const CLEAR_FILL = "#b34040";
18
+
19
+ // Note: we could alternatively render the brush overlay on the rust side, as a new layer type.
20
+ // This would solve the problem of syncing the overlay (on the JS side) with the rendered visualization (on the rust side) upon camera interactions.
21
+ // However this syncing problem only arises when the brush units mode is Data _and_ the brush overlay is persisted beyond the brush creation.
22
+
23
+ export type BrushOverlayProps = {
24
+ width: number;
25
+ height: number;
26
+ /** From `useBrush`, so that presses on the handles below are not read as new brushes. */
27
+ overlayRef: RefObject<SVGSVGElement | null>;
28
+ /** Supplies the brushable region, which everything drawn here is clipped to. */
29
+ geometry: BrushGeometry;
30
+ /** Stroke color of the brush outline/handles; the fill uses the same color at reduced opacity. */
31
+ color: string;
32
+ brushState: BrushState | undefined;
33
+ pressProgress: BrushPressProgress | null;
34
+ /** Whether to draw the clear button (the pointer is over the brush and `enableBrushClear`). */
35
+ isBrushHovered: boolean;
36
+ enableBrushEdit: boolean;
37
+ onVertexMouseDown: (vertexIndex: number, event: React.MouseEvent) => void;
38
+ onEdgeMouseDown: (edge: BrushEdge, event: React.MouseEvent) => void;
39
+ onClearClick: (event: React.MouseEvent) => void;
40
+ };
41
+
42
+ /** A side is dragged along its perpendicular, so it takes the matching resize cursor. */
43
+ function getEdgeCursor(edge: BrushEdge): string {
44
+ return edge === "Left" || edge === "Right" ? "ew-resize" : "ns-resize";
45
+ }
46
+
47
+ /**
48
+ * The cursor for corner `vertexIndex`, which advertises the axes that corner can
49
+ * actually move: a range brush only resizes along the axis it selects, and a rect
50
+ * corner resizes along the diagonal it sits on.
51
+ */
52
+ function getVertexCursor(shape: BrushState['shape'] | undefined, vertexIndex: number): string {
53
+ if (shape === "RangeX") {
54
+ return "ew-resize";
55
+ }
56
+ if (shape === "RangeY") {
57
+ return "ns-resize";
58
+ }
59
+ if (shape === "Rect") {
60
+ // Corners are ordered clockwise from the top-left.
61
+ return vertexIndex % 2 === 0 ? "nwse-resize" : "nesw-resize";
62
+ }
63
+ return "grab";
64
+ }
65
+
66
+ /**
67
+ * Draws the brush as an SVG above the plot: a rectangle (or lasso polygon) with
68
+ * a circle at each vertex, plus the long-click progress wedge and the clear button.
69
+ *
70
+ * The SVG root is `pointerEvents: none` so that it never intercepts the camera's
71
+ * pan/zoom; only the vertex handles and the clear button opt back in.
72
+ */
73
+ export function BrushOverlay(props: BrushOverlayProps) {
74
+ const {
75
+ width, height,
76
+ overlayRef,
77
+ geometry,
78
+ color,
79
+ brushState,
80
+ pressProgress,
81
+ isBrushHovered,
82
+ enableBrushEdit,
83
+ onVertexMouseDown,
84
+ onEdgeMouseDown,
85
+ onClearClick,
86
+ } = props;
87
+
88
+ const vertices = brushState?.vertices ?? [];
89
+
90
+ // Every shape but the lasso is a closed rectangle throughout the drag; a lasso
91
+ // is left open while the user is still drawing it, and closed once the drag completes.
92
+ const isClosed = (brushState !== undefined && brushState.shape !== "Polygon")
93
+ || brushState?.status === "Complete";
94
+
95
+ const pathData = useMemo(() => {
96
+ if (vertices.length === 0) {
97
+ return null;
98
+ }
99
+ const points = vertices.map(v => `${v.x_pixels},${v.y_pixels}`).join(" L ");
100
+ return `M ${points}${isClosed ? " Z" : ""}`;
101
+ }, [vertices, isClosed]);
102
+
103
+ const clearButtonCenter = getClearButtonCenter(vertices, CLEAR_BUTTON_RADIUS_PX, geometry);
104
+
105
+ // `useId` emits colons, which are legal in an id but awkward inside `url(#...)`.
106
+ const clipPathId = `pluot-brush-clip-${useId().replace(/:/g, "")}`;
107
+
108
+ // While drawing a lasso, the intermediate vertices are too dense to be useful
109
+ // as handles, and they are not editable until the drag completes.
110
+ const shouldShowVertexHandles = isClosed;
111
+
112
+ // Sides are draggable only once the shape is settled, and only for the
113
+ // axis-aligned shapes; a lasso has no meaningful sides.
114
+ const editableEdges = enableBrushEdit && isClosed && brushState
115
+ ? getEditableEdges(brushState.shape)
116
+ : [];
117
+
118
+ // The side handles are the only thing here that needs the extent, so it is not
119
+ // computed for a lasso or for a brush whose sides are not draggable.
120
+ const edgeBoundingBox = editableEdges.length > 0 ? getVerticesBoundingBox(vertices) : null;
121
+
122
+ return (
123
+ <svg
124
+ ref={overlayRef}
125
+ style={{
126
+ position: "absolute",
127
+ top: 0,
128
+ left: 0,
129
+ marginTop: 0,
130
+ marginLeft: 0,
131
+ marginRight: 0,
132
+ marginBottom: 0,
133
+ pointerEvents: "none",
134
+ // Sit above the canvas/SVG plot and the camera element.
135
+ zIndex: 1,
136
+ }}
137
+ width={width}
138
+ height={height}
139
+ viewBox={`0 0 ${width} ${height}`}
140
+ xmlns="http://www.w3.org/2000/svg"
141
+ >
142
+ <defs>
143
+ <clipPath id={clipPathId}>
144
+ <rect
145
+ x={geometry.brushLeft}
146
+ y={geometry.brushTop}
147
+ width={Math.max(geometry.brushRight - geometry.brushLeft, 0)}
148
+ height={Math.max(geometry.brushBottom - geometry.brushTop, 0)}
149
+ />
150
+ </clipPath>
151
+ </defs>
152
+ {/* Everything is clipped to the brushable region: a brush anchored in data
153
+ units scrolls with the camera, so without this it would spill over the
154
+ axes and the surrounding margins as the user pans or zooms out. Clipping
155
+ also applies to hit-testing, so handles that have scrolled out of the
156
+ region stop responding, matching what the user can see. */}
157
+ <g clipPath={`url(#${clipPathId})`}>
158
+ {pathData ? (
159
+ <path
160
+ d={pathData}
161
+ fill={isClosed ? color : "none"}
162
+ fillOpacity={isClosed ? BRUSH_FILL_OPACITY : undefined}
163
+ stroke={color}
164
+ strokeWidth={1.5}
165
+ strokeDasharray={brushState?.status === "Drawing" ? "4 3" : undefined}
166
+ />
167
+ ) : null}
168
+ {/* Drawn before the corner handles, so a press near a corner grabs the
169
+ corner rather than one of the two sides meeting there. */}
170
+ {edgeBoundingBox === null ? null : editableEdges.map(edge => {
171
+ const [x1, y1, x2, y2] = getEdgeLine(edge, edgeBoundingBox);
172
+ return (
173
+ <line
174
+ key={edge}
175
+ x1={x1}
176
+ y1={y1}
177
+ x2={x2}
178
+ y2={y2}
179
+ // Invisible ink, but a wide grab target.
180
+ stroke="transparent"
181
+ strokeWidth={EDGE_HANDLE_WIDTH_PX}
182
+ strokeLinecap="butt"
183
+ style={{ pointerEvents: "stroke", cursor: getEdgeCursor(edge) }}
184
+ onMouseDown={event => onEdgeMouseDown(edge, event)}
185
+ />
186
+ );
187
+ })}
188
+ {shouldShowVertexHandles ? vertices.map((vertex, vertexIndex) => (
189
+ <circle
190
+ // Vertices have no identity beyond their position in the ring, and the
191
+ // list is rebuilt on every update, so the index is the only stable key.
192
+ key={vertexIndex}
193
+ cx={vertex.x_pixels}
194
+ cy={vertex.y_pixels}
195
+ r={VERTEX_HANDLE_RADIUS_PX}
196
+ fill={HANDLE_FILL}
197
+ stroke={color}
198
+ strokeWidth={1.5}
199
+ style={{
200
+ pointerEvents: enableBrushEdit ? "auto" : "none",
201
+ cursor: enableBrushEdit ? getVertexCursor(brushState?.shape, vertexIndex) : "default",
202
+ }}
203
+ onMouseDown={enableBrushEdit ? (event => onVertexMouseDown(vertexIndex, event)) : undefined}
204
+ />
205
+ )) : null}
206
+ {isBrushHovered && clearButtonCenter ? (
207
+ <g
208
+ style={{ pointerEvents: "auto", cursor: "pointer" }}
209
+ onClick={onClearClick}
210
+ role="button"
211
+ aria-label="Clear brush"
212
+ >
213
+ <circle
214
+ cx={clearButtonCenter[0]}
215
+ cy={clearButtonCenter[1]}
216
+ r={CLEAR_BUTTON_RADIUS_PX}
217
+ fill={CLEAR_FILL}
218
+ />
219
+ <path
220
+ d={
221
+ `M ${clearButtonCenter[0] - 4} ${clearButtonCenter[1] - 4} L ${clearButtonCenter[0] + 4} ${clearButtonCenter[1] + 4} `
222
+ + `M ${clearButtonCenter[0] + 4} ${clearButtonCenter[1] - 4} L ${clearButtonCenter[0] - 4} ${clearButtonCenter[1] + 4}`
223
+ }
224
+ stroke="#ffffff"
225
+ strokeWidth={1.5}
226
+ strokeLinecap="round"
227
+ />
228
+ </g>
229
+ ) : null}
230
+ {pressProgress ? (
231
+ <g>
232
+ <circle
233
+ cx={pressProgress.xPixels}
234
+ cy={pressProgress.yPixels}
235
+ r={PRESS_INDICATOR_RADIUS_PX}
236
+ fill="rgba(255, 255, 255, 0.6)"
237
+ stroke={color}
238
+ strokeWidth={1.5}
239
+ />
240
+ <path
241
+ d={describeWedgePath(
242
+ pressProgress.xPixels,
243
+ pressProgress.yPixels,
244
+ PRESS_INDICATOR_RADIUS_PX,
245
+ pressProgress.fraction,
246
+ )}
247
+ fill={color}
248
+ />
249
+ </g>
250
+ ) : null}
251
+ </g>
252
+ </svg>
253
+ );
254
+ }