@pluot/react 0.1.12 → 0.1.14
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 +514 -401
- package/dist-tsc/Pluot.d.ts.map +1 -1
- package/dist-tsc/Pluot.js +103 -11
- package/dist-tsc/Tooltip.d.ts +2 -0
- package/dist-tsc/Tooltip.d.ts.map +1 -0
- package/dist-tsc/Tooltip.js +19 -0
- package/package.json +2 -2
- package/src/Pluot.jsx +130 -16
- package/src/Tooltip.jsx +35 -0
package/dist-tsc/Pluot.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Pluot.d.ts","sourceRoot":"","sources":["../src/Pluot.jsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Pluot.d.ts","sourceRoot":"","sources":["../src/Pluot.jsx"],"names":[],"mappings":"AAuDA,uCA8gBC"}
|
package/dist-tsc/Pluot.js
CHANGED
|
@@ -3,6 +3,7 @@ import React, { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, us
|
|
|
3
3
|
import lzs from "lz-string";
|
|
4
4
|
import { isEqual, throttle } from "lodash-es";
|
|
5
5
|
import { initialize, getIsWasmReady, render_wasm, pick_wasm, normalizeStores, getStore, getBounds, getCameraMatrixFromBounds, checkWebGpuFeatureDetection, onMouseMove2d, onWheel2d, onMouseMove3d, onWheel3d, } from '@pluot/core';
|
|
6
|
+
import { Tooltip } from "./Tooltip.js";
|
|
6
7
|
// Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
|
|
7
8
|
// The requested module 'lz-string' is a CommonJS module,
|
|
8
9
|
// which may not support all module.exports as named exports."
|
|
@@ -19,6 +20,11 @@ const DEFAULT_3D_VIEW = new Float32Array([
|
|
|
19
20
|
0, 0, 1, 0,
|
|
20
21
|
0, 0, -10, 1,
|
|
21
22
|
]);
|
|
23
|
+
const identity = (param) => param;
|
|
24
|
+
const noop = () => { };
|
|
25
|
+
// Mouse movement (in pixels) beyond which a mousedown-to-click is
|
|
26
|
+
// considered a drag rather than a click, so that picking is skipped.
|
|
27
|
+
const DRAG_THRESHOLD_PX = 3;
|
|
22
28
|
function normalizePickingResult(data) {
|
|
23
29
|
const result = data;
|
|
24
30
|
if (data && Array.isArray(result.layer_results)) {
|
|
@@ -32,10 +38,12 @@ function normalizePickingResult(data) {
|
|
|
32
38
|
return result;
|
|
33
39
|
}
|
|
34
40
|
export function Pluot(props) {
|
|
35
|
-
const { schemaVersion = null, width: widthProp, height: heightProp, plotId, plotType, store: storeProp, storeName: storeNameProp, stores: storesProp, plotParams, viewMode = "2d", marginBottom = 100.0, marginLeft = 100.0, marginTop = 100.0, marginRight = 100.0, aspectRatioMode = "Contain", // "Ignore", "Contain", "Cover"
|
|
41
|
+
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"
|
|
36
42
|
aspectRatioAlignmentMode = "Start", // "Center", "Start", "End"
|
|
37
43
|
format = "Raster", // "Raster", "Vector"
|
|
38
|
-
minTimeout = 32, maxTimeout = 5000, allowSimultaneousRenders = true, debugMargins = false, cameraMatrix: controlledCameraMatrix = null, setCameraMatrix: setControlledCameraMatrix = null,
|
|
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;
|
|
46
|
+
const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
|
|
39
47
|
// If cameraMatrix is not provided, then we manage the camera matrix internally.
|
|
40
48
|
const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
|
|
41
49
|
// Note: We use an initializer function here to avoid
|
|
@@ -70,8 +78,8 @@ export function Pluot(props) {
|
|
|
70
78
|
store: storeProp,
|
|
71
79
|
storeName: storeNameProp,
|
|
72
80
|
plotId,
|
|
73
|
-
register:
|
|
74
|
-
}), [storeNameProp, storeProp, storesProp, plotId]);
|
|
81
|
+
register: registerStores,
|
|
82
|
+
}), [storeNameProp, storeProp, storesProp, plotId, registerStores]);
|
|
75
83
|
const [supportsWebGpu, supportsWebGpuMessage] = useMemo(checkWebGpuFeatureDetection, []);
|
|
76
84
|
const svgRef = useRef(null);
|
|
77
85
|
const canvasRef = useRef(null);
|
|
@@ -80,6 +88,10 @@ export function Pluot(props) {
|
|
|
80
88
|
// We may want to update these things without triggering a re-render.
|
|
81
89
|
const isRenderingRef = useRef(false);
|
|
82
90
|
const currentTimeout = useRef(minTimeout);
|
|
91
|
+
// Used to distinguish a plain click from a click that ends a drag
|
|
92
|
+
// (e.g. panning), so that dragging does not trigger picking.
|
|
93
|
+
const dragStartRef = useRef(null);
|
|
94
|
+
const didDragRef = useRef(false);
|
|
83
95
|
// TODO: do we want to use the backlog approach or not?
|
|
84
96
|
// (Similar to the one used in the Vitessce heatmap)
|
|
85
97
|
// Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
|
|
@@ -89,6 +101,9 @@ export function Pluot(props) {
|
|
|
89
101
|
const [didFirstRender, setDidFirstRender] = useState(false);
|
|
90
102
|
const [bailedEarly, setBailedEarly] = useState(true);
|
|
91
103
|
const [pickingResult, setPickingResult] = useState(null);
|
|
104
|
+
// hoverInfo.mouseX/mouseY are in the coordinate space of the outer
|
|
105
|
+
// (width x height) container, used to position the hover tooltip.
|
|
106
|
+
const [hoverInfo, setHoverInfo] = useState(null);
|
|
92
107
|
const progressBarId = useId();
|
|
93
108
|
useLayoutEffect(() => {
|
|
94
109
|
initialize().then(() => setIsWasmReady(getIsWasmReady()));
|
|
@@ -125,8 +140,9 @@ export function Pluot(props) {
|
|
|
125
140
|
}, cameraMatrix, event);
|
|
126
141
|
setCameraMatrix(nextCameraMatrix);
|
|
127
142
|
});
|
|
128
|
-
//
|
|
129
|
-
|
|
143
|
+
// Runs the picking query against the wasm module and returns the normalized result.
|
|
144
|
+
// Shared by the click (pickFrame) and hover (hoverFrame) callbacks below.
|
|
145
|
+
const pick = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
130
146
|
const renderParams = {
|
|
131
147
|
schema_version: schemaVersion,
|
|
132
148
|
width,
|
|
@@ -156,11 +172,30 @@ export function Pluot(props) {
|
|
|
156
172
|
};
|
|
157
173
|
const layerHeight = height - marginTop - marginBottom;
|
|
158
174
|
// TODO: wrap pick_wasm in a try/catch
|
|
159
|
-
|
|
175
|
+
return normalizePickingResult(await pick_wasm(renderParams,
|
|
160
176
|
// The coordinates are relative to the "layer" (the camera region), not the full width/height.
|
|
161
177
|
// We also need to flip the Y coordinate so that positive is up.
|
|
162
|
-
screenCoordX + marginLeft, marginBottom + (layerHeight - screenCoordY)))
|
|
178
|
+
screenCoordX + marginLeft, marginBottom + (layerHeight - screenCoordY)));
|
|
179
|
+
});
|
|
180
|
+
// The click-picking callback.
|
|
181
|
+
const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
182
|
+
setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
|
|
183
|
+
});
|
|
184
|
+
// The hover-picking callback.
|
|
185
|
+
const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
186
|
+
const result = await pick(screenCoordX, screenCoordY);
|
|
187
|
+
setHoverInfo({
|
|
188
|
+
content: onHover(result),
|
|
189
|
+
// Convert from cameraEl-relative coordinates to outer-container-relative
|
|
190
|
+
// coordinates, since the tooltip is positioned within the outer container.
|
|
191
|
+
mouseX: screenCoordX + marginLeft,
|
|
192
|
+
mouseY: screenCoordY + marginTop,
|
|
193
|
+
});
|
|
163
194
|
});
|
|
195
|
+
const throttledHoverFrame = useMemo(() => throttle(hoverFrame, 50, { leading: true, trailing: true }), []);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
return () => throttledHoverFrame.cancel();
|
|
198
|
+
}, [throttledHoverFrame]);
|
|
164
199
|
// Set up the camera and picking handlers.
|
|
165
200
|
useEffect(() => {
|
|
166
201
|
const cameraEl = cameraElementRef.current;
|
|
@@ -170,19 +205,58 @@ export function Pluot(props) {
|
|
|
170
205
|
// Create a 2D camera for handling zoom and pan.
|
|
171
206
|
cameraEl.addEventListener("mousemove", mouseMoveHandler);
|
|
172
207
|
cameraEl.addEventListener("wheel", wheelHandler);
|
|
208
|
+
// Track mousedown -> mousemove distance so that a drag (e.g. panning)
|
|
209
|
+
// that ends on the camera element does not also trigger a click/pick.
|
|
210
|
+
const mouseDownHandler = (event) => {
|
|
211
|
+
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
|
212
|
+
didDragRef.current = false;
|
|
213
|
+
};
|
|
214
|
+
const dragDetectHandler = (event) => {
|
|
215
|
+
if (!dragStartRef.current) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const dx = event.clientX - dragStartRef.current.x;
|
|
219
|
+
const dy = event.clientY - dragStartRef.current.y;
|
|
220
|
+
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
|
|
221
|
+
didDragRef.current = true;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
cameraEl.addEventListener("mousedown", mouseDownHandler);
|
|
225
|
+
cameraEl.addEventListener("mousemove", dragDetectHandler);
|
|
173
226
|
// Set up an onClick handler for picking.
|
|
174
227
|
const clickHandler = (event) => {
|
|
175
|
-
|
|
228
|
+
const wasDrag = didDragRef.current;
|
|
229
|
+
dragStartRef.current = null;
|
|
230
|
+
didDragRef.current = false;
|
|
231
|
+
if (enableClick && !wasDrag) {
|
|
176
232
|
pickFrame(event.offsetX, event.offsetY);
|
|
177
233
|
}
|
|
178
234
|
};
|
|
179
235
|
cameraEl.addEventListener("click", clickHandler);
|
|
236
|
+
// Set up hover handlers for picking, only when the onHover prop is provided.
|
|
237
|
+
const hoverMoveHandler = (event) => {
|
|
238
|
+
if (enableTooltip) {
|
|
239
|
+
throttledHoverFrame(event.offsetX, event.offsetY);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const hoverLeaveHandler = () => {
|
|
243
|
+
throttledHoverFrame.cancel();
|
|
244
|
+
setHoverInfo(null);
|
|
245
|
+
};
|
|
246
|
+
if (enableTooltip) {
|
|
247
|
+
cameraEl.addEventListener("mousemove", hoverMoveHandler);
|
|
248
|
+
cameraEl.addEventListener("mouseleave", hoverLeaveHandler);
|
|
249
|
+
}
|
|
180
250
|
return () => {
|
|
181
251
|
cameraEl.removeEventListener("mousemove", mouseMoveHandler);
|
|
182
252
|
cameraEl.removeEventListener("wheel", wheelHandler);
|
|
253
|
+
cameraEl.removeEventListener("mousedown", mouseDownHandler);
|
|
254
|
+
cameraEl.removeEventListener("mousemove", dragDetectHandler);
|
|
183
255
|
cameraEl.removeEventListener("click", clickHandler);
|
|
256
|
+
cameraEl.removeEventListener("mousemove", hoverMoveHandler);
|
|
257
|
+
cameraEl.removeEventListener("mouseleave", hoverLeaveHandler);
|
|
184
258
|
};
|
|
185
|
-
}, [viewMode,
|
|
259
|
+
}, [viewMode, enableClick, enableTooltip, throttledHoverFrame]);
|
|
186
260
|
// The renderFrame callback.
|
|
187
261
|
// We use useEffectEvent because we want to "see"
|
|
188
262
|
// the latest values of viewMatrix, plotProps, etc.
|
|
@@ -308,6 +382,24 @@ export function Pluot(props) {
|
|
|
308
382
|
throttledRender();
|
|
309
383
|
}, [isWasmReady, didFirstRender, cameraMatrix, backlogIteration, plotId, plotType, plotParams, stores, format,
|
|
310
384
|
width, height, aspectRatioMode, aspectRatioAlignmentMode, marginLeft, marginRight, marginTop, marginBottom]);
|
|
385
|
+
// Position the hover tooltip so that it grows diagonally away from whichever
|
|
386
|
+
// quadrant of the plot the mouse currently occupies, to avoid clipping.
|
|
387
|
+
const hoverStyle = useMemo(() => {
|
|
388
|
+
if (!hoverInfo) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const { mouseX, mouseY } = hoverInfo;
|
|
392
|
+
const isLeft = mouseX < width / 2;
|
|
393
|
+
const isTop = mouseY < height / 2;
|
|
394
|
+
const offsetPx = 10;
|
|
395
|
+
const extraPx = 5;
|
|
396
|
+
return {
|
|
397
|
+
position: "absolute",
|
|
398
|
+
pointerEvents: "none",
|
|
399
|
+
...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
|
|
400
|
+
...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
|
|
401
|
+
};
|
|
402
|
+
}, [hoverInfo, width, height]);
|
|
311
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: {
|
|
312
404
|
position: "absolute",
|
|
313
405
|
top: marginTop,
|
|
@@ -326,5 +418,5 @@ export function Pluot(props) {
|
|
|
326
418
|
}) : {}) })) : (_jsx("canvas", { ref: canvasRef, style: { width, height, border: `${debugMargins ? 1 : 0}px solid black` }, width: width, height: height, ...(bailedEarly ? ({
|
|
327
419
|
['aria-busy']: true,
|
|
328
420
|
['aria-describedby']: progressBarId,
|
|
329
|
-
}) : {}) }))
|
|
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" })] }));
|
|
330
422
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Tooltip.d.ts","sourceRoot":"","sources":["../src/Tooltip.jsx"],"names":[],"mappings":"AAKA,yCA6BC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
const boxShadow = '5px 5px 15px rgb(0 0 0 / 20%)';
|
|
4
|
+
export function Tooltip(props) {
|
|
5
|
+
const { content, asTable = false, } = props;
|
|
6
|
+
if (!content) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
if (typeof content === "string" || typeof content === "number") {
|
|
10
|
+
return _jsx("pre", { children: content });
|
|
11
|
+
}
|
|
12
|
+
if (React.isValidElement(content)) {
|
|
13
|
+
return content;
|
|
14
|
+
}
|
|
15
|
+
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))) }) }));
|
|
17
|
+
}
|
|
18
|
+
return _jsx("pre", { children: JSON.stringify(content, null, 2) });
|
|
19
|
+
}
|
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.14",
|
|
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.14"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"vitest": "^3.2.4",
|
package/src/Pluot.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
onMouseMove2d, onWheel2d,
|
|
11
11
|
onMouseMove3d, onWheel3d,
|
|
12
12
|
} from '@pluot/core';
|
|
13
|
+
import { Tooltip } from "./Tooltip.js";
|
|
13
14
|
|
|
14
15
|
// Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
|
|
15
16
|
// The requested module 'lz-string' is a CommonJS module,
|
|
@@ -31,6 +32,13 @@ const DEFAULT_3D_VIEW = new Float32Array([
|
|
|
31
32
|
0, 0, -10, 1,
|
|
32
33
|
]);
|
|
33
34
|
|
|
35
|
+
const identity = (param) => param;
|
|
36
|
+
const noop = () => { };
|
|
37
|
+
|
|
38
|
+
// Mouse movement (in pixels) beyond which a mousedown-to-click is
|
|
39
|
+
// considered a drag rather than a click, so that picking is skipped.
|
|
40
|
+
const DRAG_THRESHOLD_PX = 3;
|
|
41
|
+
|
|
34
42
|
function normalizePickingResult(data) {
|
|
35
43
|
const result = data;
|
|
36
44
|
if (data && Array.isArray(result.layer_results)) {
|
|
@@ -55,6 +63,7 @@ export function Pluot(props) {
|
|
|
55
63
|
store: storeProp,
|
|
56
64
|
storeName: storeNameProp,
|
|
57
65
|
stores: storesProp,
|
|
66
|
+
registerStores = true,
|
|
58
67
|
plotParams,
|
|
59
68
|
viewMode = "2d",
|
|
60
69
|
marginBottom = 100.0,
|
|
@@ -68,12 +77,20 @@ export function Pluot(props) {
|
|
|
68
77
|
maxTimeout = 5000,
|
|
69
78
|
allowSimultaneousRenders = true,
|
|
70
79
|
debugMargins = false,
|
|
80
|
+
backgroundColor = undefined,
|
|
71
81
|
cameraMatrix: controlledCameraMatrix = null,
|
|
72
82
|
setCameraMatrix: setControlledCameraMatrix = null,
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
enableClick = false,
|
|
84
|
+
enableTooltip = false,
|
|
85
|
+
onClick: onClickProp = null,
|
|
86
|
+
onHover: onHoverProp = null,
|
|
75
87
|
} = props;
|
|
76
88
|
|
|
89
|
+
const onClick = typeof onClickProp === 'function' ? onClickProp : identity;
|
|
90
|
+
const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
77
94
|
// If cameraMatrix is not provided, then we manage the camera matrix internally.
|
|
78
95
|
const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
|
|
79
96
|
// Note: We use an initializer function here to avoid
|
|
@@ -114,8 +131,8 @@ export function Pluot(props) {
|
|
|
114
131
|
store: storeProp,
|
|
115
132
|
storeName: storeNameProp,
|
|
116
133
|
plotId,
|
|
117
|
-
register:
|
|
118
|
-
}), [storeNameProp, storeProp, storesProp, plotId]);
|
|
134
|
+
register: registerStores,
|
|
135
|
+
}), [storeNameProp, storeProp, storesProp, plotId, registerStores]);
|
|
119
136
|
|
|
120
137
|
const [supportsWebGpu, supportsWebGpuMessage] = useMemo(checkWebGpuFeatureDetection, []);
|
|
121
138
|
|
|
@@ -129,6 +146,11 @@ export function Pluot(props) {
|
|
|
129
146
|
const isRenderingRef = useRef(false);
|
|
130
147
|
const currentTimeout = useRef(minTimeout);
|
|
131
148
|
|
|
149
|
+
// Used to distinguish a plain click from a click that ends a drag
|
|
150
|
+
// (e.g. panning), so that dragging does not trigger picking.
|
|
151
|
+
const dragStartRef = useRef(null);
|
|
152
|
+
const didDragRef = useRef(false);
|
|
153
|
+
|
|
132
154
|
// TODO: do we want to use the backlog approach or not?
|
|
133
155
|
// (Similar to the one used in the Vitessce heatmap)
|
|
134
156
|
// Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
|
|
@@ -140,6 +162,9 @@ export function Pluot(props) {
|
|
|
140
162
|
const [bailedEarly, setBailedEarly] = useState(true);
|
|
141
163
|
|
|
142
164
|
const [pickingResult, setPickingResult] = useState(null);
|
|
165
|
+
// hoverInfo.mouseX/mouseY are in the coordinate space of the outer
|
|
166
|
+
// (width x height) container, used to position the hover tooltip.
|
|
167
|
+
const [hoverInfo, setHoverInfo] = useState(null);
|
|
143
168
|
|
|
144
169
|
const progressBarId = useId();
|
|
145
170
|
|
|
@@ -181,8 +206,9 @@ export function Pluot(props) {
|
|
|
181
206
|
setCameraMatrix(nextCameraMatrix);
|
|
182
207
|
});
|
|
183
208
|
|
|
184
|
-
//
|
|
185
|
-
|
|
209
|
+
// Runs the picking query against the wasm module and returns the normalized result.
|
|
210
|
+
// Shared by the click (pickFrame) and hover (hoverFrame) callbacks below.
|
|
211
|
+
const pick = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
186
212
|
const renderParams = {
|
|
187
213
|
schema_version: schemaVersion,
|
|
188
214
|
width,
|
|
@@ -215,15 +241,43 @@ export function Pluot(props) {
|
|
|
215
241
|
|
|
216
242
|
// TODO: wrap pick_wasm in a try/catch
|
|
217
243
|
|
|
218
|
-
|
|
244
|
+
return normalizePickingResult(await pick_wasm(
|
|
219
245
|
renderParams,
|
|
220
246
|
// The coordinates are relative to the "layer" (the camera region), not the full width/height.
|
|
221
247
|
// We also need to flip the Y coordinate so that positive is up.
|
|
222
248
|
screenCoordX + marginLeft,
|
|
223
249
|
marginBottom + (layerHeight - screenCoordY)
|
|
224
|
-
))
|
|
250
|
+
));
|
|
225
251
|
});
|
|
226
252
|
|
|
253
|
+
// The click-picking callback.
|
|
254
|
+
const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
255
|
+
setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// The hover-picking callback.
|
|
259
|
+
const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
|
|
260
|
+
const result = await pick(screenCoordX, screenCoordY);
|
|
261
|
+
setHoverInfo({
|
|
262
|
+
content: onHover(result),
|
|
263
|
+
// Convert from cameraEl-relative coordinates to outer-container-relative
|
|
264
|
+
// coordinates, since the tooltip is positioned within the outer container.
|
|
265
|
+
mouseX: screenCoordX + marginLeft,
|
|
266
|
+
mouseY: screenCoordY + marginTop,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const throttledHoverFrame = useMemo(
|
|
271
|
+
() => throttle(
|
|
272
|
+
hoverFrame,
|
|
273
|
+
50,
|
|
274
|
+
{ leading: true, trailing: true },
|
|
275
|
+
), []);
|
|
276
|
+
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
return () => throttledHoverFrame.cancel();
|
|
279
|
+
}, [throttledHoverFrame]);
|
|
280
|
+
|
|
227
281
|
// Set up the camera and picking handlers.
|
|
228
282
|
useEffect(() => {
|
|
229
283
|
const cameraEl = cameraElementRef.current;
|
|
@@ -236,20 +290,61 @@ export function Pluot(props) {
|
|
|
236
290
|
cameraEl.addEventListener("mousemove", mouseMoveHandler);
|
|
237
291
|
cameraEl.addEventListener("wheel", wheelHandler);
|
|
238
292
|
|
|
293
|
+
// Track mousedown -> mousemove distance so that a drag (e.g. panning)
|
|
294
|
+
// that ends on the camera element does not also trigger a click/pick.
|
|
295
|
+
const mouseDownHandler = (event) => {
|
|
296
|
+
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
|
297
|
+
didDragRef.current = false;
|
|
298
|
+
};
|
|
299
|
+
const dragDetectHandler = (event) => {
|
|
300
|
+
if (!dragStartRef.current) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const dx = event.clientX - dragStartRef.current.x;
|
|
304
|
+
const dy = event.clientY - dragStartRef.current.y;
|
|
305
|
+
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
|
|
306
|
+
didDragRef.current = true;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
cameraEl.addEventListener("mousedown", mouseDownHandler);
|
|
310
|
+
cameraEl.addEventListener("mousemove", dragDetectHandler);
|
|
311
|
+
|
|
239
312
|
// Set up an onClick handler for picking.
|
|
240
313
|
const clickHandler = (event) => {
|
|
241
|
-
|
|
314
|
+
const wasDrag = didDragRef.current;
|
|
315
|
+
dragStartRef.current = null;
|
|
316
|
+
didDragRef.current = false;
|
|
317
|
+
if (enableClick && !wasDrag) {
|
|
242
318
|
pickFrame(event.offsetX, event.offsetY);
|
|
243
319
|
}
|
|
244
320
|
};
|
|
245
321
|
cameraEl.addEventListener("click", clickHandler);
|
|
246
322
|
|
|
323
|
+
// Set up hover handlers for picking, only when the onHover prop is provided.
|
|
324
|
+
const hoverMoveHandler = (event) => {
|
|
325
|
+
if (enableTooltip) {
|
|
326
|
+
throttledHoverFrame(event.offsetX, event.offsetY);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const hoverLeaveHandler = () => {
|
|
330
|
+
throttledHoverFrame.cancel();
|
|
331
|
+
setHoverInfo(null);
|
|
332
|
+
};
|
|
333
|
+
if (enableTooltip) {
|
|
334
|
+
cameraEl.addEventListener("mousemove", hoverMoveHandler);
|
|
335
|
+
cameraEl.addEventListener("mouseleave", hoverLeaveHandler);
|
|
336
|
+
}
|
|
337
|
+
|
|
247
338
|
return () => {
|
|
248
339
|
cameraEl.removeEventListener("mousemove", mouseMoveHandler);
|
|
249
340
|
cameraEl.removeEventListener("wheel", wheelHandler);
|
|
341
|
+
cameraEl.removeEventListener("mousedown", mouseDownHandler);
|
|
342
|
+
cameraEl.removeEventListener("mousemove", dragDetectHandler);
|
|
250
343
|
cameraEl.removeEventListener("click", clickHandler);
|
|
344
|
+
cameraEl.removeEventListener("mousemove", hoverMoveHandler);
|
|
345
|
+
cameraEl.removeEventListener("mouseleave", hoverLeaveHandler);
|
|
251
346
|
};
|
|
252
|
-
}, [viewMode,
|
|
347
|
+
}, [viewMode, enableClick, enableTooltip, throttledHoverFrame]);
|
|
253
348
|
|
|
254
349
|
|
|
255
350
|
// The renderFrame callback.
|
|
@@ -354,8 +449,6 @@ export function Pluot(props) {
|
|
|
354
449
|
});
|
|
355
450
|
|
|
356
451
|
|
|
357
|
-
|
|
358
|
-
|
|
359
452
|
const throttledRender = useMemo(
|
|
360
453
|
() => throttle(
|
|
361
454
|
renderFrame,
|
|
@@ -401,6 +494,26 @@ export function Pluot(props) {
|
|
|
401
494
|
}, [isWasmReady, didFirstRender, cameraMatrix, backlogIteration, plotId, plotType, plotParams, stores, format,
|
|
402
495
|
width, height, aspectRatioMode, aspectRatioAlignmentMode, marginLeft, marginRight, marginTop, marginBottom]);
|
|
403
496
|
|
|
497
|
+
// Position the hover tooltip so that it grows diagonally away from whichever
|
|
498
|
+
// quadrant of the plot the mouse currently occupies, to avoid clipping.
|
|
499
|
+
const hoverStyle = useMemo(() => {
|
|
500
|
+
if (!hoverInfo) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
const { mouseX, mouseY } = hoverInfo;
|
|
504
|
+
const isLeft = mouseX < width / 2;
|
|
505
|
+
const isTop = mouseY < height / 2;
|
|
506
|
+
|
|
507
|
+
const offsetPx = 10;
|
|
508
|
+
const extraPx = 5;
|
|
509
|
+
return {
|
|
510
|
+
position: "absolute",
|
|
511
|
+
pointerEvents: "none",
|
|
512
|
+
...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
|
|
513
|
+
...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
|
|
514
|
+
};
|
|
515
|
+
}, [hoverInfo, width, height]);
|
|
516
|
+
|
|
404
517
|
return (
|
|
405
518
|
<>
|
|
406
519
|
<div style={{ width, height, position: "relative", backgroundColor }}>
|
|
@@ -457,12 +570,13 @@ export function Pluot(props) {
|
|
|
457
570
|
|
|
458
571
|
/>
|
|
459
572
|
)}
|
|
460
|
-
|
|
573
|
+
{hoverInfo ? (
|
|
574
|
+
<div style={hoverStyle}>
|
|
575
|
+
<Tooltip content={hoverInfo.content} asTable />
|
|
576
|
+
</div>
|
|
577
|
+
) : null}
|
|
461
578
|
</div>
|
|
462
579
|
<button ref={tempButtonRef} style={{ display: 'none' }}>Try lookAt</button>
|
|
463
|
-
{pickingResult ? (
|
|
464
|
-
<pre>{JSON.stringify(pickingResult, null, 2)}</pre>
|
|
465
|
-
) : null}
|
|
466
580
|
</>
|
|
467
581
|
);
|
|
468
582
|
}
|
package/src/Tooltip.jsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
const boxShadow = '5px 5px 15px rgb(0 0 0 / 20%)';
|
|
5
|
+
|
|
6
|
+
export function Tooltip(props) {
|
|
7
|
+
const {
|
|
8
|
+
content,
|
|
9
|
+
asTable = false,
|
|
10
|
+
} = props;
|
|
11
|
+
if (!content) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (typeof content === "string" || typeof content === "number") {
|
|
15
|
+
return <pre>{content}</pre>;
|
|
16
|
+
}
|
|
17
|
+
if (React.isValidElement(content)) {
|
|
18
|
+
return content;
|
|
19
|
+
}
|
|
20
|
+
if (asTable) {
|
|
21
|
+
return (
|
|
22
|
+
<table style={{ display: 'inline-block', marginBottom: 0, opacity: 0.9, padding: '5px', backgroundColor: 'white', borderRadius: '2px', boxShadow }}>
|
|
23
|
+
<tbody>
|
|
24
|
+
{Object.entries(content).map(([key, value]) => (
|
|
25
|
+
<tr key={key}>
|
|
26
|
+
<th style={{ border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }}>{key}</th>
|
|
27
|
+
<td style={{ border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }}>{value}</td>
|
|
28
|
+
</tr>
|
|
29
|
+
))}
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return <pre>{JSON.stringify(content, null, 2)}</pre>;
|
|
35
|
+
}
|