@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.
- package/dist/index.js +994 -100
- package/dist-tsc/BrushOverlay.d.ts +31 -0
- package/dist-tsc/BrushOverlay.d.ts.map +1 -0
- package/dist-tsc/BrushOverlay.js +91 -0
- package/dist-tsc/Pluot.d.ts +2 -1
- package/dist-tsc/Pluot.d.ts.map +1 -1
- package/dist-tsc/Pluot.js +59 -22
- package/dist-tsc/Tooltip.d.ts +2 -1
- package/dist-tsc/Tooltip.d.ts.map +1 -1
- package/dist-tsc/Tooltip.js +15 -1
- package/dist-tsc/brush.d.ts +155 -0
- package/dist-tsc/brush.d.ts.map +1 -0
- package/dist-tsc/brush.js +312 -0
- package/dist-tsc/brush.test.d.ts +2 -0
- package/dist-tsc/brush.test.d.ts.map +1 -0
- package/dist-tsc/brush.test.js +487 -0
- package/dist-tsc/index.d.ts +3 -1
- package/dist-tsc/index.d.ts.map +1 -1
- package/dist-tsc/index.js +1 -0
- package/dist-tsc/types.d.ts +255 -0
- package/dist-tsc/types.d.ts.map +1 -0
- package/dist-tsc/types.js +11 -0
- package/dist-tsc/use-brush.d.ts +53 -0
- package/dist-tsc/use-brush.d.ts.map +1 -0
- package/dist-tsc/use-brush.js +360 -0
- package/package.json +5 -3
- package/src/BrushOverlay.tsx +254 -0
- package/src/{Pluot.jsx → Pluot.tsx} +132 -46
- package/src/{Tooltip.jsx → Tooltip.tsx} +19 -3
- package/src/brush.test.ts +590 -0
- package/src/brush.ts +435 -0
- package/src/index.ts +24 -0
- package/src/types.ts +378 -0
- package/src/use-brush.ts +501 -0
- package/src/index.js +0 -2
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|