@pluot/react 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,21 @@
1
- import React, { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, useMemo, useReducer, useCallback, useId } from "react";
1
+ import React, { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, useMemo, useReducer, useId, type CSSProperties } from "react";
2
2
  import lzs from "lz-string";
3
- import { isEqual, throttle } from "lodash-es";
3
+ import { throttle } from "lodash-es";
4
4
  import {
5
5
  initialize, getIsWasmReady,
6
6
  render_wasm, pick_wasm,
7
7
  normalizeStores, getStore,
8
- getBounds, getCameraMatrixFromBounds,
9
8
  checkWebGpuFeatureDetection,
10
9
  onMouseMove2d, onWheel2d,
11
10
  onMouseMove3d, onWheel3d,
11
+ type CameraMatrix,
12
12
  } from '@pluot/core';
13
13
  import { Tooltip } from "./Tooltip.js";
14
+ import { BrushOverlay } from "./BrushOverlay.js";
15
+ import { useBrush } from "./use-brush.js";
16
+ import type {
17
+ HoverInfo, PickingResult, PluotProps, RawPickingResult, RenderParams, TooltipContent,
18
+ } from "./types.js";
14
19
 
15
20
  // Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
16
21
  // The requested module 'lz-string' is a CommonJS module,
@@ -32,28 +37,29 @@ const DEFAULT_3D_VIEW = new Float32Array([
32
37
  0, 0, -10, 1,
33
38
  ]);
34
39
 
35
- const identity = (param) => param;
40
+ const identity = <T,>(param: T): T => param;
36
41
  const noop = () => { };
37
42
 
38
43
  // Mouse movement (in pixels) beyond which a mousedown-to-click is
39
44
  // considered a drag rather than a click, so that picking is skipped.
40
45
  const DRAG_THRESHOLD_PX = 3;
41
46
 
42
- function normalizePickingResult(data) {
43
- const result = data;
44
- if (data && Array.isArray(result.layer_results)) {
45
- result.layer_results = result.layer_results.map(obj => ({
46
- layer_id: obj.layer_id,
47
+ // `pick_wasm` is typed `any` by wasm-bindgen, so `RawPickingResult` is what
48
+ // documents its wire format (see types.ts).
49
+ function normalizePickingResult(data: RawPickingResult): PickingResult {
50
+ return {
51
+ ...data,
52
+ layer_results: data.layer_results.map(({ layer_id, info }) => ({
53
+ layer_id,
47
54
  // This is needed because serde-wasm-bindgen
48
55
  // converts Rust HashMap to JS Map.
49
- info: Object.fromEntries(Array.from(obj.info)),
50
- }));
51
- }
52
- return result;
56
+ info: Object.fromEntries(info),
57
+ })),
58
+ };
53
59
  }
54
60
 
55
61
 
56
- export function Pluot(props) {
62
+ export function Pluot(props: PluotProps) {
57
63
  const {
58
64
  schemaVersion = null,
59
65
  width: widthProp,
@@ -84,15 +90,35 @@ export function Pluot(props) {
84
90
  enableTooltip = false,
85
91
  onClick: onClickProp = null,
86
92
  onHover: onHoverProp = null,
93
+ brushUnitsModeX = "Data",
94
+ brushUnitsModeY = "Data",
95
+ brushMarginTop,
96
+ brushMarginRight,
97
+ brushMarginBottom,
98
+ brushMarginLeft,
99
+ enableBrushCreate = false,
100
+ enableBrushEdit = false,
101
+ enableBrushClear = false,
102
+ brushDelay = 1500,
103
+ maybeBrushDelay = 250,
104
+ persistBrush = false,
105
+ brushMode = "Rect",
106
+ brushColor = "#3b6ea5",
107
+ // An omitted `brush` means uncontrolled; a controlled parent signals the
108
+ // empty state with `NO_BRUSH`, never `undefined`.
109
+ brush = null,
110
+ onBrush,
111
+ onBrushEnd,
112
+ onBrushClear,
87
113
  } = props;
88
114
 
89
- const onClick = typeof onClickProp === 'function' ? onClickProp : identity;
90
- const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
115
+ const onClick: (result: PickingResult) => void = typeof onClickProp === 'function' ? onClickProp : noop;
116
+ const onHover: (result: PickingResult) => TooltipContent = typeof onHoverProp === 'function' ? onHoverProp : identity;
91
117
 
92
118
 
93
119
 
94
120
  // If cameraMatrix is not provided, then we manage the camera matrix internally.
95
- const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
121
+ const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState<CameraMatrix>(
96
122
  // Note: We use an initializer function here to avoid
97
123
  // sharing the same Float32Array among multiple Pluot
98
124
  // component instances that may be rendered on the same page.
@@ -115,7 +141,7 @@ export function Pluot(props) {
115
141
  const cameraMatrix = isControlledCamera && controlledCameraMatrix !== null
116
142
  ? controlledCameraMatrix
117
143
  : uncontrolledCameraMatrix;
118
- const setCameraMatrix = isControlledCamera
144
+ const setCameraMatrix: (nextCameraMatrix: CameraMatrix) => void = isControlledCamera
119
145
  ? setControlledCameraMatrix
120
146
  : setUncontrolledCameraMatrix;
121
147
 
@@ -136,11 +162,14 @@ export function Pluot(props) {
136
162
 
137
163
  const [supportsWebGpu, supportsWebGpuMessage] = useMemo(checkWebGpuFeatureDetection, []);
138
164
 
139
- const svgRef = useRef(null);
140
- const canvasRef = useRef(null);
141
- const cameraElementRef = useRef(null);
165
+ const svgRef = useRef<SVGSVGElement | null>(null);
166
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
167
+ const cameraElementRef = useRef<HTMLDivElement | null>(null);
168
+ // The outer (width x height) element, which is the coordinate space that both
169
+ // the brush overlay and the hover tooltip are positioned within.
170
+ const containerRef = useRef<HTMLDivElement | null>(null);
142
171
 
143
- const tempButtonRef = useRef(null);
172
+ const tempButtonRef = useRef<HTMLButtonElement | null>(null);
144
173
 
145
174
  // We may want to update these things without triggering a re-render.
146
175
  const isRenderingRef = useRef(false);
@@ -148,31 +177,53 @@ export function Pluot(props) {
148
177
 
149
178
  // Used to distinguish a plain click from a click that ends a drag
150
179
  // (e.g. panning), so that dragging does not trigger picking.
151
- const dragStartRef = useRef(null);
180
+ const dragStartRef = useRef<{ x: number, y: number } | null>(null);
152
181
  const didDragRef = useRef(false);
153
182
 
154
183
  // TODO: do we want to use the backlog approach or not?
155
184
  // (Similar to the one used in the Vitessce heatmap)
156
185
  // Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
157
186
  //const backlogRef = useRef([]);
158
- const [backlogIteration, incBacklogIteration] = useReducer(i => i + 1, 0);
187
+ const [backlogIteration, incBacklogIteration] = useReducer((i: number) => i + 1, 0);
159
188
 
160
189
  const [isWasmReady, setIsWasmReady] = useState(false);
161
190
  const [didFirstRender, setDidFirstRender] = useState(false);
162
191
  const [bailedEarly, setBailedEarly] = useState(true);
163
192
 
164
- const [pickingResult, setPickingResult] = useState(null);
165
193
  // hoverInfo.mouseX/mouseY are in the coordinate space of the outer
166
194
  // (width x height) container, used to position the hover tooltip.
167
- const [hoverInfo, setHoverInfo] = useState(null);
195
+ const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
168
196
 
169
197
  const progressBarId = useId();
170
198
 
199
+ const {
200
+ brushState,
201
+ overlayRef: brushOverlayRef,
202
+ geometry: brushGeometry,
203
+ pressProgress,
204
+ isBrushHovered,
205
+ isBrushingRef,
206
+ shouldSuppressClickRef,
207
+ onVertexMouseDown,
208
+ onEdgeMouseDown,
209
+ onClearClick,
210
+ } = useBrush({
211
+ containerRef,
212
+ width, height,
213
+ marginTop, marginRight, marginBottom, marginLeft,
214
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
215
+ brushUnitsModeX, brushUnitsModeY,
216
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
217
+ enableBrushCreate, enableBrushEdit, enableBrushClear,
218
+ brushDelay, maybeBrushDelay, persistBrush, brushMode,
219
+ brush, onBrush, onBrushEnd, onBrushClear,
220
+ });
221
+
171
222
  useLayoutEffect(() => {
172
223
  initialize().then(() => setIsWasmReady(getIsWasmReady()));
173
224
  }, []);
174
225
 
175
- const wheelHandler = useEffectEvent((event) => {
226
+ const wheelHandler = useEffectEvent((event: WheelEvent) => {
176
227
  const onWheel = viewMode === "3d" ? onWheel3d : onWheel2d;
177
228
  const nextCameraMatrix = onWheel({
178
229
  width,
@@ -189,7 +240,11 @@ export function Pluot(props) {
189
240
  setCameraMatrix(nextCameraMatrix);
190
241
  });
191
242
 
192
- const mouseMoveHandler = useEffectEvent((event) => {
243
+ const mouseMoveHandler = useEffectEvent((event: MouseEvent) => {
244
+ // A drag that is drawing or editing a brush must not also pan/rotate the camera.
245
+ if (isBrushingRef.current) {
246
+ return;
247
+ }
193
248
  const onMouseMove = viewMode === "3d" ? onMouseMove3d : onMouseMove2d;
194
249
  const nextCameraMatrix = onMouseMove({
195
250
  width,
@@ -208,8 +263,8 @@ export function Pluot(props) {
208
263
 
209
264
  // Runs the picking query against the wasm module and returns the normalized result.
210
265
  // Shared by the click (pickFrame) and hover (hoverFrame) callbacks below.
211
- const pick = useEffectEvent(async (screenCoordX, screenCoordY) => {
212
- const renderParams = {
266
+ const pick = useEffectEvent(async (screenCoordX: number, screenCoordY: number): Promise<PickingResult> => {
267
+ const renderParams: RenderParams = {
213
268
  schema_version: schemaVersion,
214
269
  width,
215
270
  height,
@@ -251,12 +306,12 @@ export function Pluot(props) {
251
306
  });
252
307
 
253
308
  // The click-picking callback.
254
- const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
255
- setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
309
+ const pickFrame = useEffectEvent(async (screenCoordX: number, screenCoordY: number) => {
310
+ onClick(await pick(screenCoordX, screenCoordY));
256
311
  });
257
312
 
258
313
  // The hover-picking callback.
259
- const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
314
+ const hoverFrame = useEffectEvent(async (screenCoordX: number, screenCoordY: number) => {
260
315
  const result = await pick(screenCoordX, screenCoordY);
261
316
  setHoverInfo({
262
317
  content: onHover(result),
@@ -292,11 +347,15 @@ export function Pluot(props) {
292
347
 
293
348
  // Track mousedown -> mousemove distance so that a drag (e.g. panning)
294
349
  // that ends on the camera element does not also trigger a click/pick.
295
- const mouseDownHandler = (event) => {
350
+ const mouseDownHandler = (event: MouseEvent) => {
296
351
  dragStartRef.current = { x: event.clientX, y: event.clientY };
297
352
  didDragRef.current = false;
353
+ // A brush drag that ended outside the camera element never produced the
354
+ // click that would have consumed this flag, so clear it as the next
355
+ // interaction begins rather than letting it suppress that one too.
356
+ shouldSuppressClickRef.current = false;
298
357
  };
299
- const dragDetectHandler = (event) => {
358
+ const dragDetectHandler = (event: MouseEvent) => {
300
359
  if (!dragStartRef.current) {
301
360
  return;
302
361
  }
@@ -310,19 +369,23 @@ export function Pluot(props) {
310
369
  cameraEl.addEventListener("mousemove", dragDetectHandler);
311
370
 
312
371
  // Set up an onClick handler for picking.
313
- const clickHandler = (event) => {
372
+ const clickHandler = (event: MouseEvent) => {
314
373
  const wasDrag = didDragRef.current;
374
+ // A brush drag (or a click on the clear button) ends with a click on the
375
+ // camera element, which should not also run a picking query.
376
+ const wasBrush = shouldSuppressClickRef.current;
315
377
  dragStartRef.current = null;
316
378
  didDragRef.current = false;
317
- if (enableClick && !wasDrag) {
379
+ shouldSuppressClickRef.current = false;
380
+ if (enableClick && !wasDrag && !wasBrush) {
318
381
  pickFrame(event.offsetX, event.offsetY);
319
382
  }
320
383
  };
321
384
  cameraEl.addEventListener("click", clickHandler);
322
385
 
323
386
  // Set up hover handlers for picking, only when the onHover prop is provided.
324
- const hoverMoveHandler = (event) => {
325
- if (enableTooltip) {
387
+ const hoverMoveHandler = (event: MouseEvent) => {
388
+ if (enableTooltip && !isBrushingRef.current) {
326
389
  throttledHoverFrame(event.offsetX, event.offsetY);
327
390
  }
328
391
  };
@@ -354,7 +417,7 @@ export function Pluot(props) {
354
417
  isRenderingRef.current = true;
355
418
  console.log('wasm.render');
356
419
 
357
- const renderParams = {
420
+ const renderParams: RenderParams = {
358
421
  schema_version: schemaVersion,
359
422
  width,
360
423
  height,
@@ -383,7 +446,7 @@ export function Pluot(props) {
383
446
  };
384
447
 
385
448
  // Wrap render_wasm in try/catch, to handle Rust panics.
386
- let arr;
449
+ let arr: Uint8Array;
387
450
  try {
388
451
  arr = await render_wasm(renderParams);
389
452
 
@@ -436,9 +499,9 @@ export function Pluot(props) {
436
499
  setBailedEarly(false); // Update this to hide the loading indicator.
437
500
 
438
501
  // Clear the LRU cache for the store (via its store_name) corresponding to the rendered plot.
439
- Object.keys(stores).forEach(storeName => {
502
+ Object.keys(stores ?? {}).forEach(storeName => {
440
503
  const storeUsed = getStore(storeName);
441
- if (storeUsed && storeUsed.clearCache && typeof storeUsed.clearCache === 'function') {
504
+ if (storeUsed && typeof storeUsed.clearCache === 'function') {
442
505
  storeUsed.clearCache();
443
506
  }
444
507
  });
@@ -496,7 +559,7 @@ export function Pluot(props) {
496
559
 
497
560
  // Position the hover tooltip so that it grows diagonally away from whichever
498
561
  // quadrant of the plot the mouse currently occupies, to avoid clipping.
499
- const hoverStyle = useMemo(() => {
562
+ const hoverStyle = useMemo<CSSProperties | null>(() => {
500
563
  if (!hoverInfo) {
501
564
  return null;
502
565
  }
@@ -509,6 +572,8 @@ export function Pluot(props) {
509
572
  return {
510
573
  position: "absolute",
511
574
  pointerEvents: "none",
575
+ // Above the brush overlay, so a persisted brush does not tint the tooltip.
576
+ zIndex: 2,
512
577
  ...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
513
578
  ...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
514
579
  };
@@ -516,7 +581,14 @@ export function Pluot(props) {
516
581
 
517
582
  return (
518
583
  <>
519
- <div style={{ width, height, position: "relative", backgroundColor }}>
584
+ <div
585
+ ref={containerRef}
586
+ style={{
587
+ width, height, position: "relative", backgroundColor,
588
+ // Long-clicking to start a brush otherwise selects surrounding text.
589
+ userSelect: enableBrushCreate ? "none" : undefined,
590
+ }}
591
+ >
520
592
  {!supportsWebGpu ? (
521
593
  <p>{supportsWebGpuMessage}</p>
522
594
  ) : null}
@@ -570,8 +642,22 @@ export function Pluot(props) {
570
642
 
571
643
  />
572
644
  )}
645
+ <BrushOverlay
646
+ width={width}
647
+ height={height}
648
+ overlayRef={brushOverlayRef}
649
+ geometry={brushGeometry}
650
+ color={brushColor}
651
+ brushState={brushState}
652
+ pressProgress={pressProgress}
653
+ isBrushHovered={isBrushHovered}
654
+ enableBrushEdit={enableBrushEdit}
655
+ onVertexMouseDown={onVertexMouseDown}
656
+ onEdgeMouseDown={onEdgeMouseDown}
657
+ onClearClick={onClearClick}
658
+ />
573
659
  {hoverInfo ? (
574
- <div style={hoverStyle}>
660
+ <div style={hoverStyle ?? undefined}>
575
661
  <Tooltip content={hoverInfo.content} asTable />
576
662
  </div>
577
663
  ) : null}
@@ -1,9 +1,25 @@
1
- import React from "react";
1
+ import React, { type ReactNode } from "react";
2
+ import type { TooltipProps } from "./types.js";
2
3
 
3
4
 
4
5
  const boxShadow = '5px 5px 15px rgb(0 0 0 / 20%)';
5
6
 
6
- export function Tooltip(props) {
7
+ // Table cell values come from arbitrary onHover return objects, so narrow
8
+ // them to something React can actually render.
9
+ function renderCell(value: unknown): ReactNode {
10
+ if (value === null || value === undefined || typeof value === "boolean") {
11
+ return null;
12
+ }
13
+ if (typeof value === "string" || typeof value === "number") {
14
+ return value;
15
+ }
16
+ if (React.isValidElement(value)) {
17
+ return value;
18
+ }
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ export function Tooltip(props: TooltipProps) {
7
23
  const {
8
24
  content,
9
25
  asTable = false,
@@ -24,7 +40,7 @@ export function Tooltip(props) {
24
40
  {Object.entries(content).map(([key, value]) => (
25
41
  <tr key={key}>
26
42
  <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>
43
+ <td style={{ border: 'none', fontSize: '12px', outline: 0, padding: '0 2px', textAlign: 'left' }}>{renderCell(value)}</td>
28
44
  </tr>
29
45
  ))}
30
46
  </tbody>