@pluot/react 0.1.15 → 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,31 @@
1
+ import React, { type RefObject } from "react";
2
+ import { type BrushEdge, type BrushGeometry } from "./brush.js";
3
+ import type { BrushState } from "./types.js";
4
+ import { type BrushPressProgress } from "./use-brush.js";
5
+ export type BrushOverlayProps = {
6
+ width: number;
7
+ height: number;
8
+ /** From `useBrush`, so that presses on the handles below are not read as new brushes. */
9
+ overlayRef: RefObject<SVGSVGElement | null>;
10
+ /** Supplies the brushable region, which everything drawn here is clipped to. */
11
+ geometry: BrushGeometry;
12
+ /** Stroke color of the brush outline/handles; the fill uses the same color at reduced opacity. */
13
+ color: string;
14
+ brushState: BrushState | undefined;
15
+ pressProgress: BrushPressProgress | null;
16
+ /** Whether to draw the clear button (the pointer is over the brush and `enableBrushClear`). */
17
+ isBrushHovered: boolean;
18
+ enableBrushEdit: boolean;
19
+ onVertexMouseDown: (vertexIndex: number, event: React.MouseEvent) => void;
20
+ onEdgeMouseDown: (edge: BrushEdge, event: React.MouseEvent) => void;
21
+ onClearClick: (event: React.MouseEvent) => void;
22
+ };
23
+ /**
24
+ * Draws the brush as an SVG above the plot: a rectangle (or lasso polygon) with
25
+ * a circle at each vertex, plus the long-click progress wedge and the clear button.
26
+ *
27
+ * The SVG root is `pointerEvents: none` so that it never intercepts the camera's
28
+ * pan/zoom; only the vertex handles and the clear button opt back in.
29
+ */
30
+ export declare function BrushOverlay(props: BrushOverlayProps): import("react/jsx-runtime").JSX.Element;
31
+ //# sourceMappingURL=BrushOverlay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrushOverlay.d.ts","sourceRoot":"","sources":["../src/BrushOverlay.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAkB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC9D,OAAO,EAEL,KAAK,SAAS,EAAE,KAAK,aAAa,EACnC,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAA0B,KAAK,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAgBjF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,yFAAyF;IACzF,UAAU,EAAE,SAAS,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAC5C,gFAAgF;IAChF,QAAQ,EAAE,aAAa,CAAC;IACxB,kGAAkG;IAClG,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,UAAU,GAAG,SAAS,CAAC;IACnC,aAAa,EAAE,kBAAkB,GAAG,IAAI,CAAC;IACzC,+FAA+F;IAC/F,cAAc,EAAE,OAAO,CAAC;IACxB,eAAe,EAAE,OAAO,CAAC;IACzB,iBAAiB,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAC1E,eAAe,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACpE,YAAY,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;CACjD,CAAC;AA0BF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,2CAqLpD"}
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useMemo } from "react";
3
+ import { describeWedgePath, getClearButtonCenter, getEdgeLine, getEditableEdges, getVerticesBoundingBox, } from "./brush.js";
4
+ import { CLEAR_BUTTON_RADIUS_PX } from "./use-brush.js";
5
+ const VERTEX_HANDLE_RADIUS_PX = 4;
6
+ const PRESS_INDICATOR_RADIUS_PX = 10;
7
+ /** How wide a side's invisible grab target is. Kept generous, since a side is 1.5px of ink. */
8
+ const EDGE_HANDLE_WIDTH_PX = 9;
9
+ /** Opacity of the brush's fill, relative to `color`; the stroke and handles stay fully opaque. */
10
+ const BRUSH_FILL_OPACITY = 0.15;
11
+ const HANDLE_FILL = "#ffffff";
12
+ const CLEAR_FILL = "#b34040";
13
+ /** A side is dragged along its perpendicular, so it takes the matching resize cursor. */
14
+ function getEdgeCursor(edge) {
15
+ return edge === "Left" || edge === "Right" ? "ew-resize" : "ns-resize";
16
+ }
17
+ /**
18
+ * The cursor for corner `vertexIndex`, which advertises the axes that corner can
19
+ * actually move: a range brush only resizes along the axis it selects, and a rect
20
+ * corner resizes along the diagonal it sits on.
21
+ */
22
+ function getVertexCursor(shape, vertexIndex) {
23
+ if (shape === "RangeX") {
24
+ return "ew-resize";
25
+ }
26
+ if (shape === "RangeY") {
27
+ return "ns-resize";
28
+ }
29
+ if (shape === "Rect") {
30
+ // Corners are ordered clockwise from the top-left.
31
+ return vertexIndex % 2 === 0 ? "nwse-resize" : "nesw-resize";
32
+ }
33
+ return "grab";
34
+ }
35
+ /**
36
+ * Draws the brush as an SVG above the plot: a rectangle (or lasso polygon) with
37
+ * a circle at each vertex, plus the long-click progress wedge and the clear button.
38
+ *
39
+ * The SVG root is `pointerEvents: none` so that it never intercepts the camera's
40
+ * pan/zoom; only the vertex handles and the clear button opt back in.
41
+ */
42
+ export function BrushOverlay(props) {
43
+ const { width, height, overlayRef, geometry, color, brushState, pressProgress, isBrushHovered, enableBrushEdit, onVertexMouseDown, onEdgeMouseDown, onClearClick, } = props;
44
+ const vertices = brushState?.vertices ?? [];
45
+ // Every shape but the lasso is a closed rectangle throughout the drag; a lasso
46
+ // is left open while the user is still drawing it, and closed once the drag completes.
47
+ const isClosed = (brushState !== undefined && brushState.shape !== "Polygon")
48
+ || brushState?.status === "Complete";
49
+ const pathData = useMemo(() => {
50
+ if (vertices.length === 0) {
51
+ return null;
52
+ }
53
+ const points = vertices.map(v => `${v.x_pixels},${v.y_pixels}`).join(" L ");
54
+ return `M ${points}${isClosed ? " Z" : ""}`;
55
+ }, [vertices, isClosed]);
56
+ const clearButtonCenter = getClearButtonCenter(vertices, CLEAR_BUTTON_RADIUS_PX, geometry);
57
+ // `useId` emits colons, which are legal in an id but awkward inside `url(#...)`.
58
+ const clipPathId = `pluot-brush-clip-${useId().replace(/:/g, "")}`;
59
+ // While drawing a lasso, the intermediate vertices are too dense to be useful
60
+ // as handles, and they are not editable until the drag completes.
61
+ const shouldShowVertexHandles = isClosed;
62
+ // Sides are draggable only once the shape is settled, and only for the
63
+ // axis-aligned shapes; a lasso has no meaningful sides.
64
+ const editableEdges = enableBrushEdit && isClosed && brushState
65
+ ? getEditableEdges(brushState.shape)
66
+ : [];
67
+ // The side handles are the only thing here that needs the extent, so it is not
68
+ // computed for a lasso or for a brush whose sides are not draggable.
69
+ const edgeBoundingBox = editableEdges.length > 0 ? getVerticesBoundingBox(vertices) : null;
70
+ return (_jsxs("svg", { ref: overlayRef, style: {
71
+ position: "absolute",
72
+ top: 0,
73
+ left: 0,
74
+ marginTop: 0,
75
+ marginLeft: 0,
76
+ marginRight: 0,
77
+ marginBottom: 0,
78
+ pointerEvents: "none",
79
+ // Sit above the canvas/SVG plot and the camera element.
80
+ zIndex: 1,
81
+ }, width: width, height: height, viewBox: `0 0 ${width} ${height}`, xmlns: "http://www.w3.org/2000/svg", children: [_jsx("defs", { children: _jsx("clipPath", { id: clipPathId, children: _jsx("rect", { x: geometry.brushLeft, y: geometry.brushTop, width: Math.max(geometry.brushRight - geometry.brushLeft, 0), height: Math.max(geometry.brushBottom - geometry.brushTop, 0) }) }) }), _jsxs("g", { clipPath: `url(#${clipPathId})`, children: [pathData ? (_jsx("path", { d: pathData, fill: isClosed ? color : "none", fillOpacity: isClosed ? BRUSH_FILL_OPACITY : undefined, stroke: color, strokeWidth: 1.5, strokeDasharray: brushState?.status === "Drawing" ? "4 3" : undefined })) : null, edgeBoundingBox === null ? null : editableEdges.map(edge => {
82
+ const [x1, y1, x2, y2] = getEdgeLine(edge, edgeBoundingBox);
83
+ return (_jsx("line", { x1: x1, y1: y1, x2: x2, y2: y2,
84
+ // Invisible ink, but a wide grab target.
85
+ stroke: "transparent", strokeWidth: EDGE_HANDLE_WIDTH_PX, strokeLinecap: "butt", style: { pointerEvents: "stroke", cursor: getEdgeCursor(edge) }, onMouseDown: event => onEdgeMouseDown(edge, event) }, edge));
86
+ }), shouldShowVertexHandles ? vertices.map((vertex, vertexIndex) => (_jsx("circle", { cx: vertex.x_pixels, cy: vertex.y_pixels, r: VERTEX_HANDLE_RADIUS_PX, fill: HANDLE_FILL, stroke: color, strokeWidth: 1.5, style: {
87
+ pointerEvents: enableBrushEdit ? "auto" : "none",
88
+ cursor: enableBrushEdit ? getVertexCursor(brushState?.shape, vertexIndex) : "default",
89
+ }, onMouseDown: enableBrushEdit ? (event => onVertexMouseDown(vertexIndex, event)) : undefined }, vertexIndex))) : null, isBrushHovered && clearButtonCenter ? (_jsxs("g", { style: { pointerEvents: "auto", cursor: "pointer" }, onClick: onClearClick, role: "button", "aria-label": "Clear brush", children: [_jsx("circle", { cx: clearButtonCenter[0], cy: clearButtonCenter[1], r: CLEAR_BUTTON_RADIUS_PX, fill: CLEAR_FILL }), _jsx("path", { d: `M ${clearButtonCenter[0] - 4} ${clearButtonCenter[1] - 4} L ${clearButtonCenter[0] + 4} ${clearButtonCenter[1] + 4} `
90
+ + `M ${clearButtonCenter[0] + 4} ${clearButtonCenter[1] - 4} L ${clearButtonCenter[0] - 4} ${clearButtonCenter[1] + 4}`, stroke: "#ffffff", strokeWidth: 1.5, strokeLinecap: "round" })] })) : null, pressProgress ? (_jsxs("g", { children: [_jsx("circle", { cx: pressProgress.xPixels, cy: pressProgress.yPixels, r: PRESS_INDICATOR_RADIUS_PX, fill: "rgba(255, 255, 255, 0.6)", stroke: color, strokeWidth: 1.5 }), _jsx("path", { d: describeWedgePath(pressProgress.xPixels, pressProgress.yPixels, PRESS_INDICATOR_RADIUS_PX, pressProgress.fraction), fill: color })] })) : null] })] }));
91
+ }
@@ -1,2 +1,3 @@
1
- export function Pluot(props: any): any;
1
+ import type { PluotProps } from "./types.js";
2
+ export declare function Pluot(props: PluotProps): import("react/jsx-runtime").JSX.Element;
2
3
  //# sourceMappingURL=Pluot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Pluot.d.ts","sourceRoot":"","sources":["../src/Pluot.jsx"],"names":[],"mappings":"AAuDA,uCA8gBC"}
1
+ {"version":3,"file":"Pluot.d.ts","sourceRoot":"","sources":["../src/Pluot.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EACgB,UAAU,EACrC,MAAM,YAAY,CAAC;AA4CpB,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,2CA8lBtC"}
package/dist-tsc/Pluot.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, useMemo, useReducer, useCallback, useId } from "react";
2
+ import { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, useMemo, useReducer, useId } from "react";
3
3
  import lzs from "lz-string";
4
- import { isEqual, throttle } from "lodash-es";
5
- import { initialize, getIsWasmReady, render_wasm, pick_wasm, normalizeStores, getStore, getBounds, getCameraMatrixFromBounds, checkWebGpuFeatureDetection, onMouseMove2d, onWheel2d, onMouseMove3d, onWheel3d, } from '@pluot/core';
4
+ import { throttle } from "lodash-es";
5
+ import { initialize, getIsWasmReady, render_wasm, pick_wasm, normalizeStores, getStore, checkWebGpuFeatureDetection, onMouseMove2d, onWheel2d, onMouseMove3d, onWheel3d, } from '@pluot/core';
6
6
  import { Tooltip } from "./Tooltip.js";
7
+ import { BrushOverlay } from "./BrushOverlay.js";
8
+ import { useBrush } from "./use-brush.js";
7
9
  // Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
8
10
  // The requested module 'lz-string' is a CommonJS module,
9
11
  // which may not support all module.exports as named exports."
@@ -25,24 +27,28 @@ const noop = () => { };
25
27
  // Mouse movement (in pixels) beyond which a mousedown-to-click is
26
28
  // considered a drag rather than a click, so that picking is skipped.
27
29
  const DRAG_THRESHOLD_PX = 3;
30
+ // `pick_wasm` is typed `any` by wasm-bindgen, so `RawPickingResult` is what
31
+ // documents its wire format (see types.ts).
28
32
  function normalizePickingResult(data) {
29
- const result = data;
30
- if (data && Array.isArray(result.layer_results)) {
31
- result.layer_results = result.layer_results.map(obj => ({
32
- layer_id: obj.layer_id,
33
+ return {
34
+ ...data,
35
+ layer_results: data.layer_results.map(({ layer_id, info }) => ({
36
+ layer_id,
33
37
  // This is needed because serde-wasm-bindgen
34
38
  // converts Rust HashMap to JS Map.
35
- info: Object.fromEntries(Array.from(obj.info)),
36
- }));
37
- }
38
- return result;
39
+ info: Object.fromEntries(info),
40
+ })),
41
+ };
39
42
  }
40
43
  export function Pluot(props) {
41
44
  const { schemaVersion = null, width: widthProp, height: heightProp, plotId, plotType, store: storeProp, storeName: storeNameProp, stores: storesProp, registerStores = true, plotParams, viewMode = "2d", marginBottom = 100.0, marginLeft = 100.0, marginTop = 100.0, marginRight = 100.0, aspectRatioMode = "Contain", // "Ignore", "Contain", "Cover"
42
45
  aspectRatioAlignmentMode = "Start", // "Center", "Start", "End"
43
46
  format = "Raster", // "Raster", "Vector"
44
- minTimeout = 32, maxTimeout = 5000, allowSimultaneousRenders = true, debugMargins = false, backgroundColor = undefined, cameraMatrix: controlledCameraMatrix = null, setCameraMatrix: setControlledCameraMatrix = null, enableClick = false, enableTooltip = false, onClick: onClickProp = null, onHover: onHoverProp = null, } = props;
45
- const onClick = typeof onClickProp === 'function' ? onClickProp : identity;
47
+ minTimeout = 32, maxTimeout = 5000, allowSimultaneousRenders = true, debugMargins = false, backgroundColor = undefined, cameraMatrix: controlledCameraMatrix = null, setCameraMatrix: setControlledCameraMatrix = null, enableClick = false, enableTooltip = false, onClick: onClickProp = null, onHover: onHoverProp = null, brushUnitsModeX = "Data", brushUnitsModeY = "Data", brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft, enableBrushCreate = false, enableBrushEdit = false, enableBrushClear = false, brushDelay = 1500, maybeBrushDelay = 250, persistBrush = false, brushMode = "Rect", brushColor = "#3b6ea5",
48
+ // An omitted `brush` means uncontrolled; a controlled parent signals the
49
+ // empty state with `NO_BRUSH`, never `undefined`.
50
+ brush = null, onBrush, onBrushEnd, onBrushClear, } = props;
51
+ const onClick = typeof onClickProp === 'function' ? onClickProp : noop;
46
52
  const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
47
53
  // If cameraMatrix is not provided, then we manage the camera matrix internally.
48
54
  const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
@@ -84,6 +90,9 @@ export function Pluot(props) {
84
90
  const svgRef = useRef(null);
85
91
  const canvasRef = useRef(null);
86
92
  const cameraElementRef = useRef(null);
93
+ // The outer (width x height) element, which is the coordinate space that both
94
+ // the brush overlay and the hover tooltip are positioned within.
95
+ const containerRef = useRef(null);
87
96
  const tempButtonRef = useRef(null);
88
97
  // We may want to update these things without triggering a re-render.
89
98
  const isRenderingRef = useRef(false);
@@ -96,15 +105,25 @@ export function Pluot(props) {
96
105
  // (Similar to the one used in the Vitessce heatmap)
97
106
  // Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
98
107
  //const backlogRef = useRef([]);
99
- const [backlogIteration, incBacklogIteration] = useReducer(i => i + 1, 0);
108
+ const [backlogIteration, incBacklogIteration] = useReducer((i) => i + 1, 0);
100
109
  const [isWasmReady, setIsWasmReady] = useState(false);
101
110
  const [didFirstRender, setDidFirstRender] = useState(false);
102
111
  const [bailedEarly, setBailedEarly] = useState(true);
103
- const [pickingResult, setPickingResult] = useState(null);
104
112
  // hoverInfo.mouseX/mouseY are in the coordinate space of the outer
105
113
  // (width x height) container, used to position the hover tooltip.
106
114
  const [hoverInfo, setHoverInfo] = useState(null);
107
115
  const progressBarId = useId();
116
+ const { brushState, overlayRef: brushOverlayRef, geometry: brushGeometry, pressProgress, isBrushHovered, isBrushingRef, shouldSuppressClickRef, onVertexMouseDown, onEdgeMouseDown, onClearClick, } = useBrush({
117
+ containerRef,
118
+ width, height,
119
+ marginTop, marginRight, marginBottom, marginLeft,
120
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
121
+ brushUnitsModeX, brushUnitsModeY,
122
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
123
+ enableBrushCreate, enableBrushEdit, enableBrushClear,
124
+ brushDelay, maybeBrushDelay, persistBrush, brushMode,
125
+ brush, onBrush, onBrushEnd, onBrushClear,
126
+ });
108
127
  useLayoutEffect(() => {
109
128
  initialize().then(() => setIsWasmReady(getIsWasmReady()));
110
129
  }, []);
@@ -125,6 +144,10 @@ export function Pluot(props) {
125
144
  setCameraMatrix(nextCameraMatrix);
126
145
  });
127
146
  const mouseMoveHandler = useEffectEvent((event) => {
147
+ // A drag that is drawing or editing a brush must not also pan/rotate the camera.
148
+ if (isBrushingRef.current) {
149
+ return;
150
+ }
128
151
  const onMouseMove = viewMode === "3d" ? onMouseMove3d : onMouseMove2d;
129
152
  const nextCameraMatrix = onMouseMove({
130
153
  width,
@@ -179,7 +202,7 @@ export function Pluot(props) {
179
202
  });
180
203
  // The click-picking callback.
181
204
  const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
182
- setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
205
+ onClick(await pick(screenCoordX, screenCoordY));
183
206
  });
184
207
  // The hover-picking callback.
185
208
  const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
@@ -210,6 +233,10 @@ export function Pluot(props) {
210
233
  const mouseDownHandler = (event) => {
211
234
  dragStartRef.current = { x: event.clientX, y: event.clientY };
212
235
  didDragRef.current = false;
236
+ // A brush drag that ended outside the camera element never produced the
237
+ // click that would have consumed this flag, so clear it as the next
238
+ // interaction begins rather than letting it suppress that one too.
239
+ shouldSuppressClickRef.current = false;
213
240
  };
214
241
  const dragDetectHandler = (event) => {
215
242
  if (!dragStartRef.current) {
@@ -226,16 +253,20 @@ export function Pluot(props) {
226
253
  // Set up an onClick handler for picking.
227
254
  const clickHandler = (event) => {
228
255
  const wasDrag = didDragRef.current;
256
+ // A brush drag (or a click on the clear button) ends with a click on the
257
+ // camera element, which should not also run a picking query.
258
+ const wasBrush = shouldSuppressClickRef.current;
229
259
  dragStartRef.current = null;
230
260
  didDragRef.current = false;
231
- if (enableClick && !wasDrag) {
261
+ shouldSuppressClickRef.current = false;
262
+ if (enableClick && !wasDrag && !wasBrush) {
232
263
  pickFrame(event.offsetX, event.offsetY);
233
264
  }
234
265
  };
235
266
  cameraEl.addEventListener("click", clickHandler);
236
267
  // Set up hover handlers for picking, only when the onHover prop is provided.
237
268
  const hoverMoveHandler = (event) => {
238
- if (enableTooltip) {
269
+ if (enableTooltip && !isBrushingRef.current) {
239
270
  throttledHoverFrame(event.offsetX, event.offsetY);
240
271
  }
241
272
  };
@@ -338,9 +369,9 @@ export function Pluot(props) {
338
369
  currentTimeout.current = minTimeout;
339
370
  setBailedEarly(false); // Update this to hide the loading indicator.
340
371
  // Clear the LRU cache for the store (via its store_name) corresponding to the rendered plot.
341
- Object.keys(stores).forEach(storeName => {
372
+ Object.keys(stores ?? {}).forEach(storeName => {
342
373
  const storeUsed = getStore(storeName);
343
- if (storeUsed && storeUsed.clearCache && typeof storeUsed.clearCache === 'function') {
374
+ if (storeUsed && typeof storeUsed.clearCache === 'function') {
344
375
  storeUsed.clearCache();
345
376
  }
346
377
  });
@@ -396,11 +427,17 @@ export function Pluot(props) {
396
427
  return {
397
428
  position: "absolute",
398
429
  pointerEvents: "none",
430
+ // Above the brush overlay, so a persisted brush does not tint the tooltip.
431
+ zIndex: 2,
399
432
  ...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
400
433
  ...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
401
434
  };
402
435
  }, [hoverInfo, width, height]);
403
- return (_jsxs(_Fragment, { children: [_jsxs("div", { style: { width, height, position: "relative", backgroundColor }, children: [!supportsWebGpu ? (_jsx("p", { children: supportsWebGpuMessage })) : null, _jsx("div", { ref: cameraElementRef, style: {
436
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: containerRef, style: {
437
+ width, height, position: "relative", backgroundColor,
438
+ // Long-clicking to start a brush otherwise selects surrounding text.
439
+ userSelect: enableBrushCreate ? "none" : undefined,
440
+ }, children: [!supportsWebGpu ? (_jsx("p", { children: supportsWebGpuMessage })) : null, _jsx("div", { ref: cameraElementRef, style: {
404
441
  position: "absolute",
405
442
  top: marginTop,
406
443
  left: marginLeft,
@@ -418,5 +455,5 @@ export function Pluot(props) {
418
455
  }) : {}) })) : (_jsx("canvas", { ref: canvasRef, style: { width, height, border: `${debugMargins ? 1 : 0}px solid black` }, width: width, height: height, ...(bailedEarly ? ({
419
456
  ['aria-busy']: true,
420
457
  ['aria-describedby']: progressBarId,
421
- }) : {}) })), hoverInfo ? (_jsx("div", { style: hoverStyle, children: _jsx(Tooltip, { content: hoverInfo.content, asTable: true }) })) : null] }), _jsx("button", { ref: tempButtonRef, style: { display: 'none' }, children: "Try lookAt" })] }));
458
+ }) : {}) })), _jsx(BrushOverlay, { width: width, height: height, overlayRef: brushOverlayRef, geometry: brushGeometry, color: brushColor, brushState: brushState, pressProgress: pressProgress, isBrushHovered: isBrushHovered, enableBrushEdit: enableBrushEdit, onVertexMouseDown: onVertexMouseDown, onEdgeMouseDown: onEdgeMouseDown, onClearClick: onClearClick }), hoverInfo ? (_jsx("div", { style: hoverStyle ?? undefined, children: _jsx(Tooltip, { content: hoverInfo.content, asTable: true }) })) : null] }), _jsx("button", { ref: tempButtonRef, style: { display: 'none' }, children: "Try lookAt" })] }));
422
459
  }
@@ -1,2 +1,3 @@
1
- export function Tooltip(props: any): any;
1
+ import type { TooltipProps } from "./types.js";
2
+ export declare function Tooltip(props: TooltipProps): import("react/jsx-runtime").JSX.Element | null;
2
3
  //# sourceMappingURL=Tooltip.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Tooltip.d.ts","sourceRoot":"","sources":["../src/Tooltip.jsx"],"names":[],"mappings":"AAKA,yCA6BC"}
1
+ {"version":3,"file":"Tooltip.d.ts","sourceRoot":"","sources":["../src/Tooltip.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAoB/C,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,kDA6B1C"}
@@ -1,6 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  const boxShadow = '5px 5px 15px rgb(0 0 0 / 20%)';
4
+ // Table cell values come from arbitrary onHover return objects, so narrow
5
+ // them to something React can actually render.
6
+ function renderCell(value) {
7
+ if (value === null || value === undefined || typeof value === "boolean") {
8
+ return null;
9
+ }
10
+ if (typeof value === "string" || typeof value === "number") {
11
+ return value;
12
+ }
13
+ if (React.isValidElement(value)) {
14
+ return value;
15
+ }
16
+ return JSON.stringify(value);
17
+ }
4
18
  export function Tooltip(props) {
5
19
  const { content, asTable = false, } = props;
6
20
  if (!content) {
@@ -13,7 +27,7 @@ export function Tooltip(props) {
13
27
  return content;
14
28
  }
15
29
  if (asTable) {
16
- return (_jsx("table", { style: { display: 'inline-block', marginBottom: 0, opacity: 0.9, padding: '5px', backgroundColor: 'white', borderRadius: '2px', boxShadow }, children: _jsx("tbody", { children: Object.entries(content).map(([key, value]) => (_jsxs("tr", { children: [_jsx("th", { style: { border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }, children: key }), _jsx("td", { style: { border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }, children: value })] }, key))) }) }));
30
+ return (_jsx("table", { style: { display: 'inline-block', marginBottom: 0, opacity: 0.9, padding: '5px', backgroundColor: 'white', borderRadius: '2px', boxShadow }, children: _jsx("tbody", { children: Object.entries(content).map(([key, value]) => (_jsxs("tr", { children: [_jsx("th", { style: { border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }, children: key }), _jsx("td", { style: { border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }, children: renderCell(value) })] }, key))) }) }));
17
31
  }
18
32
  return _jsx("pre", { children: JSON.stringify(content, null, 2) });
19
33
  }
@@ -0,0 +1,155 @@
1
+ import { type AspectRatioMode, type AspectRatioAlignmentMode, type Bounds, type CameraMatrix } from "@pluot/core";
2
+ import type { BrushMode, BrushState, BrushUnitsMode, BrushVertex, RectLikeBrushMode } from "./types.js";
3
+ /** An axis-aligned brush extent, in container pixels. */
4
+ export type BrushBoundingBox = {
5
+ left: number;
6
+ top: number;
7
+ right: number;
8
+ bottom: number;
9
+ };
10
+ /** One side of an axis-aligned brush, which the user can drag to extend it. */
11
+ export type BrushEdge = "Top" | "Right" | "Bottom" | "Left";
12
+ /**
13
+ * Everything needed to convert a brush vertex between the three units modes.
14
+ *
15
+ * There are two rectangles involved, both expressed in *container* pixels
16
+ * (relative to the top-left of the outer `width` x `height` element, Y down),
17
+ * which is also the coordinate space of the brush overlay SVG:
18
+ *
19
+ * - The **layer** rect (the camera region, inside `margin*`), which anchors the
20
+ * `Data` units mode, since that is the region the camera matrix maps onto.
21
+ * - The **brushable** rect (inside `brushMargin*`), which bounds where the user
22
+ * may draw and which anchors the `Normalized` units mode.
23
+ *
24
+ * Note that `Data` and `Normalized` are Y-up (matching Pluot's data coordinate
25
+ * system, where `getBounds().yMin` is the bottom of the layer), whereas
26
+ * `Pixels` is Y-down (matching the DOM/SVG convention).
27
+ */
28
+ export type BrushGeometry = {
29
+ layerLeft: number;
30
+ layerTop: number;
31
+ layerWidth: number;
32
+ layerHeight: number;
33
+ brushLeft: number;
34
+ brushTop: number;
35
+ brushRight: number;
36
+ brushBottom: number;
37
+ /** The visible data range of the layer rect, under the current camera. */
38
+ dataBounds: Required<Bounds>;
39
+ };
40
+ export type BrushGeometryParams = {
41
+ width: number;
42
+ height: number;
43
+ marginTop: number;
44
+ marginRight: number;
45
+ marginBottom: number;
46
+ marginLeft: number;
47
+ /** Each defaults to the corresponding layer margin when undefined. */
48
+ brushMarginTop: number | undefined;
49
+ brushMarginRight: number | undefined;
50
+ brushMarginBottom: number | undefined;
51
+ brushMarginLeft: number | undefined;
52
+ brushUnitsModeX: BrushUnitsMode;
53
+ brushUnitsModeY: BrushUnitsMode;
54
+ aspectRatioMode: AspectRatioMode;
55
+ aspectRatioAlignmentMode: AspectRatioAlignmentMode;
56
+ cameraMatrix: CameraMatrix;
57
+ };
58
+ export declare function getBrushGeometry(params: BrushGeometryParams): BrushGeometry;
59
+ /**
60
+ * Build a full {@link BrushVertex} (all three units modes) from a position in
61
+ * container pixels.
62
+ */
63
+ export declare function vertexFromPixels(xPixels: number, yPixels: number, geom: BrushGeometry): BrushVertex;
64
+ /**
65
+ * Recover the container-pixel position of a vertex from whichever of its
66
+ * representations is authoritative for each axis.
67
+ *
68
+ * Only the representation matching the units mode survives a camera or resize
69
+ * change; the other two are derived, so they must be recomputed rather than
70
+ * read back (see {@link reprojectVertex}).
71
+ */
72
+ export declare function pixelsFromVertex(vertex: BrushVertex, geom: BrushGeometry, brushUnitsModeX: BrushUnitsMode, brushUnitsModeY: BrushUnitsMode): [number, number];
73
+ /**
74
+ * Re-derive the non-authoritative representations of a vertex under the current
75
+ * geometry. This is what makes a `Data`-units brush track the camera as the user
76
+ * zooms/pans: `x_data`/`y_data` stay fixed while the pixel positions move.
77
+ */
78
+ export declare function reprojectVertex(vertex: BrushVertex, geom: BrushGeometry, brushUnitsModeX: BrushUnitsMode, brushUnitsModeY: BrushUnitsMode): BrushVertex;
79
+ export declare function reprojectBrushState(state: BrushState, geom: BrushGeometry, brushUnitsModeX: BrushUnitsMode, brushUnitsModeY: BrushUnitsMode): BrushState;
80
+ /** Restrict a container-pixel position to the brushable region. */
81
+ export declare function clampToBrushRegion(xPixels: number, yPixels: number, geom: BrushGeometry): [number, number];
82
+ /**
83
+ * The four corners of the rect spanned by two opposite corners, ordered
84
+ * clockwise in pixel space starting from the top-left, so that corner `i` is
85
+ * always diagonally opposite corner `(i + 2) % 4`.
86
+ *
87
+ * `RangeX` and `RangeY` select along a single axis, so the other axis is
88
+ * discarded and pinned to the full extent of the brushable region.
89
+ */
90
+ export declare function rectVerticesFromCorners(x0: number, y0: number, x1: number, y1: number, geom: BrushGeometry, shape?: RectLikeBrushMode): BrushVertex[];
91
+ /** The bounding box, in container pixels, of a list of already-reprojected vertices. */
92
+ export declare function getVerticesBoundingBox(vertices: BrushVertex[]): BrushBoundingBox | null;
93
+ /**
94
+ * Whether a brush is too small to be a selection.
95
+ *
96
+ * A long-click that never turns into a drag produces a rect whose four corners
97
+ * coincide, which draws as a stray dot rather than as nothing, so these states
98
+ * are held back instead of being committed.
99
+ */
100
+ export declare function isDegenerateBrush(state: BrushState): boolean;
101
+ /**
102
+ * Where the clear button sits: adjacent to the brush's first vertex — the
103
+ * top-left corner of a rect, or the point a lasso was started from.
104
+ *
105
+ * Anchoring to the first vertex keeps the button in one place while a lasso is
106
+ * being drawn, rather than trailing the cursor around the shape. It is pushed
107
+ * outwards along the ray from the centroid through that vertex, so it lands
108
+ * outside the brush and does not obscure the brushed content. Returns `null` for
109
+ * an empty brush.
110
+ *
111
+ * The result is kept within the brushable region, since the overlay is clipped to
112
+ * that region and a button pushed outside it would be invisible and unclickable.
113
+ */
114
+ export declare function getClearButtonCenter(vertices: BrushVertex[], radius: number, geom: BrushGeometry): [number, number] | null;
115
+ /**
116
+ * Which sides of a brush the user may drag to extend it.
117
+ *
118
+ * A range brush pins its unselected axis to the whole brushable region, so
119
+ * dragging those two sides could not change anything and they are left out.
120
+ */
121
+ export declare function getEditableEdges(shape: BrushMode): BrushEdge[];
122
+ /** The endpoints `[x1, y1, x2, y2]` of an edge, in container pixels. */
123
+ export declare function getEdgeLine(edge: BrushEdge, boundingBox: BrushBoundingBox): [number, number, number, number];
124
+ /**
125
+ * The two opposite corners that dragging `edge` spans: the corner that stays
126
+ * put, and the corner that follows the cursor along `axis` only.
127
+ *
128
+ * Expressing an edge drag as a pair of corners lets it reuse
129
+ * {@link rectVerticesFromCorners}, which also means dragging a side past its
130
+ * opposite side flips the brush rather than inverting it.
131
+ */
132
+ export declare function getEdgeDragCorners(edge: BrushEdge, boundingBox: BrushBoundingBox): {
133
+ axis: "X" | "Y";
134
+ fixedX: number;
135
+ fixedY: number;
136
+ movingX: number;
137
+ movingY: number;
138
+ };
139
+ /**
140
+ * Whether a container-pixel position lies inside a brush, used to decide when to
141
+ * reveal the clear button. Hit-testing is done here rather than with SVG pointer
142
+ * events so that the overlay never swallows camera pan/zoom interactions.
143
+ * TODO: replace this by using the regular onHover events in the brush overlay SVG.
144
+ * The challenge with using the regular onHover events in the overlay is that
145
+ * it makes it tricky to avoid absorbing the hover/mouse events which the camera pan/zoom need.
146
+ * An alternative/intermediate optimization would be to compute the polygon bounding box
147
+ * upon the polygon creation/modification, and do hit-testing against that cached bounding box instead.
148
+ */
149
+ export declare function isPointInBrush(xPixels: number, yPixels: number, vertices: BrushVertex[]): boolean;
150
+ /**
151
+ * An SVG path for a pie wedge filled clockwise from 12 o'clock, used to
152
+ * visualize progress towards the long-click that starts a brush.
153
+ */
154
+ export declare function describeWedgePath(cx: number, cy: number, radius: number, fraction: number): string;
155
+ //# sourceMappingURL=brush.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brush.d.ts","sourceRoot":"","sources":["../src/brush.ts"],"names":[],"mappings":"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"}