@pluot/react 0.1.0

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/src/Pluot.jsx ADDED
@@ -0,0 +1,560 @@
1
+ import React, { useLayoutEffect, useEffect, useEffectEvent, useRef, useState, useMemo, useReducer, useCallback } from "react";
2
+ import { mat4, vec4 } from "gl-matrix";
3
+ import lzs from "lz-string";
4
+ import { isEqual, throttle } from "lodash-es";
5
+ import { FetchStore } from 'zarrita';
6
+ import {
7
+ initialize, getIsWasmReady,
8
+ render_wasm, pick_wasm,
9
+ setStore, getStore,
10
+ create2dCamera, create3dCamera,
11
+ getBounds, getCameraMatrixFromBounds,
12
+ checkWebGpuFeatureDetection,
13
+ } from '@pluot/core';
14
+
15
+ // Needed due to "SyntaxError: Named export 'decompressFromUint8Array' not found.
16
+ // The requested module 'lz-string' is a CommonJS module,
17
+ // which may not support all module.exports as named exports."
18
+ const { decompressFromUint8Array } = lzs;
19
+
20
+ const DEFAULT_VIEW = new Float32Array([
21
+ 1, 0, 0, 0,
22
+ 0, 1, 0, 0,
23
+ 0, 0, 1/200, 0,
24
+ 0, 0, 0, 1,
25
+ ]);
26
+
27
+
28
+ const DEFAULT_3D_VIEW = new Float32Array([
29
+ 1, 0, 0, 0,
30
+ 0, 1, 0, 0,
31
+ 0, 0, 1, 0,
32
+ 0, 0, -10, 1,
33
+ ]);
34
+
35
+ function normalizePickingResult(data) {
36
+ const result = data;
37
+ if (data && Array.isArray(result.layer_results)) {
38
+ result.layer_results = result.layer_results.map(obj => ({
39
+ layer_id: obj.layer_id,
40
+ // This is needed because serde-wasm-bindgen
41
+ // converts Rust HashMap to JS Map.
42
+ info: Object.fromEntries(Array.from(obj.info)),
43
+ }));
44
+ }
45
+ return result;
46
+ }
47
+
48
+
49
+ export function Pluot(props) {
50
+ const {
51
+ width: widthProp,
52
+ height: heightProp,
53
+ plotId,
54
+ plotType,
55
+ store,
56
+ storeName: storeNameProp,
57
+ plotParams,
58
+ viewMode = "2d",
59
+ marginBottom = 100.0,
60
+ marginLeft = 100.0,
61
+ marginTop = 100.0,
62
+ marginRight = 100.0,
63
+ aspectRatioMode = "Contain", // "Ignore", "Contain", "Cover"
64
+ aspectRatioAlignmentMode = "Start", // "Center", "Start", "End"
65
+ format = "Raster", // "Raster", "Vector"
66
+ minTimeout = 32,
67
+ maxTimeout = 32,
68
+ allowSimultaneousRenders = true,
69
+ debugMargins = false,
70
+ } = props;
71
+
72
+ const width = Math.floor(widthProp);
73
+ const height = Math.floor(heightProp);
74
+
75
+ const isVector = format === "Vector";
76
+
77
+ const storeName = useMemo(() => {
78
+ if (storeNameProp) {
79
+ return storeNameProp;
80
+ }
81
+ // If store is a string, assume it is a URL and initialize a FetchStore here.
82
+ if (store) {
83
+ if (typeof store === 'string') {
84
+ return setStore(new FetchStore(store), plotId);
85
+ }
86
+ return setStore(store, plotId);
87
+ }
88
+ throw new Error("Either storeName or store must be provided.");
89
+ }, [storeNameProp, store]);
90
+
91
+ const [supportsWebGpu, supportsWebGpuMessage] = useMemo(checkWebGpuFeatureDetection, []);
92
+
93
+ const svgRef = useRef(null);
94
+ const canvasRef = useRef(null);
95
+ const cameraRef = useRef(null);
96
+
97
+ const tempButtonRef = useRef(null);
98
+
99
+ // We may want to update these things without triggering a re-render.
100
+ const isRenderingRef = useRef(false);
101
+ const currentTimeout = useRef(maxTimeout);
102
+
103
+ // TODO: do we want to use the backlog approach or not?
104
+ // (Similar to the one used in the Vitessce heatmap)
105
+ // Reference: https://github.com/vitessce/vitessce/blob/71f17fb605768e0428fb15ed87b3ea34bcbb4803/packages/view-types/heatmap/src/Heatmap.js#L368
106
+ //const backlogRef = useRef([]);
107
+ const [backlogIteration, incBacklogIteration] = useReducer(i => i + 1, 0);
108
+
109
+ const [isWasmReady, setIsWasmReady] = useState(false);
110
+ const [didFirstRender, setDidFirstRender] = useState(false);
111
+ const [bailedEarly, setBailedEarly] = useState(true);
112
+
113
+ const [pickingResult, setPickingResult] = useState(null);
114
+
115
+ // TODO: handle a viewMatrix that is provided and set via props,
116
+ // to enable usage as a controlled component
117
+ // (e.g., for linked views with shared cameras).
118
+ const [viewMatrix, setViewMatrix] = useState(
119
+ // Note: We use an initializer function here to avoid
120
+ // sharing the same Float32Array among multiple Pluot
121
+ // component instances that may be rendered on the same page.
122
+ () => new Float32Array(DEFAULT_VIEW)
123
+ );
124
+
125
+ useLayoutEffect(() => {
126
+ initialize().then(() => setIsWasmReady(getIsWasmReady()));
127
+ }, []);
128
+
129
+ useEffect(() => {
130
+ // Reset view matrix on plot change.
131
+ // Create a new Float32Array to avoid sharing a mutable array
132
+ // among multiple Pluot component instances.
133
+ setViewMatrix(new Float32Array(viewMode === "2d" ? DEFAULT_VIEW : DEFAULT_3D_VIEW));
134
+ //viewMatrixRef.current = new Float32Array(DEFAULT_VIEW);
135
+ }, [plotId, viewMode]);
136
+
137
+
138
+ // Set up the camera.
139
+ useEffect(() => {
140
+ // Set up the camera.
141
+ const cameraEl = cameraRef.current;
142
+ if (!cameraEl) {
143
+ return () => {};
144
+ }
145
+
146
+ let dispose = () => {};
147
+
148
+ // Create a 2D camera for handling zoom and pan.
149
+ if (viewMode === "2d") {
150
+ function onCameraEvent(camera, event) {
151
+ camera.tick();
152
+ // Reference: https://github.com/flekschas/regl-scatterplot/blob/17a650c352fad313d1574472b2fdc5f58b9e1eca/src/index.js#L1648
153
+
154
+ setViewMatrix(prev => {
155
+ // Since camera events happen even on mousemove events that do not change the matrix,
156
+ // we check for equality here to avoid unnecessary state updates and plot re-renders.
157
+ if (isEqual(prev, camera.view)) {
158
+ return prev;
159
+ }
160
+ return mat4.clone(camera.view)
161
+ });
162
+
163
+ currentTimeout.current = minTimeout;
164
+ }
165
+
166
+ const camera = create2dCamera(cameraEl, {
167
+ isFixed: false,
168
+ distance: 0.0,
169
+ //target: [0.0, 0.0],
170
+ //viewCenter: [0.5, 0.5], // Should this be used when the coordinate system is (0 to 1) rather than (-1 to 1)?
171
+ viewCenter: [0.0, 0.0],
172
+ defaultMouseDownMoveAction: "pan",
173
+
174
+ onKeyDown: (event) => {
175
+ onCameraEvent(camera, event);
176
+ },
177
+ onKeyUp: (event) => {
178
+ onCameraEvent(camera, event);
179
+ },
180
+ onMouseDown: (event) => {
181
+ onCameraEvent(camera, event);
182
+ },
183
+ onMouseUp: (event) => {
184
+ onCameraEvent(camera, event);
185
+ },
186
+ onMouseMove: (event) => {
187
+ onCameraEvent(camera, event);
188
+ },
189
+ onWheel: (event) => {
190
+ onCameraEvent(camera, event);
191
+ },
192
+ aspectRatioMode: aspectRatioMode,
193
+ aspectRatioAlignmentMode: aspectRatioAlignmentMode,
194
+ });
195
+
196
+
197
+ // Set the initial view matrix.
198
+ // We need to ensure we create a new copy of the array.
199
+ camera.setView(new Float32Array(viewMatrix));
200
+
201
+ const tempHandler = e => {
202
+ // camera.setScaleBounds([[xScaleMin, xScaleMax], [yScaleMin, yScaleMax]])
203
+ //camera.lookAt([2.0, 2.0], 2.0);
204
+ //onCameraEvent(camera, null);
205
+
206
+ // Only zoom/pan the X axis; keep Y unchanged
207
+ const nextCameraMatrix = getCameraMatrixFromBounds(
208
+ { yMin: 0.0, yMax: 100.0 },
209
+ new Float32Array(viewMatrix),
210
+ {
211
+ width,
212
+ height,
213
+ aspectRatioMode,
214
+ aspectRatioAlignmentMode,
215
+ margins: {
216
+ marginTop,
217
+ marginBottom,
218
+ marginLeft,
219
+ marginRight
220
+ },
221
+ },
222
+ );
223
+
224
+ console.log("done", nextCameraMatrix)
225
+
226
+ camera.setView(nextCameraMatrix);
227
+ onCameraEvent(camera, null);
228
+ };
229
+
230
+ tempButtonRef.current.addEventListener('click', tempHandler);
231
+
232
+ // Set up an onClick handler.
233
+ //
234
+ const clickHandler = (event) => {
235
+ pickFrame(event.offsetX, event.offsetY);
236
+ };
237
+ cameraEl.addEventListener("click", clickHandler);
238
+
239
+ dispose = () => {
240
+ camera.dispose();
241
+ cameraEl.removeEventListener("click", clickHandler);
242
+
243
+ tempButtonRef.current.removeEventListener('click', tempHandler);
244
+ };
245
+ } else if (viewMode === "3d") {
246
+ function onCameraEvent(camera, event) {
247
+ camera.tick();
248
+ // Note: the 3D camera stores the matrix in camera.matrix (not camera.view).
249
+ setViewMatrix(prev => {
250
+ // Since camera events happen even on mousemove events that do not change the matrix,
251
+ // we check for equality here to avoid unnecessary state updates and plot re-renders.
252
+ if (isEqual(prev, camera.matrix)) {
253
+ return prev;
254
+ }
255
+ return mat4.clone(camera.matrix)
256
+ });
257
+ }
258
+
259
+ const camera = create3dCamera(cameraEl, {
260
+ mode: "orbit",
261
+ zoomSpeed: -5,
262
+ });
263
+
264
+ // TODO:
265
+ // - fork 3d-view-controls and remove usage of "global" - then clean up vite config.
266
+ // - define a camera.dispsose option.
267
+
268
+ // Reference: https://github.com/flekschas/dom-2d-camera/blob/cd59ea035a0ea72c2c0535fa3721f8127946576c/src/index.js#L237C3-L315C71
269
+ const keyUpHandler = (event) => {
270
+ // TODO
271
+ };
272
+
273
+ const keyDownHandler = (event) => {
274
+ // TODO
275
+ };
276
+
277
+ const mouseUpHandler = (event) => {
278
+ // TODO
279
+ };
280
+
281
+ const mouseDownHandler = (event) => {
282
+ // TODO
283
+ };
284
+
285
+ // TODO: use react state?
286
+ var lastX = 0;
287
+ var lastY = 0;
288
+
289
+ // Reference: https://github.com/mikolalysenko/3d-view/blob/8269e02337bba1923173a750aa7f3f0f76c91ba5/example/minimal.js#L67
290
+ const mouseMoveHandler = (event) => {
291
+ /*
292
+ var dx = (event.clientX - lastX) / width;
293
+ var dy = -(event.clientY - lastY) / height;
294
+ if (event.which === 1) {
295
+ if (event.shiftKey) {
296
+ //zoom
297
+ camera.rotate(now(), 0, 0, dx);
298
+ } else {
299
+ //rotate
300
+ camera.rotate(now(), dx, dy);
301
+ }
302
+ } else if (event.which === 3) {
303
+ //pan
304
+ camera.pan(now(), dx, dy);
305
+ }
306
+ lastX = event.clientX;
307
+ lastY = event.clientY;
308
+ */
309
+ onCameraEvent(camera, event);
310
+ };
311
+
312
+ const wheelHandler = (event) => {
313
+ //camera.pan(now(), 0, 0, event.deltaY);
314
+ onCameraEvent(camera, event);
315
+ };
316
+
317
+ cameraEl.addEventListener("keydown", keyDownHandler);
318
+ cameraEl.addEventListener("keyup", keyUpHandler);
319
+ cameraEl.addEventListener("mousedown", mouseDownHandler);
320
+ cameraEl.addEventListener("mouseup", mouseUpHandler);
321
+ cameraEl.addEventListener("mousemove", mouseMoveHandler);
322
+ cameraEl.addEventListener("wheel", wheelHandler);
323
+
324
+ dispose = () => {
325
+ cameraEl.removeEventListener("keydown", keyDownHandler);
326
+ cameraEl.removeEventListener("keyup", keyUpHandler);
327
+ cameraEl.removeEventListener("mousedown", mouseDownHandler);
328
+ cameraEl.removeEventListener("mouseup", mouseUpHandler);
329
+ cameraEl.removeEventListener("mousemove", mouseMoveHandler);
330
+ cameraEl.removeEventListener("wheel", wheelHandler);
331
+ };
332
+
333
+ //camera.matrix = new Float32Array(viewMatrix);
334
+
335
+ } else {
336
+ throw new Error("Unknown mode found.");
337
+ }
338
+
339
+ return dispose;
340
+ }, [cameraRef, viewMode, aspectRatioMode, aspectRatioAlignmentMode, width, height, marginLeft, marginRight, marginTop, marginBottom]);
341
+
342
+ // The picking callback.
343
+ const pickFrame = useEffectEvent(async (screenCoordX, screenCoordY) => {
344
+ const renderParams = {
345
+ width,
346
+ height,
347
+ format: format,
348
+ margin_bottom: marginBottom,
349
+ margin_left: marginLeft,
350
+ margin_top: marginTop,
351
+ margin_right: marginRight,
352
+ device_pixel_ratio: window.devicePixelRatio,
353
+ aspect_ratio_mode: aspectRatioMode,
354
+ aspect_ratio_alignment_mode: aspectRatioAlignmentMode,
355
+ view_mode: viewMode,
356
+ pickable: false,
357
+ // Should see the latest viewMatrix here, since renderFrame is wrapped in useEffectEvent.
358
+ camera_view: viewMatrix,
359
+ plot_id: plotId,
360
+ plot_type: plotType,
361
+ store_name: storeName,
362
+ plot_params: plotParams,
363
+ // Reduce the timeout value to improve responsiveness during data loading (bailed-early renders)?
364
+ timeout: currentTimeout.current, // in ms // Note: will not have any effect when wait_for_store_gets is false.
365
+ wait_for_store_gets: false, // TODO: lift this value up to pass/use it in the window.zarr_ functions as well?
366
+ cache_enabled: true,
367
+ svg_compression_enabled: true,
368
+ svg_include_document: false,
369
+ };
370
+
371
+ const layerHeight = height - marginTop - marginBottom;
372
+
373
+ setPickingResult(normalizePickingResult(await pick_wasm(
374
+ renderParams,
375
+ // The coordinates are relative to the "layer" (the camera region), not the full width/height.
376
+ // We also need to flip the Y coordinate so that positive is up.
377
+ screenCoordX + marginLeft,
378
+ marginBottom + (layerHeight - screenCoordY)
379
+ )));
380
+ });
381
+
382
+
383
+ // The renderFrame callback.
384
+ // We use useEffectEvent because we want to "see"
385
+ // the latest values of viewMatrix, plotProps, etc.
386
+ const renderFrame = useEffectEvent(async () => {
387
+ isRenderingRef.current = true;
388
+ console.log('wasm.render');
389
+
390
+ const renderParams = {
391
+ width,
392
+ height,
393
+ format: format,
394
+ margin_bottom: marginBottom,
395
+ margin_left: marginLeft,
396
+ margin_top: marginTop,
397
+ margin_right: marginRight,
398
+ device_pixel_ratio: window.devicePixelRatio,
399
+ aspect_ratio_mode: aspectRatioMode,
400
+ aspect_ratio_alignment_mode: aspectRatioAlignmentMode,
401
+ view_mode: viewMode,
402
+ pickable: false,
403
+ // Should see the latest viewMatrix here, since renderFrame is wrapped in useEffectEvent.
404
+ camera_view: viewMatrix,
405
+ plot_id: plotId,
406
+ plot_type: plotType,
407
+ store_name: storeName,
408
+ plot_params: plotParams,
409
+ // Reduce the timeout value to improve responsiveness during data loading (bailed-early renders)?
410
+ timeout: currentTimeout.current, // in ms // Note: will not have any effect when wait_for_store_gets is false.
411
+ wait_for_store_gets: false, // TODO: lift this value up to pass/use it in the window.zarr_ functions as well?
412
+ cache_enabled: true,
413
+ svg_compression_enabled: true,
414
+ svg_include_document: false,
415
+ };
416
+
417
+ // Wrap render_wasm in try/catch, to handle Rust panics.
418
+ let arr;
419
+ try {
420
+ arr = await render_wasm(renderParams);
421
+
422
+ isRenderingRef.current = false;
423
+ } catch (error) {
424
+ console.error("Error during wasm.render_wasm:", error);
425
+ // Cleanup
426
+ isRenderingRef.current = false;
427
+ return;
428
+ }
429
+
430
+ if (isVector) {
431
+ // Format: Vector (render to SVG)
432
+ const gContents = decompressFromUint8Array(arr);
433
+
434
+ //console.log(gContents)
435
+
436
+ if (!svgRef.current) {
437
+ return;
438
+ }
439
+ svgRef.current.innerHTML = gContents;
440
+
441
+ // TODO: check for bailed early
442
+ } else {
443
+ // Format: Raster (render to canvas)
444
+ const canvas = canvasRef.current;
445
+ if (!canvas) {
446
+ return;
447
+ }
448
+ const ctx = canvas.getContext("2d");
449
+ if (!ctx) {
450
+ return;
451
+ }
452
+ // TODO: is there a more efficient way to do this?
453
+ // E.g., write to a webgl texture? or is this fast enough already?
454
+ const imageData = new ImageData(
455
+ new Uint8ClampedArray(arr.subarray(0, -1)),
456
+ width,
457
+ height,
458
+ );
459
+ ctx.putImageData(imageData, 0, 0);
460
+
461
+ const frameBailedEarly = arr.at(-1) === 1;
462
+ if (frameBailedEarly) {
463
+ currentTimeout.current = maxTimeout;
464
+ incBacklogIteration(); // Increment this to force a re-render.
465
+ setBailedEarly(true); // Update this to show the loading indicator.
466
+ } else {
467
+ // Successful render.
468
+ setBailedEarly(false); // Update this to hide the loading indicator.
469
+
470
+ // Clear the LRU cache for the store (via its store_name) corresponding to the rendered plot.
471
+ const storeUsed = getStore(renderParams.store_name);
472
+ if (storeUsed && storeUsed.clearCache && typeof storeUsed.clearCache === 'function') {
473
+ storeUsed.clearCache();
474
+ }
475
+ }
476
+ }
477
+ setDidFirstRender(true);
478
+ });
479
+
480
+ const throttledRender = useMemo(
481
+ () => throttle(
482
+ renderFrame,
483
+ 16, // ~60fps
484
+ // When both leading and trailing are true (the default):
485
+ // - First call -> executes immediately (leading edge)
486
+ // - Calls during the wait window -> ignored, but the most recent one is remembered.
487
+ // - After the wait period expires -> the last remembered call is executed (trailing edge).
488
+ { leading: true, trailing: true }
489
+ ), []);
490
+
491
+ useEffect(() => {
492
+ return () => throttledRender.cancel();
493
+ }, [throttledRender]);
494
+
495
+ // TODO: use react-query?
496
+ useEffect(() => {
497
+ if (!isWasmReady) {
498
+ return;
499
+ }
500
+
501
+ // We want to allow for simultaneous renders, as this makes user interactions feel
502
+ // much smoother. However, we allow for users to opt-out, and we also
503
+ // need to prevent simultaneous renders prior to the first render, as the first
504
+ // render initializes cached values and stuff.
505
+ if (isRenderingRef.current && (!didFirstRender || bailedEarly || !allowSimultaneousRenders)) {
506
+ // Prevent multiple render calls prior to the first successful render.
507
+ return;
508
+ }
509
+
510
+ // Render on the next animation frame.
511
+ throttledRender();
512
+ }, [isWasmReady, didFirstRender, viewMatrix, backlogIteration, plotId, plotType, plotParams, storeName, format,
513
+ width, height, aspectRatioMode, aspectRatioAlignmentMode, marginLeft, marginRight, marginTop, marginBottom]);
514
+
515
+ return (
516
+ <>
517
+ <div style={{ width, height, position: "relative" }}>
518
+ {!supportsWebGpu ? (
519
+ <p>{supportsWebGpuMessage}</p>
520
+ ) : null}
521
+ <div
522
+ ref={cameraRef}
523
+ style={{
524
+ position: "absolute",
525
+ top: marginTop,
526
+ left: marginLeft,
527
+ width: width - marginLeft - marginRight,
528
+ height: height - marginTop - marginBottom,
529
+ border: `${debugMargins ? 1 : 0}px solid red`,
530
+ }}
531
+ />
532
+ {isVector ? (
533
+ <svg
534
+ ref={svgRef}
535
+ style={{ width, height, border: "1px solid black" }}
536
+ width={width}
537
+ height={height}
538
+ viewBox={`0 0 ${width} ${height}`}
539
+ xmlns="http://www.w3.org/2000/svg"
540
+ >
541
+ </svg>
542
+ ) : (
543
+ <canvas
544
+ ref={canvasRef}
545
+ style={{ width, height, border: "1px solid black" }}
546
+ width={width}
547
+ height={height}
548
+ />
549
+ )}
550
+ </div>
551
+ {bailedEarly ? (
552
+ <p>Loading...</p>
553
+ ) : null}
554
+ <button ref={tempButtonRef} style={{ display: 'none' }}>Try lookAt</button>
555
+ {pickingResult ? (
556
+ <pre>{JSON.stringify(pickingResult, null, 2)}</pre>
557
+ ) : null}
558
+ </>
559
+ );
560
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from '@pluot/core'; // Re-export everything from the vanilla JS package.
2
+ export { Pluot } from "./Pluot.jsx";