@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.
@@ -1 +1 @@
1
- {"version":3,"file":"Pluot.d.ts","sourceRoot":"","sources":["../src/Pluot.jsx"],"names":[],"mappings":"AA+CA,uCAoaC"}
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, enablePicking = true, backgroundColor = undefined, } = props;
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: true,
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
- // The picking callback.
129
- const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
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
- setPickingResult(normalizePickingResult(await pick_wasm(renderParams,
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
- if (enablePicking) {
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, enablePicking]);
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
- }) : {}) }))] }), _jsx("button", { ref: tempButtonRef, style: { display: 'none' }, children: "Try lookAt" }), pickingResult ? (_jsx("pre", { children: JSON.stringify(pickingResult, null, 2) })) : null] }));
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,2 @@
1
+ export function Tooltip(props: any): any;
2
+ //# sourceMappingURL=Tooltip.d.ts.map
@@ -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.12",
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.12"
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
- enablePicking = true,
74
- backgroundColor = undefined,
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: true,
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
- // The picking callback.
185
- const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
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
- setPickingResult(normalizePickingResult(await pick_wasm(
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
- if (enablePicking) {
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, enablePicking]);
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
  }
@@ -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
+ }