@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.
@@ -1,16 +1,22 @@
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
- render_wasm, pick_wasm,
6
+ render_wasm, pick_wasm, brush_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
+ BrushingResult, BrushState, HoverInfo, PickingResult, PluotProps, RawBrushingResult, RawPickingResult,
18
+ RenderParams, TooltipContent,
19
+ } from "./types.js";
14
20
 
15
21
  // Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
16
22
  // The requested module 'lz-string' is a CommonJS module,
@@ -32,28 +38,44 @@ const DEFAULT_3D_VIEW = new Float32Array([
32
38
  0, 0, -10, 1,
33
39
  ]);
34
40
 
35
- const identity = (param) => param;
41
+ const identity = <T,>(param: T): T => param;
36
42
  const noop = () => { };
37
43
 
38
44
  // Mouse movement (in pixels) beyond which a mousedown-to-click is
39
45
  // considered a drag rather than a click, so that picking is skipped.
40
46
  const DRAG_THRESHOLD_PX = 3;
41
47
 
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,
48
+ // `pick_wasm` is typed `any` by wasm-bindgen, so `RawPickingResult` is what
49
+ // documents its wire format (see types.ts).
50
+ function normalizePickingResult(data: RawPickingResult): PickingResult {
51
+ return {
52
+ ...data,
53
+ layer_results: data.layer_results.map(({ layer_id, info }) => ({
54
+ layer_id,
47
55
  // This is needed because serde-wasm-bindgen
48
56
  // converts Rust HashMap to JS Map.
49
- info: Object.fromEntries(Array.from(obj.info)),
50
- }));
51
- }
52
- return result;
57
+ info: Object.fromEntries(info),
58
+ })),
59
+ };
53
60
  }
54
61
 
62
+ // `brush_wasm` is typed `any` by wasm-bindgen, so `RawBrushingResult` is what
63
+ // documents its wire format (see types.ts).
64
+ function normalizeBrushingResult(data: RawBrushingResult): BrushingResult {
65
+ return {
66
+ ...data,
67
+ layer_results: data.layer_results.map(({ layer_id, info, element_info }) => ({
68
+ layer_id,
69
+ // This is needed because serde-wasm-bindgen
70
+ // converts Rust HashMap to JS Map.
71
+ info: Object.fromEntries(info),
72
+ element_info: Object.fromEntries(element_info),
73
+ })),
74
+ };
75
+ }
55
76
 
56
- export function Pluot(props) {
77
+
78
+ export function Pluot(props: PluotProps) {
57
79
  const {
58
80
  schemaVersion = null,
59
81
  width: widthProp,
@@ -84,15 +106,38 @@ export function Pluot(props) {
84
106
  enableTooltip = false,
85
107
  onClick: onClickProp = null,
86
108
  onHover: onHoverProp = null,
109
+ brushUnitsModeX = "Data",
110
+ brushUnitsModeY = "Data",
111
+ brushMarginTop,
112
+ brushMarginRight,
113
+ brushMarginBottom,
114
+ brushMarginLeft,
115
+ enableBrushCreate = false,
116
+ enableBrushEdit = false,
117
+ enableBrushClear = false,
118
+ brushDelay = 1500,
119
+ maybeBrushDelay = 250,
120
+ persistBrush = false,
121
+ brushMode = "Rect",
122
+ brushColor = "#3b6ea5",
123
+ // An omitted `brush` means uncontrolled; a controlled parent signals the
124
+ // empty state with `NO_BRUSH`, never `undefined`.
125
+ brush = null,
126
+ onBrush,
127
+ onBrushEnd,
128
+ onBrushClear,
129
+
130
+ // Temporary workaround. See comments in LruStore.clearCache.
131
+ shouldClearCache = true,
87
132
  } = props;
88
133
 
89
- const onClick = typeof onClickProp === 'function' ? onClickProp : identity;
90
- const onHover = typeof onHoverProp === 'function' ? onHoverProp : identity;
134
+ const onClick: (result: PickingResult) => void = typeof onClickProp === 'function' ? onClickProp : noop;
135
+ const onHover: (result: PickingResult) => TooltipContent = typeof onHoverProp === 'function' ? onHoverProp : identity;
91
136
 
92
137
 
93
138
 
94
139
  // If cameraMatrix is not provided, then we manage the camera matrix internally.
95
- const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState(
140
+ const [uncontrolledCameraMatrix, setUncontrolledCameraMatrix] = useState<CameraMatrix>(
96
141
  // Note: We use an initializer function here to avoid
97
142
  // sharing the same Float32Array among multiple Pluot
98
143
  // component instances that may be rendered on the same page.
@@ -115,7 +160,7 @@ export function Pluot(props) {
115
160
  const cameraMatrix = isControlledCamera && controlledCameraMatrix !== null
116
161
  ? controlledCameraMatrix
117
162
  : uncontrolledCameraMatrix;
118
- const setCameraMatrix = isControlledCamera
163
+ const setCameraMatrix: (nextCameraMatrix: CameraMatrix) => void = isControlledCamera
119
164
  ? setControlledCameraMatrix
120
165
  : setUncontrolledCameraMatrix;
121
166
 
@@ -136,11 +181,14 @@ export function Pluot(props) {
136
181
 
137
182
  const [supportsWebGpu, supportsWebGpuMessage] = useMemo(checkWebGpuFeatureDetection, []);
138
183
 
139
- const svgRef = useRef(null);
140
- const canvasRef = useRef(null);
141
- const cameraElementRef = useRef(null);
184
+ const svgRef = useRef<SVGSVGElement | null>(null);
185
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
186
+ const cameraElementRef = useRef<HTMLDivElement | null>(null);
187
+ // The outer (width x height) element, which is the coordinate space that both
188
+ // the brush overlay and the hover tooltip are positioned within.
189
+ const containerRef = useRef<HTMLDivElement | null>(null);
142
190
 
143
- const tempButtonRef = useRef(null);
191
+ const tempButtonRef = useRef<HTMLButtonElement | null>(null);
144
192
 
145
193
  // We may want to update these things without triggering a re-render.
146
194
  const isRenderingRef = useRef(false);
@@ -148,31 +196,101 @@ export function Pluot(props) {
148
196
 
149
197
  // Used to distinguish a plain click from a click that ends a drag
150
198
  // (e.g. panning), so that dragging does not trigger picking.
151
- const dragStartRef = useRef(null);
199
+ const dragStartRef = useRef<{ x: number, y: number } | null>(null);
152
200
  const didDragRef = useRef(false);
153
201
 
154
202
  // TODO: do we want to use the backlog approach or not?
155
203
  // (Similar to the one used in the Vitessce heatmap)
156
204
  // Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
157
205
  //const backlogRef = useRef([]);
158
- const [backlogIteration, incBacklogIteration] = useReducer(i => i + 1, 0);
206
+ const [backlogIteration, incBacklogIteration] = useReducer((i: number) => i + 1, 0);
159
207
 
160
208
  const [isWasmReady, setIsWasmReady] = useState(false);
161
209
  const [didFirstRender, setDidFirstRender] = useState(false);
162
210
  const [bailedEarly, setBailedEarly] = useState(true);
163
211
 
164
- const [pickingResult, setPickingResult] = useState(null);
165
212
  // hoverInfo.mouseX/mouseY are in the coordinate space of the outer
166
213
  // (width x height) container, used to position the hover tooltip.
167
- const [hoverInfo, setHoverInfo] = useState(null);
214
+ const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
168
215
 
169
216
  const progressBarId = useId();
170
217
 
218
+ // Runs the brush query against the wasm module for a given brush state,
219
+ // analogous to `pick` below (defined here, ahead of `pick`, since `useBrush`
220
+ // needs it immediately).
221
+ const runBrush = useEffectEvent(async (state: BrushState): Promise<BrushingResult|undefined> => {
222
+
223
+ if (!isWasmReady) {
224
+ return;
225
+ }
226
+
227
+ const renderParams: RenderParams = {
228
+ schema_version: schemaVersion,
229
+ width,
230
+ height,
231
+ format: format,
232
+ margin_bottom: marginBottom,
233
+ margin_left: marginLeft,
234
+ margin_top: marginTop,
235
+ margin_right: marginRight,
236
+ device_pixel_ratio: window.devicePixelRatio,
237
+ aspect_ratio_mode: aspectRatioMode,
238
+ aspect_ratio_alignment_mode: aspectRatioAlignmentMode,
239
+ view_mode: viewMode,
240
+ pickable: false,
241
+ camera_view: cameraMatrix,
242
+ plot_id: plotId,
243
+ plot_type: plotType,
244
+ stores,
245
+ plot_params: plotParams,
246
+ timeout: currentTimeout.current,
247
+ wait_for_store_gets: false,
248
+ cache_enabled: true,
249
+ svg_compression_enabled: true,
250
+ svg_include_document: false,
251
+ };
252
+
253
+ // Brush vertices are container-relative pixels with Y increasing downwards;
254
+ // the wasm side expects screen coordinates with Y increasing upwards (as
255
+ // with the `screenCoordX`/`screenCoordY` passed to `pick_wasm` below).
256
+ const brushParams = {
257
+ screen_vertices: state.vertices.map((vertex) => ({ x: vertex.x_pixels, y: height - vertex.y_pixels })),
258
+ brush_units_mode_x: brushUnitsModeX,
259
+ brush_units_mode_y: brushUnitsModeY,
260
+ brush_mode: state.shape,
261
+ };
262
+
263
+ return normalizeBrushingResult(await brush_wasm(renderParams, brushParams));
264
+ });
265
+
266
+ const {
267
+ brushState,
268
+ overlayRef: brushOverlayRef,
269
+ geometry: brushGeometry,
270
+ pressProgress,
271
+ isBrushHovered,
272
+ isBrushingRef,
273
+ shouldSuppressClickRef,
274
+ onVertexMouseDown,
275
+ onEdgeMouseDown,
276
+ onClearClick,
277
+ } = useBrush({
278
+ containerRef,
279
+ width, height,
280
+ marginTop, marginRight, marginBottom, marginLeft,
281
+ aspectRatioMode, aspectRatioAlignmentMode, cameraMatrix,
282
+ brushUnitsModeX, brushUnitsModeY,
283
+ brushMarginTop, brushMarginRight, brushMarginBottom, brushMarginLeft,
284
+ enableBrushCreate, enableBrushEdit, enableBrushClear,
285
+ brushDelay, maybeBrushDelay, persistBrush, brushMode,
286
+ brush, onBrush, onBrushEnd, onBrushClear, runBrush,
287
+ });
288
+
171
289
  useLayoutEffect(() => {
172
290
  initialize().then(() => setIsWasmReady(getIsWasmReady()));
173
291
  }, []);
174
292
 
175
- const wheelHandler = useEffectEvent((event) => {
293
+ const wheelHandler = useEffectEvent((event: WheelEvent) => {
176
294
  const onWheel = viewMode === "3d" ? onWheel3d : onWheel2d;
177
295
  const nextCameraMatrix = onWheel({
178
296
  width,
@@ -189,7 +307,11 @@ export function Pluot(props) {
189
307
  setCameraMatrix(nextCameraMatrix);
190
308
  });
191
309
 
192
- const mouseMoveHandler = useEffectEvent((event) => {
310
+ const mouseMoveHandler = useEffectEvent((event: MouseEvent) => {
311
+ // A drag that is drawing or editing a brush must not also pan/rotate the camera.
312
+ if (isBrushingRef.current) {
313
+ return;
314
+ }
193
315
  const onMouseMove = viewMode === "3d" ? onMouseMove3d : onMouseMove2d;
194
316
  const nextCameraMatrix = onMouseMove({
195
317
  width,
@@ -208,8 +330,8 @@ export function Pluot(props) {
208
330
 
209
331
  // Runs the picking query against the wasm module and returns the normalized result.
210
332
  // Shared by the click (pickFrame) and hover (hoverFrame) callbacks below.
211
- const pick = useEffectEvent(async (screenCoordX, screenCoordY) => {
212
- const renderParams = {
333
+ const pick = useEffectEvent(async (screenCoordX: number, screenCoordY: number): Promise<PickingResult> => {
334
+ const renderParams: RenderParams = {
213
335
  schema_version: schemaVersion,
214
336
  width,
215
337
  height,
@@ -251,12 +373,12 @@ export function Pluot(props) {
251
373
  });
252
374
 
253
375
  // The click-picking callback.
254
- const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
255
- setPickingResult(onClick(await pick(screenCoordX, screenCoordY)));
376
+ const pickFrame = useEffectEvent(async (screenCoordX: number, screenCoordY: number) => {
377
+ onClick(await pick(screenCoordX, screenCoordY));
256
378
  });
257
379
 
258
380
  // The hover-picking callback.
259
- const hoverFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
381
+ const hoverFrame = useEffectEvent(async (screenCoordX: number, screenCoordY: number) => {
260
382
  const result = await pick(screenCoordX, screenCoordY);
261
383
  setHoverInfo({
262
384
  content: onHover(result),
@@ -292,11 +414,15 @@ export function Pluot(props) {
292
414
 
293
415
  // Track mousedown -> mousemove distance so that a drag (e.g. panning)
294
416
  // that ends on the camera element does not also trigger a click/pick.
295
- const mouseDownHandler = (event) => {
417
+ const mouseDownHandler = (event: MouseEvent) => {
296
418
  dragStartRef.current = { x: event.clientX, y: event.clientY };
297
419
  didDragRef.current = false;
420
+ // A brush drag that ended outside the camera element never produced the
421
+ // click that would have consumed this flag, so clear it as the next
422
+ // interaction begins rather than letting it suppress that one too.
423
+ shouldSuppressClickRef.current = false;
298
424
  };
299
- const dragDetectHandler = (event) => {
425
+ const dragDetectHandler = (event: MouseEvent) => {
300
426
  if (!dragStartRef.current) {
301
427
  return;
302
428
  }
@@ -310,19 +436,23 @@ export function Pluot(props) {
310
436
  cameraEl.addEventListener("mousemove", dragDetectHandler);
311
437
 
312
438
  // Set up an onClick handler for picking.
313
- const clickHandler = (event) => {
439
+ const clickHandler = (event: MouseEvent) => {
314
440
  const wasDrag = didDragRef.current;
441
+ // A brush drag (or a click on the clear button) ends with a click on the
442
+ // camera element, which should not also run a picking query.
443
+ const wasBrush = shouldSuppressClickRef.current;
315
444
  dragStartRef.current = null;
316
445
  didDragRef.current = false;
317
- if (enableClick && !wasDrag) {
446
+ shouldSuppressClickRef.current = false;
447
+ if (enableClick && !wasDrag && !wasBrush) {
318
448
  pickFrame(event.offsetX, event.offsetY);
319
449
  }
320
450
  };
321
451
  cameraEl.addEventListener("click", clickHandler);
322
452
 
323
453
  // Set up hover handlers for picking, only when the onHover prop is provided.
324
- const hoverMoveHandler = (event) => {
325
- if (enableTooltip) {
454
+ const hoverMoveHandler = (event: MouseEvent) => {
455
+ if (enableTooltip && !isBrushingRef.current) {
326
456
  throttledHoverFrame(event.offsetX, event.offsetY);
327
457
  }
328
458
  };
@@ -354,7 +484,7 @@ export function Pluot(props) {
354
484
  isRenderingRef.current = true;
355
485
  console.log('wasm.render');
356
486
 
357
- const renderParams = {
487
+ const renderParams: RenderParams = {
358
488
  schema_version: schemaVersion,
359
489
  width,
360
490
  height,
@@ -383,7 +513,7 @@ export function Pluot(props) {
383
513
  };
384
514
 
385
515
  // Wrap render_wasm in try/catch, to handle Rust panics.
386
- let arr;
516
+ let arr: Uint8Array;
387
517
  try {
388
518
  arr = await render_wasm(renderParams);
389
519
 
@@ -436,9 +566,9 @@ export function Pluot(props) {
436
566
  setBailedEarly(false); // Update this to hide the loading indicator.
437
567
 
438
568
  // Clear the LRU cache for the store (via its store_name) corresponding to the rendered plot.
439
- Object.keys(stores).forEach(storeName => {
569
+ Object.keys(stores ?? {}).forEach(storeName => {
440
570
  const storeUsed = getStore(storeName);
441
- if (storeUsed && storeUsed.clearCache && typeof storeUsed.clearCache === 'function') {
571
+ if (storeUsed && typeof storeUsed.clearCache === 'function' && shouldClearCache) {
442
572
  storeUsed.clearCache();
443
573
  }
444
574
  });
@@ -496,7 +626,7 @@ export function Pluot(props) {
496
626
 
497
627
  // Position the hover tooltip so that it grows diagonally away from whichever
498
628
  // quadrant of the plot the mouse currently occupies, to avoid clipping.
499
- const hoverStyle = useMemo(() => {
629
+ const hoverStyle = useMemo<CSSProperties | null>(() => {
500
630
  if (!hoverInfo) {
501
631
  return null;
502
632
  }
@@ -509,6 +639,8 @@ export function Pluot(props) {
509
639
  return {
510
640
  position: "absolute",
511
641
  pointerEvents: "none",
642
+ // Above the brush overlay, so a persisted brush does not tint the tooltip.
643
+ zIndex: 2,
512
644
  ...(isTop ? { top: mouseY + offsetPx } : { bottom: height - mouseY + offsetPx + extraPx }),
513
645
  ...(isLeft ? { left: mouseX + offsetPx + extraPx } : { right: width - mouseX + offsetPx }),
514
646
  };
@@ -516,7 +648,14 @@ export function Pluot(props) {
516
648
 
517
649
  return (
518
650
  <>
519
- <div style={{ width, height, position: "relative", backgroundColor }}>
651
+ <div
652
+ ref={containerRef}
653
+ style={{
654
+ width, height, position: "relative", backgroundColor,
655
+ // Long-clicking to start a brush otherwise selects surrounding text.
656
+ userSelect: enableBrushCreate ? "none" : undefined,
657
+ }}
658
+ >
520
659
  {!supportsWebGpu ? (
521
660
  <p>{supportsWebGpuMessage}</p>
522
661
  ) : null}
@@ -570,8 +709,22 @@ export function Pluot(props) {
570
709
 
571
710
  />
572
711
  )}
712
+ <BrushOverlay
713
+ width={width}
714
+ height={height}
715
+ overlayRef={brushOverlayRef}
716
+ geometry={brushGeometry}
717
+ color={brushColor}
718
+ brushState={brushState}
719
+ pressProgress={pressProgress}
720
+ isBrushHovered={isBrushHovered}
721
+ enableBrushEdit={enableBrushEdit}
722
+ onVertexMouseDown={onVertexMouseDown}
723
+ onEdgeMouseDown={onEdgeMouseDown}
724
+ onClearClick={onClearClick}
725
+ />
573
726
  {hoverInfo ? (
574
- <div style={hoverStyle}>
727
+ <div style={hoverStyle ?? undefined}>
575
728
  <Tooltip content={hoverInfo.content} asTable />
576
729
  </div>
577
730
  ) : 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>