@pluot/react 0.1.16 → 0.1.18

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,2CAyLpD"}
@@ -0,0 +1,95 @@
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. RangeX/RangeY
61
+ // brushes only resize along one axis via their edges, so their corners are not
62
+ // meaningful drag handles either.
63
+ const shouldShowVertexHandles = isClosed
64
+ && brushState?.shape !== "RangeX"
65
+ && brushState?.shape !== "RangeY";
66
+ // Sides are draggable only once the shape is settled, and only for the
67
+ // axis-aligned shapes; a lasso has no meaningful sides.
68
+ const editableEdges = enableBrushEdit && isClosed && brushState
69
+ ? getEditableEdges(brushState.shape)
70
+ : [];
71
+ // The side handles are the only thing here that needs the extent, so it is not
72
+ // computed for a lasso or for a brush whose sides are not draggable.
73
+ const edgeBoundingBox = editableEdges.length > 0 ? getVerticesBoundingBox(vertices) : null;
74
+ return (_jsxs("svg", { ref: overlayRef, style: {
75
+ position: "absolute",
76
+ top: 0,
77
+ left: 0,
78
+ marginTop: 0,
79
+ marginLeft: 0,
80
+ marginRight: 0,
81
+ marginBottom: 0,
82
+ pointerEvents: "none",
83
+ // Sit above the canvas/SVG plot and the camera element.
84
+ zIndex: 1,
85
+ }, 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 => {
86
+ const [x1, y1, x2, y2] = getEdgeLine(edge, edgeBoundingBox);
87
+ return (_jsx("line", { x1: x1, y1: y1, x2: x2, y2: y2,
88
+ // Invisible ink, but a wide grab target.
89
+ stroke: "transparent", strokeWidth: EDGE_HANDLE_WIDTH_PX, strokeLinecap: "butt", style: { pointerEvents: "stroke", cursor: getEdgeCursor(edge) }, onMouseDown: event => onEdgeMouseDown(edge, event) }, edge));
90
+ }), 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: {
91
+ pointerEvents: enableBrushEdit ? "auto" : "none",
92
+ cursor: enableBrushEdit ? getVertexCursor(brushState?.shape, vertexIndex) : "default",
93
+ }, 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} `
94
+ + `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] })] }));
95
+ }
@@ -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,EAC4C,UAAU,EAEjE,MAAM,YAAY,CAAC;AA2DpB,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,2CAipBtC"}
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, brush_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,44 @@ 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
+ };
42
+ }
43
+ // `brush_wasm` is typed `any` by wasm-bindgen, so `RawBrushingResult` is what
44
+ // documents its wire format (see types.ts).
45
+ function normalizeBrushingResult(data) {
46
+ return {
47
+ ...data,
48
+ layer_results: data.layer_results.map(({ layer_id, info, element_info }) => ({
49
+ layer_id,
50
+ // This is needed because serde-wasm-bindgen
51
+ // converts Rust HashMap to JS Map.
52
+ info: Object.fromEntries(info),
53
+ element_info: Object.fromEntries(element_info),
54
+ })),
55
+ };
39
56
  }
40
57
  export function Pluot(props) {
41
58
  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
59
  aspectRatioAlignmentMode = "Start", // "Center", "Start", "End"
43
60
  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;
61
+ 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",
62
+ // An omitted `brush` means uncontrolled; a controlled parent signals the
63
+ // empty state with `NO_BRUSH`, never `undefined`.
64
+ brush = null, onBrush, onBrushEnd, onBrushClear,
65
+ // Temporary workaround. See comments in LruStore.clearCache.
66
+ shouldClearCache = true, } = props;
67
+ const onClick = typeof onClickProp === 'function' ? onClickProp : noop;
46
68
  const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
47
69
  // If cameraMatrix is not provided, then we manage the camera matrix internally.
48
70
  const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
@@ -84,6 +106,9 @@ export function Pluot(props) {
84
106
  const svgRef = useRef(null);
85
107
  const canvasRef = useRef(null);
86
108
  const cameraElementRef = useRef(null);
109
+ // The outer (width x height) element, which is the coordinate space that both
110
+ // the brush overlay and the hover tooltip are positioned within.
111
+ const containerRef = useRef(null);
87
112
  const tempButtonRef = useRef(null);
88
113
  // We may want to update these things without triggering a re-render.
89
114
  const isRenderingRef = useRef(false);
@@ -96,15 +121,68 @@ export function Pluot(props) {
96
121
  // (Similar to the one used in the Vitessce heatmap)
97
122
  // Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
98
123
  //const backlogRef = useRef([]);
99
- const [backlogIteration, incBacklogIteration] = useReducer(i => i + 1, 0);
124
+ const [backlogIteration, incBacklogIteration] = useReducer((i) => i + 1, 0);
100
125
  const [isWasmReady, setIsWasmReady] = useState(false);
101
126
  const [didFirstRender, setDidFirstRender] = useState(false);
102
127
  const [bailedEarly, setBailedEarly] = useState(true);
103
- const [pickingResult, setPickingResult] = useState(null);
104
128
  // hoverInfo.mouseX/mouseY are in the coordinate space of the outer
105
129
  // (width x height) container, used to position the hover tooltip.
106
130
  const [hoverInfo, setHoverInfo] = useState(null);
107
131
  const progressBarId = useId();
132
+ // Runs the brush query against the wasm module for a given brush state,
133
+ // analogous to `pick` below (defined here, ahead of `pick`, since `useBrush`
134
+ // needs it immediately).
135
+ const runBrush = useEffectEvent(async (state) => {
136
+ if (!isWasmReady) {
137
+ return;
138
+ }
139
+ const renderParams = {
140
+ schema_version: schemaVersion,
141
+ width,
142
+ height,
143
+ format: format,
144
+ margin_bottom: marginBottom,
145
+ margin_left: marginLeft,
146
+ margin_top: marginTop,
147
+ margin_right: marginRight,
148
+ device_pixel_ratio: window.devicePixelRatio,
149
+ aspect_ratio_mode: aspectRatioMode,
150
+ aspect_ratio_alignment_mode: aspectRatioAlignmentMode,
151
+ view_mode: viewMode,
152
+ pickable: false,
153
+ camera_view: cameraMatrix,
154
+ plot_id: plotId,
155
+ plot_type: plotType,
156
+ stores,
157
+ plot_params: plotParams,
158
+ timeout: currentTimeout.current,
159
+ wait_for_store_gets: false,
160
+ cache_enabled: true,
161
+ svg_compression_enabled: true,
162
+ svg_include_document: false,
163
+ };
164
+ // Brush vertices are container-relative pixels with Y increasing downwards;
165
+ // the wasm side expects screen coordinates with Y increasing upwards (as
166
+ // with the `screenCoordX`/`screenCoordY` passed to `pick_wasm` below).
167
+ const brushParams = {
168
+ screen_vertices: state.vertices.map((vertex) => ({ x: vertex.x_pixels, y: height - vertex.y_pixels })),
169
+ brush_units_mode_x: brushUnitsModeX,
170
+ brush_units_mode_y: brushUnitsModeY,
171
+ brush_mode: state.shape,
172
+ };
173
+ return normalizeBrushingResult(await brush_wasm(renderParams, brushParams));
174
+ });
175
+ const { brushState, overlayRef: brushOverlayRef, geometry: brushGeometry, pressProgress, isBrushHovered, isBrushingRef, shouldSuppressClickRef, onVertexMouseDown, onEdgeMouseDown, onClearClick, } = useBrush({
176
+ containerRef,
177
+ width, height,
178
+ marginTop, marginRight, marginBottom, marginLeft,
179
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
180
+ brushUnitsModeX, brushUnitsModeY,
181
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
182
+ enableBrushCreate, enableBrushEdit, enableBrushClear,
183
+ brushDelay, maybeBrushDelay, persistBrush, brushMode,
184
+ brush, onBrush, onBrushEnd, onBrushClear, runBrush,
185
+ });
108
186
  useLayoutEffect(() => {
109
187
  initialize().then(() => setIsWasmReady(getIsWasmReady()));
110
188
  }, []);
@@ -125,6 +203,10 @@ export function Pluot(props) {
125
203
  setCameraMatrix(nextCameraMatrix);
126
204
  });
127
205
  const mouseMoveHandler = useEffectEvent((event) => {
206
+ // A drag that is drawing or editing a brush must not also pan/rotate the camera.
207
+ if (isBrushingRef.current) {
208
+ return;
209
+ }
128
210
  const onMouseMove = viewMode === "3d" ? onMouseMove3d : onMouseMove2d;
129
211
  const nextCameraMatrix = onMouseMove({
130
212
  width,
@@ -179,7 +261,7 @@ export function Pluot(props) {
179
261
  });
180
262
  // The click-picking callback.
181
263
  const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
182
- setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
264
+ onClick(await pick(screenCoordX, screenCoordY));
183
265
  });
184
266
  // The hover-picking callback.
185
267
  const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
@@ -210,6 +292,10 @@ export function Pluot(props) {
210
292
  const mouseDownHandler = (event) => {
211
293
  dragStartRef.current = { x: event.clientX, y: event.clientY };
212
294
  didDragRef.current = false;
295
+ // A brush drag that ended outside the camera element never produced the
296
+ // click that would have consumed this flag, so clear it as the next
297
+ // interaction begins rather than letting it suppress that one too.
298
+ shouldSuppressClickRef.current = false;
213
299
  };
214
300
  const dragDetectHandler = (event) => {
215
301
  if (!dragStartRef.current) {
@@ -226,16 +312,20 @@ export function Pluot(props) {
226
312
  // Set up an onClick handler for picking.
227
313
  const clickHandler = (event) => {
228
314
  const wasDrag = didDragRef.current;
315
+ // A brush drag (or a click on the clear button) ends with a click on the
316
+ // camera element, which should not also run a picking query.
317
+ const wasBrush = shouldSuppressClickRef.current;
229
318
  dragStartRef.current = null;
230
319
  didDragRef.current = false;
231
- if (enableClick && !wasDrag) {
320
+ shouldSuppressClickRef.current = false;
321
+ if (enableClick && !wasDrag && !wasBrush) {
232
322
  pickFrame(event.offsetX, event.offsetY);
233
323
  }
234
324
  };
235
325
  cameraEl.addEventListener("click", clickHandler);
236
326
  // Set up hover handlers for picking, only when the onHover prop is provided.
237
327
  const hoverMoveHandler = (event) => {
238
- if (enableTooltip) {
328
+ if (enableTooltip && !isBrushingRef.current) {
239
329
  throttledHoverFrame(event.offsetX, event.offsetY);
240
330
  }
241
331
  };
@@ -338,9 +428,9 @@ export function Pluot(props) {
338
428
  currentTimeout.current = minTimeout;
339
429
  setBailedEarly(false); // Update this to hide the loading indicator.
340
430
  // Clear the LRU cache for the store (via its store_name) corresponding to the rendered plot.
341
- Object.keys(stores).forEach(storeName => {
431
+ Object.keys(stores ?? {}).forEach(storeName => {
342
432
  const storeUsed = getStore(storeName);
343
- if (storeUsed && storeUsed.clearCache && typeof storeUsed.clearCache === 'function') {
433
+ if (storeUsed && typeof storeUsed.clearCache === 'function' && shouldClearCache) {
344
434
  storeUsed.clearCache();
345
435
  }
346
436
  });
@@ -396,11 +486,17 @@ export function Pluot(props) {
396
486
  return {
397
487
  position: "absolute",
398
488
  pointerEvents: "none",
489
+ // Above the brush overlay, so a persisted brush does not tint the tooltip.
490
+ zIndex: 2,
399
491
  ...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
400
492
  ...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
401
493
  };
402
494
  }, [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: {
495
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: containerRef, style: {
496
+ width, height, position: "relative", backgroundColor,
497
+ // Long-clicking to start a brush otherwise selects surrounding text.
498
+ userSelect: enableBrushCreate ? "none" : undefined,
499
+ }, children: [!supportsWebGpu ? (_jsx("p", { children: supportsWebGpuMessage })) : null, _jsx("div", { ref: cameraElementRef, style: {
404
500
  position: "absolute",
405
501
  top: marginTop,
406
502
  left: marginLeft,
@@ -418,5 +514,5 @@ export function Pluot(props) {
418
514
  }) : {}) })) : (_jsx("canvas", { ref: canvasRef, style: { width, height, border: `${debugMargins ? 1 : 0}px solid black` }, width: width, height: height, ...(bailedEarly ? ({
419
515
  ['aria-busy']: true,
420
516
  ['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" })] }));
517
+ }) : {}) })), _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
518
  }
@@ -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":"AAAA,OAAO,EAAa,KAAK,eAAe,EAAE,KAAK,wBAAwB,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7H,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAExG,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE5D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,eAAe,EAAE,cAAc,CAAC;IAChC,eAAe,EAAE,cAAc,CAAC;IAChC,eAAe,EAAE,eAAe,CAAC;IACjC,wBAAwB,EAAE,wBAAwB,CAAC;IACnD,YAAY,EAAE,YAAY,CAAC;CAC5B,CAAC;AAOF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,mBAAmB,GAAG,aAAa,CAsC3E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,WAAW,CAWnG;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,aAAa,EACnB,eAAe,EAAE,cAAc,EAC/B,eAAe,EAAE,cAAc,GAC9B,CAAC,MAAM,EAAE,MAAM,CAAC,CAsBlB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,aAAa,EACnB,eAAe,EAAE,cAAc,EAC/B,eAAe,EAAE,cAAc,GAC9B,WAAW,CAGb;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,aAAa,EACnB,eAAe,EAAE,cAAc,EAC/B,eAAe,EAAE,cAAc,GAC9B,UAAU,CAkBZ;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAK1G;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EACtB,IAAI,EAAE,aAAa,EACnB,KAAK,GAAE,iBAA0B,GAChC,WAAW,EAAE,CAWf;AAED,wFAAwF;AACxF,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,gBAAgB,GAAG,IAAI,CAYvF;AAKD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAmB5D;AAKD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,WAAW,EAAE,EACvB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,aAAa,GAClB,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAyBzB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,EAAE,CAW9D;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAY5G;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,GAAG;IAClF,IAAI,EAAE,GAAG,GAAG,GAAG,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB,CAYA;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,OAAO,CAiBjG;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWlG"}