@vitessce/heatmap 2.0.2 → 2.0.3

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/Heatmap.js ADDED
@@ -0,0 +1,857 @@
1
+ /* eslint-disable react/display-name */
2
+ import React, {
3
+ useRef, useState, useCallback, useMemo, useEffect, useReducer, forwardRef,
4
+ } from 'react';
5
+ import uuidv4 from 'uuid/v4';
6
+ import {
7
+ deck, luma,
8
+ HeatmapCompositeTextLayer,
9
+ PixelatedBitmapLayer,
10
+ PaddedExpressionHeatmapBitmapLayer,
11
+ HeatmapBitmapLayer,
12
+ TILE_SIZE,
13
+ MAX_ROW_AGG,
14
+ MIN_ROW_AGG,
15
+ COLOR_BAR_SIZE,
16
+ AXIS_MARGIN,
17
+ DATA_TEXTURE_SIZE,
18
+ PIXELATED_TEXTURE_PARAMETERS,
19
+ } from '@vitessce/gl';
20
+ import range from 'lodash/range';
21
+ import clamp from 'lodash/clamp';
22
+ import isEqual from 'lodash/isEqual';
23
+ import {
24
+ getLongestString,
25
+ DEFAULT_GL_OPTIONS,
26
+ createDefaultUpdateCellsHover,
27
+ createDefaultUpdateGenesHover,
28
+ createDefaultUpdateTracksHover,
29
+ createDefaultUpdateViewInfo,
30
+ copyUint8Array,
31
+ getDefaultColor,
32
+ } from '@vitessce/utils';
33
+
34
+
35
+ import {
36
+ layerFilter,
37
+ getAxisSizes,
38
+ mouseToHeatmapPosition,
39
+ heatmapToMousePosition,
40
+ mouseToCellColorPosition,
41
+ } from './utils';
42
+ import HeatmapWorkerPool from './HeatmapWorkerPool';
43
+ // Only allocate the memory once for the container
44
+ const paddedExpressionContainer = new Uint8Array(DATA_TEXTURE_SIZE * DATA_TEXTURE_SIZE);
45
+
46
+ /**
47
+ * Should the "padded" implementation
48
+ * be used? Only works if the number of heatmap values is
49
+ * <= 4096^2 = ~16 million.
50
+ * @param {number|null} dataLength The number of heatmap values.
51
+ * @returns {boolean} Whether the more efficient implementation should be used.
52
+ */
53
+ function shouldUsePaddedImplementation(dataLength) {
54
+ return dataLength <= DATA_TEXTURE_SIZE ** 2;
55
+ }
56
+
57
+ /**
58
+ * A heatmap component for cell x gene matrices.
59
+ * @param {object} props
60
+ * @param {string} props.uuid The uuid of this component,
61
+ * used by tooltips to determine whether to render a tooltip or
62
+ * a crosshair.
63
+ * @param {string} props.theme The current theme name.
64
+ * @param {object} props.viewState The viewState for
65
+ * DeckGL.
66
+ * @param {function} props.setViewState The viewState setter
67
+ * for DeckGL.
68
+ * @param {number} props.width The width of the canvas.
69
+ * @param {number} props.height The height of the canvas.
70
+ * @param {object} props.expressionMatrix An object { rows, cols, matrix },
71
+ * where matrix is a flat Uint8Array, rows is a list of cell ID strings,
72
+ * and cols is a list of gene ID strings.
73
+ * @param {Map} props.cellColors Map of cell ID to color. Optional.
74
+ * If defined, the key ordering is used to order the cell axis of the heatmap.
75
+ * @param {array} props.cellColorLabels array of labels to place beside cell color
76
+ * tracks. Only works for transpose=true.
77
+ * @param {function} props.clearPleaseWait The clear please wait callback,
78
+ * called when the expression matrix has loaded (is not null).
79
+ * @param {function} props.setCellHighlight Callback function called on
80
+ * hover with the cell ID. Optional.
81
+ * @param {function} props.setGeneHighlight Callback function called on
82
+ * hover with the gene ID. Optional.
83
+ * @param {function} props.updateViewInfo Callback function that gets called with an
84
+ * object { uuid, project() } where project is a function that maps (cellId, geneId)
85
+ * to canvas (x,y) coordinates. Used to show tooltips. Optional.
86
+ * @param {boolean} props.transpose By default, false.
87
+ * @param {string} props.variablesTitle By default, 'Genes'.
88
+ * @param {string} props.observationsTitle By default, 'Cells'.
89
+ * @param {number} props.useDevicePixels By default, 1. Higher values
90
+ * e.g. 2 increase text sharpness.
91
+ * @param {boolean} props.hideObservationLabels By default false.
92
+ * @param {boolean} props.hideVariableLabels By default false.
93
+ * @param {string} props.colormap The name of the colormap function to use.
94
+ * @param {array} props.colormapRange A tuple [lower, upper] to adjust the color scale.
95
+ * @param {function} props.setColormapRange The setter function for colormapRange.
96
+ */
97
+ const Heatmap = forwardRef((props, deckRef) => {
98
+ const {
99
+ uuid,
100
+ theme,
101
+ viewState: rawViewState,
102
+ setViewState,
103
+ width: viewWidth,
104
+ height: viewHeight,
105
+ expressionMatrix: expression,
106
+ cellColors,
107
+ cellColorLabels = [''],
108
+ colormap,
109
+ colormapRange,
110
+ clearPleaseWait,
111
+ setComponentHover,
112
+ setCellHighlight = createDefaultUpdateCellsHover('Heatmap'),
113
+ setGeneHighlight = createDefaultUpdateGenesHover('Heatmap'),
114
+ setTrackHighlight = createDefaultUpdateTracksHover('Heatmap'),
115
+ updateViewInfo = createDefaultUpdateViewInfo('Heatmap'),
116
+ setIsRendering = () => {},
117
+ transpose = false,
118
+ variablesTitle = 'Genes',
119
+ observationsTitle = 'Cells',
120
+ variablesDashes = true,
121
+ observationsDashes = true,
122
+ useDevicePixels = 1,
123
+ hideObservationLabels = false,
124
+ hideVariableLabels = false,
125
+ } = props;
126
+
127
+ const viewState = {
128
+ ...rawViewState,
129
+ target: (transpose ? [rawViewState.target[1], rawViewState.target[0]] : rawViewState.target),
130
+ minZoom: 0,
131
+ };
132
+
133
+ const axisLeftTitle = (transpose ? variablesTitle : observationsTitle);
134
+ const axisTopTitle = (transpose ? observationsTitle : variablesTitle);
135
+
136
+ const workerPool = useMemo(() => new HeatmapWorkerPool(), []);
137
+
138
+ useEffect(() => {
139
+ if (clearPleaseWait && expression) {
140
+ clearPleaseWait('expression-matrix');
141
+ }
142
+ }, [clearPleaseWait, expression]);
143
+
144
+ const tilesRef = useRef();
145
+ const dataRef = useRef();
146
+ const [axisLeftLabels, setAxisLeftLabels] = useState([]);
147
+ const [axisTopLabels, setAxisTopLabels] = useState([]);
148
+ const [numCellColorTracks, setNumCellColorTracks] = useState([]);
149
+
150
+
151
+ // Since we are storing the tile data in a ref,
152
+ // and updating it asynchronously when the worker finishes,
153
+ // we need to tie it to a piece of state through this iteration value.
154
+ const [tileIteration, incTileIteration] = useReducer(i => i + 1, 0);
155
+
156
+ // We need to keep a backlog of the tasks for the worker thread,
157
+ // since the array buffer can only be held by one thread at a time.
158
+ const [backlog, setBacklog] = useState([]);
159
+
160
+ // Store a reference to the matrix Uint8Array in the dataRef,
161
+ // since we need to access its array buffer to transfer
162
+ // it back and forth from the worker thread.
163
+ useEffect(() => {
164
+ // Store the expression matrix Uint8Array in the dataRef.
165
+ if (expression && expression.matrix
166
+ && !shouldUsePaddedImplementation(expression.matrix.length)
167
+ ) {
168
+ dataRef.current = copyUint8Array(expression.matrix);
169
+ }
170
+ }, [dataRef, expression]);
171
+
172
+ // Check if the ordering of axis labels needs to be changed,
173
+ // for example if the cells "selected" (technically just colored)
174
+ // have changed.
175
+ useEffect(() => {
176
+ if (!expression) {
177
+ return;
178
+ }
179
+
180
+ const newCellOrdering = (!cellColors || cellColors.size === 0
181
+ ? expression.rows
182
+ : Array.from(cellColors.keys())
183
+ );
184
+
185
+ const oldCellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
186
+
187
+ if (!isEqual(oldCellOrdering, newCellOrdering)) {
188
+ if (transpose) {
189
+ setAxisTopLabels(newCellOrdering);
190
+ } else {
191
+ setAxisLeftLabels(newCellOrdering);
192
+ }
193
+ }
194
+ }, [expression, cellColors, axisTopLabels, axisLeftLabels, transpose]);
195
+
196
+ // Set the genes ordering.
197
+ useEffect(() => {
198
+ if (!expression) {
199
+ return;
200
+ }
201
+ if (transpose) {
202
+ setAxisLeftLabels(expression.cols);
203
+ } else {
204
+ setAxisTopLabels(expression.cols);
205
+ }
206
+ }, [expression, transpose]);
207
+
208
+ const [longestCellLabel, longestGeneLabel] = useMemo(() => {
209
+ if (!expression) {
210
+ return ['', ''];
211
+ }
212
+
213
+ return [
214
+ getLongestString(expression.rows),
215
+ getLongestString([...expression.cols, ...cellColorLabels]),
216
+ ];
217
+ }, [expression, cellColorLabels]);
218
+
219
+ // Creating a look up dictionary once is faster than calling indexOf many times
220
+ // i.e when cell ordering changes.
221
+ const expressionRowLookUp = useMemo(() => {
222
+ const lookUp = new Map();
223
+ if (expression?.rows) {
224
+ // eslint-disable-next-line no-return-assign
225
+ expression.rows.forEach((cell, j) => (lookUp.set(cell, j)));
226
+ }
227
+ return lookUp;
228
+ }, [expression]);
229
+
230
+ const width = axisTopLabels.length;
231
+ const height = axisLeftLabels.length;
232
+
233
+ const [axisOffsetLeft, axisOffsetTop] = getAxisSizes(
234
+ transpose, longestGeneLabel, longestCellLabel,
235
+ hideObservationLabels, hideVariableLabels,
236
+ );
237
+ const [gl, setGlContext] = useState(null);
238
+
239
+ const offsetTop = axisOffsetTop + COLOR_BAR_SIZE * (transpose ? numCellColorTracks : 0);
240
+ const offsetLeft = axisOffsetLeft + COLOR_BAR_SIZE * (transpose ? 0 : numCellColorTracks);
241
+
242
+ const matrixWidth = viewWidth - offsetLeft;
243
+ const matrixHeight = viewHeight - offsetTop;
244
+
245
+ const matrixLeft = -matrixWidth / 2;
246
+ const matrixRight = matrixWidth / 2;
247
+ const matrixTop = -matrixHeight / 2;
248
+ const matrixBottom = matrixHeight / 2;
249
+
250
+ const xTiles = Math.ceil(width / TILE_SIZE);
251
+ const yTiles = Math.ceil(height / TILE_SIZE);
252
+
253
+ const widthRatio = 1 - (TILE_SIZE - (width % TILE_SIZE)) / (xTiles * TILE_SIZE);
254
+ const heightRatio = 1 - (TILE_SIZE - (height % TILE_SIZE)) / (yTiles * TILE_SIZE);
255
+
256
+ const tileWidth = (matrixWidth / widthRatio) / (xTiles);
257
+ const tileHeight = (matrixHeight / heightRatio) / (yTiles);
258
+
259
+ const scaleFactor = 2 ** viewState.zoom;
260
+ const cellHeight = (matrixHeight * scaleFactor) / height;
261
+ const cellWidth = (matrixWidth * scaleFactor) / width;
262
+
263
+ // Get power of 2 between 1 and 16,
264
+ // for number of cells to aggregate together in each direction.
265
+ const aggSizeX = clamp(2 ** Math.ceil(Math.log2(1 / cellWidth)), MIN_ROW_AGG, MAX_ROW_AGG);
266
+ const aggSizeY = clamp(2 ** Math.ceil(Math.log2(1 / cellHeight)), MIN_ROW_AGG, MAX_ROW_AGG);
267
+
268
+ const [targetX, targetY] = viewState.target;
269
+
270
+ // Emit the viewInfo object on viewState updates
271
+ // (used by tooltips / crosshair elements).
272
+ useEffect(() => {
273
+ updateViewInfo({
274
+ uuid,
275
+ project: (cellId, geneId) => {
276
+ const colI = transpose ? axisTopLabels.indexOf(cellId) : axisTopLabels.indexOf(geneId);
277
+ const rowI = transpose ? axisLeftLabels.indexOf(geneId) : axisLeftLabels.indexOf(cellId);
278
+ return heatmapToMousePosition(
279
+ colI, rowI, {
280
+ offsetLeft,
281
+ offsetTop,
282
+ targetX: viewState.target[0],
283
+ targetY: viewState.target[1],
284
+ scaleFactor,
285
+ matrixWidth,
286
+ matrixHeight,
287
+ numRows: height,
288
+ numCols: width,
289
+ },
290
+ );
291
+ },
292
+ });
293
+ }, [uuid, updateViewInfo, transpose, axisTopLabels, axisLeftLabels, offsetLeft,
294
+ offsetTop, viewState, scaleFactor, matrixWidth, matrixHeight, height, width]);
295
+
296
+
297
+ // Listen for viewState changes.
298
+ // Do not allow the user to zoom and pan outside of the initial window.
299
+ const onViewStateChange = useCallback(({ viewState: nextViewState }) => {
300
+ const { zoom: nextZoom } = nextViewState;
301
+ const nextScaleFactor = 2 ** nextZoom;
302
+
303
+ const minTargetX = nextZoom === 0 ? 0 : -(matrixRight - (matrixRight / nextScaleFactor));
304
+ const maxTargetX = -1 * minTargetX;
305
+
306
+ const minTargetY = nextZoom === 0 ? 0 : -(matrixBottom - (matrixBottom / nextScaleFactor));
307
+ const maxTargetY = -1 * minTargetY;
308
+
309
+ // Manipulate view state if necessary to keep the user in the window.
310
+ const nextTarget = [
311
+ clamp(nextViewState.target[0], minTargetX, maxTargetX),
312
+ clamp(nextViewState.target[1], minTargetY, maxTargetY),
313
+ ];
314
+
315
+ setViewState({
316
+ zoom: nextZoom,
317
+ target: (transpose ? [nextTarget[1], nextTarget[0]] : nextTarget),
318
+ });
319
+ }, [matrixRight, matrixBottom, transpose, setViewState]);
320
+
321
+ // If `expression` or `cellOrdering` have changed,
322
+ // then new tiles need to be generated,
323
+ // so add a new task to the backlog.
324
+ useEffect(() => {
325
+ if (!expression || !expression.matrix || expression.matrix.length < DATA_TEXTURE_SIZE ** 2) {
326
+ return;
327
+ }
328
+ // Use a uuid to give the task a unique ID,
329
+ // to help identify where in the list it is located
330
+ // after the worker thread asynchronously sends the data back
331
+ // to this thread.
332
+ if (
333
+ axisTopLabels && axisLeftLabels && xTiles && yTiles
334
+ ) {
335
+ setBacklog(prev => [...prev, uuidv4()]);
336
+ }
337
+ }, [dataRef, expression, axisTopLabels, axisLeftLabels, xTiles, yTiles]);
338
+
339
+ // When the backlog has updated, a new worker job can be submitted if:
340
+ // - the backlog has length >= 1 (at least one job is waiting), and
341
+ // - buffer.byteLength is not zero, so the worker does not currently "own" the buffer.
342
+ useEffect(() => {
343
+ if (backlog.length < 1 || shouldUsePaddedImplementation(dataRef.current.length)) {
344
+ return;
345
+ }
346
+ const curr = backlog[backlog.length - 1];
347
+ if (dataRef.current
348
+ && dataRef.current.buffer.byteLength && expressionRowLookUp.size > 0
349
+ && !shouldUsePaddedImplementation(dataRef.current.length)) {
350
+ const { cols, matrix } = expression;
351
+ const promises = range(yTiles).map(i => range(xTiles).map(async j => workerPool.process({
352
+ curr,
353
+ tileI: i,
354
+ tileJ: j,
355
+ tileSize: TILE_SIZE,
356
+ cellOrdering: transpose ? axisTopLabels : axisLeftLabels,
357
+ cols,
358
+ transpose,
359
+ data: matrix.buffer.slice(),
360
+ expressionRowLookUp,
361
+ })));
362
+ const process = async () => {
363
+ const tiles = await Promise.all(promises.flat());
364
+ tilesRef.current = tiles.map(i => i.tile);
365
+ incTileIteration();
366
+ dataRef.current = new Uint8Array(tiles[0].buffer);
367
+ const { curr: currWork } = tiles[0];
368
+ setBacklog((prev) => {
369
+ const currIndex = prev.indexOf(currWork);
370
+ return prev.slice(currIndex + 1, prev.length);
371
+ });
372
+ };
373
+ process();
374
+ }
375
+ }, [axisLeftLabels, axisTopLabels, backlog, expression, transpose,
376
+ xTiles, yTiles, workerPool, expressionRowLookUp]);
377
+
378
+ useEffect(() => {
379
+ setIsRendering(backlog.length > 0);
380
+ }, [backlog, setIsRendering]);
381
+
382
+ // Create the padded expression matrix for holding data which can then be bound to the GPU.
383
+ const paddedExpressions = useMemo(() => {
384
+ const cellOrdering = transpose ? axisTopLabels : axisLeftLabels;
385
+ if (expression?.matrix && cellOrdering.length
386
+ && gl && shouldUsePaddedImplementation(expression.matrix.length)) {
387
+ let newIndex = 0;
388
+ for (
389
+ let cellOrderingIndex = 0;
390
+ cellOrderingIndex < cellOrdering.length;
391
+ cellOrderingIndex += 1
392
+ ) {
393
+ const cell = cellOrdering[cellOrderingIndex];
394
+ newIndex = transpose ? cellOrderingIndex : newIndex;
395
+ const cellIndex = expressionRowLookUp.get(cell);
396
+ for (
397
+ let geneIndex = 0;
398
+ geneIndex < expression.cols.length;
399
+ geneIndex += 1
400
+ ) {
401
+ const index = cellIndex * expression.cols.length + geneIndex;
402
+ paddedExpressionContainer[
403
+ newIndex % (DATA_TEXTURE_SIZE * DATA_TEXTURE_SIZE)
404
+ ] = expression.matrix[index];
405
+ newIndex = transpose ? newIndex + cellOrdering.length : newIndex + 1;
406
+ }
407
+ }
408
+ }
409
+ return gl ? new luma.Texture2D(gl, {
410
+ data: paddedExpressionContainer,
411
+ mipmaps: false,
412
+ parameters: PIXELATED_TEXTURE_PARAMETERS,
413
+ // Each color contains a single luminance value.
414
+ // When sampled, rgb are all set to this luminance, alpha is 1.0.
415
+ // Reference: https://luma.gl/docs/api-reference/webgl/texture#texture-formats
416
+ format: luma.GL.LUMINANCE,
417
+ dataFormat: luma.GL.LUMINANCE,
418
+ type: luma.GL.UNSIGNED_BYTE,
419
+ width: DATA_TEXTURE_SIZE,
420
+ height: DATA_TEXTURE_SIZE,
421
+ }) : paddedExpressionContainer;
422
+ }, [
423
+ transpose,
424
+ axisTopLabels,
425
+ axisLeftLabels,
426
+ expression,
427
+ expressionRowLookUp,
428
+ gl,
429
+ ]);
430
+
431
+ // Update the heatmap tiles if:
432
+ // - new tiles are available (`tileIteration` has changed), or
433
+ // - the matrix bounds have changed, or
434
+ // - the `aggSizeX` or `aggSizeY` have changed, or
435
+ // - the cell ordering has changed.
436
+ const heatmapLayers = useMemo(() => {
437
+ const usePaddedExpressions = expression?.matrix
438
+ && shouldUsePaddedImplementation(expression?.matrix.length);
439
+ if ((!tilesRef.current || backlog.length) && !usePaddedExpressions) {
440
+ return [];
441
+ }
442
+ if (usePaddedExpressions) {
443
+ const cellOrdering = transpose ? axisTopLabels : axisLeftLabels;
444
+ // eslint-disable-next-line no-inner-declarations, no-shadow
445
+ function getLayer(i, j) {
446
+ const { cols } = expression;
447
+ return new PaddedExpressionHeatmapBitmapLayer({
448
+ id: `heatmapLayer-${i}-${j}`,
449
+ image: paddedExpressions,
450
+ bounds: [
451
+ matrixLeft + j * tileWidth,
452
+ matrixTop + i * tileHeight,
453
+ matrixLeft + (j + 1) * tileWidth,
454
+ matrixTop + (i + 1) * tileHeight,
455
+ ],
456
+ tileI: i,
457
+ tileJ: j,
458
+ numXTiles: xTiles,
459
+ numYTiles: yTiles,
460
+ origDataSize: transpose
461
+ ? [cols.length, cellOrdering.length]
462
+ : [cellOrdering.length, cols.length],
463
+ aggSizeX,
464
+ aggSizeY,
465
+ colormap,
466
+ colorScaleLo: colormapRange[0],
467
+ colorScaleHi: colormapRange[1],
468
+ updateTriggers: {
469
+ image: [axisLeftLabels, axisTopLabels],
470
+ bounds: [tileHeight, tileWidth],
471
+ },
472
+ });
473
+ }
474
+ const layers = range(yTiles * xTiles).map(
475
+ index => getLayer(Math.floor(index / xTiles), index % xTiles),
476
+ );
477
+ return layers;
478
+ }
479
+ function getLayer(i, j, tile) {
480
+ return new HeatmapBitmapLayer({
481
+ id: `heatmapLayer-${tileIteration}-${i}-${j}`,
482
+ image: tile,
483
+ bounds: [
484
+ matrixLeft + j * tileWidth,
485
+ matrixTop + i * tileHeight,
486
+ matrixLeft + (j + 1) * tileWidth,
487
+ matrixTop + (i + 1) * tileHeight,
488
+ ],
489
+ aggSizeX,
490
+ aggSizeY,
491
+ colormap,
492
+ colorScaleLo: colormapRange[0],
493
+ colorScaleHi: colormapRange[1],
494
+ updateTriggers: {
495
+ image: [axisLeftLabels, axisTopLabels],
496
+ bounds: [tileHeight, tileWidth],
497
+ },
498
+ });
499
+ }
500
+ const layers = tilesRef.current.map(
501
+ (tile, index) => getLayer(Math.floor(index / xTiles), index % xTiles, tile),
502
+ );
503
+ return layers;
504
+ }, [expression, backlog.length, transpose, axisTopLabels, axisLeftLabels, yTiles, xTiles,
505
+ paddedExpressions, matrixLeft, tileWidth, matrixTop, tileHeight,
506
+ aggSizeX, aggSizeY, colormap, colormapRange, tileIteration]);
507
+ const axisLeftDashes = (transpose ? variablesDashes : observationsDashes);
508
+ const axisTopDashes = (transpose ? observationsDashes : variablesDashes);
509
+
510
+ // Map cell and gene names to arrays with indices,
511
+ // to prepare to render the names in TextLayers.
512
+ const axisTopLabelData = useMemo(() => axisTopLabels.map((d, i) => [i, (axisTopDashes ? `- ${d}` : d)]), [axisTopLabels, axisTopDashes]);
513
+ const axisLeftLabelData = useMemo(() => axisLeftLabels.map((d, i) => [i, (axisLeftDashes ? `${d} -` : d)]), [axisLeftLabels, axisLeftDashes]);
514
+ const cellColorLabelsData = useMemo(() => cellColorLabels.map((d, i) => [i, d && (transpose ? `${d} -` : `- ${d}`)]), [cellColorLabels, transpose]);
515
+
516
+ const hideTopLabels = (transpose ? hideObservationLabels : hideVariableLabels);
517
+ const hideLeftLabels = (transpose ? hideVariableLabels : hideObservationLabels);
518
+
519
+ // Generate the axis label, axis title, and loading indicator text layers.
520
+ const textLayers = [
521
+ new HeatmapCompositeTextLayer({
522
+ axis: 'left',
523
+ id: 'axisLeftCompositeTextLayer',
524
+ targetX,
525
+ targetY,
526
+ scaleFactor,
527
+ axisLeftLabelData,
528
+ matrixTop,
529
+ height,
530
+ matrixHeight,
531
+ cellHeight,
532
+ cellWidth,
533
+ axisTopLabelData,
534
+ matrixLeft,
535
+ width,
536
+ matrixWidth,
537
+ viewHeight,
538
+ viewWidth,
539
+ theme,
540
+ axisLeftTitle,
541
+ axisTopTitle,
542
+ axisOffsetLeft,
543
+ axisOffsetTop,
544
+ hideTopLabels,
545
+ hideLeftLabels,
546
+ transpose,
547
+ }),
548
+ new HeatmapCompositeTextLayer({
549
+ axis: 'top',
550
+ id: 'axisTopCompositeTextLayer',
551
+ targetX,
552
+ targetY,
553
+ scaleFactor,
554
+ axisLeftLabelData,
555
+ matrixTop,
556
+ height,
557
+ matrixHeight,
558
+ cellHeight,
559
+ cellWidth,
560
+ axisTopLabelData,
561
+ matrixLeft,
562
+ width,
563
+ matrixWidth,
564
+ viewHeight,
565
+ viewWidth,
566
+ theme,
567
+ axisLeftTitle,
568
+ axisTopTitle,
569
+ axisOffsetLeft,
570
+ axisOffsetTop,
571
+ cellColorLabelsData,
572
+ hideTopLabels,
573
+ hideLeftLabels,
574
+ transpose,
575
+ }),
576
+ new HeatmapCompositeTextLayer({
577
+ axis: 'corner',
578
+ id: 'cellColorLabelCompositeTextLayer',
579
+ targetX,
580
+ targetY,
581
+ scaleFactor,
582
+ axisLeftLabelData,
583
+ matrixTop,
584
+ height,
585
+ matrixHeight,
586
+ cellHeight,
587
+ cellWidth,
588
+ axisTopLabelData,
589
+ matrixLeft,
590
+ width,
591
+ matrixWidth,
592
+ viewHeight,
593
+ viewWidth,
594
+ theme,
595
+ axisLeftTitle,
596
+ axisTopTitle,
597
+ axisOffsetLeft,
598
+ axisOffsetTop,
599
+ cellColorLabelsData,
600
+ hideTopLabels,
601
+ hideLeftLabels,
602
+ transpose,
603
+ }),
604
+ ];
605
+
606
+ useEffect(() => {
607
+ setNumCellColorTracks(cellColorLabels.length);
608
+ }, [cellColorLabels]);
609
+
610
+
611
+ // Create the left color bar with a BitmapLayer.
612
+ // TODO: find a way to do aggregation for this as well.
613
+ const cellColorsTilesList = useMemo(() => {
614
+ if (!cellColors) {
615
+ return null;
616
+ }
617
+
618
+ let cellId;
619
+ let offset;
620
+ let color;
621
+ let rowI;
622
+
623
+ const cellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
624
+ const colorBarTileWidthPx = (transpose ? TILE_SIZE : 1);
625
+ const colorBarTileHeightPx = (transpose ? 1 : TILE_SIZE);
626
+
627
+ const result = range(numCellColorTracks).map((track) => {
628
+ const trackResult = range((transpose ? xTiles : yTiles)).map((i) => {
629
+ const tileData = new Uint8ClampedArray(TILE_SIZE * 1 * 4);
630
+
631
+ range(TILE_SIZE).forEach((tileY) => {
632
+ rowI = (i * TILE_SIZE) + tileY; // the row / cell index
633
+ if (rowI < cellOrdering.length) {
634
+ cellId = cellOrdering[rowI];
635
+ color = cellColors.get(cellId);
636
+
637
+ offset = (transpose ? tileY : (TILE_SIZE - tileY - 1)) * 4;
638
+
639
+ if (color) {
640
+ // allows color to be [R, G, B] or array of arrays of [R, G, B]
641
+ if (typeof color[0] !== 'number') color = color[track] ?? getDefaultColor(theme);
642
+
643
+ const [rValue, gValue, bValue] = color;
644
+ tileData[offset + 0] = rValue;
645
+ tileData[offset + 1] = gValue;
646
+ tileData[offset + 2] = bValue;
647
+ tileData[offset + 3] = 255;
648
+ }
649
+ }
650
+ });
651
+
652
+ return new ImageData(tileData, colorBarTileWidthPx, colorBarTileHeightPx);
653
+ });
654
+
655
+ return trackResult;
656
+ });
657
+
658
+ return result;
659
+ }, [cellColors, transpose, axisTopLabels, axisLeftLabels,
660
+ numCellColorTracks, xTiles, yTiles, theme]);
661
+
662
+
663
+ const cellColorsLayersList = useMemo(() => {
664
+ if (!cellColorsTilesList) {
665
+ return [];
666
+ }
667
+
668
+ const result = cellColorsTilesList.map((cellColorsTiles, track) => (cellColorsTiles
669
+ ? cellColorsTiles.map((tile, i) => new PixelatedBitmapLayer({
670
+ id: `${(transpose ? 'colorsTopLayer' : 'colorsLeftLayer')}-${track}-${i}-${uuidv4()}`,
671
+ image: tile,
672
+ bounds: (transpose ? [
673
+ matrixLeft + i * tileWidth,
674
+ -matrixHeight / 2,
675
+ matrixLeft + (i + 1) * tileWidth,
676
+ matrixHeight / 2,
677
+ ] : [
678
+ -matrixWidth / 2,
679
+ matrixTop + i * tileHeight,
680
+ matrixWidth / 2,
681
+ matrixTop + (i + 1) * tileHeight,
682
+ ]),
683
+ }))
684
+ : []));
685
+
686
+ return (result);
687
+ }, [cellColorsTilesList, matrixTop, matrixLeft, matrixHeight,
688
+ matrixWidth, tileWidth, tileHeight, transpose]);
689
+
690
+
691
+ const layers = heatmapLayers
692
+ .concat(textLayers)
693
+ .concat(...cellColorsLayersList);
694
+
695
+ // Set up the onHover function.
696
+ function onHover(info, event) {
697
+ if (!expression) {
698
+ return;
699
+ }
700
+
701
+ const { x: mouseX, y: mouseY } = event.offsetCenter;
702
+
703
+ const [trackColI, trackI] = mouseToCellColorPosition(mouseX, mouseY, {
704
+ axisOffsetTop,
705
+ axisOffsetLeft,
706
+ offsetTop,
707
+ offsetLeft,
708
+ colorBarSize: COLOR_BAR_SIZE,
709
+ numCellColorTracks,
710
+ transpose,
711
+ targetX,
712
+ targetY,
713
+ scaleFactor,
714
+ matrixWidth,
715
+ matrixHeight,
716
+ numRows: height,
717
+ numCols: width,
718
+ });
719
+
720
+ if (trackI === null || trackColI === null) {
721
+ setTrackHighlight(null);
722
+ } else {
723
+ const obsI = expression.rows.indexOf(axisTopLabels[trackColI]);
724
+ const cellIndex = expression.rows[obsI];
725
+
726
+ setTrackHighlight([cellIndex, trackI, mouseX, mouseY]);
727
+ }
728
+
729
+ const [colI, rowI] = mouseToHeatmapPosition(mouseX, mouseY, {
730
+ offsetLeft,
731
+ offsetTop,
732
+ targetX,
733
+ targetY,
734
+ scaleFactor,
735
+ matrixWidth,
736
+ matrixHeight,
737
+ numRows: height,
738
+ numCols: width,
739
+ });
740
+
741
+ if (colI === null) {
742
+ if (transpose) {
743
+ setCellHighlight(null);
744
+ } else {
745
+ setGeneHighlight(null);
746
+ }
747
+ }
748
+
749
+ if (rowI === null) {
750
+ if (transpose) {
751
+ setGeneHighlight(null);
752
+ } else {
753
+ setCellHighlight(null);
754
+ }
755
+ }
756
+
757
+ const obsI = expression.rows.indexOf(transpose
758
+ ? axisTopLabels[colI]
759
+ : axisLeftLabels[rowI]);
760
+ const varI = expression.cols.indexOf(transpose
761
+ ? axisLeftLabels[rowI]
762
+ : axisTopLabels[colI]);
763
+
764
+ const obsId = expression.rows[obsI];
765
+ const varId = expression.cols[varI];
766
+
767
+ if (setComponentHover) {
768
+ setComponentHover();
769
+ }
770
+ setCellHighlight(obsId || null);
771
+ setGeneHighlight(varId || null);
772
+ }
773
+
774
+ const cellColorsViews = useMemo(() => {
775
+ const result = range(numCellColorTracks).map((track) => {
776
+ let view;
777
+ if (transpose) {
778
+ view = new deck.OrthographicView({
779
+ id: `colorsTop-${track}`,
780
+ controller: true,
781
+ x: offsetLeft,
782
+ y: axisOffsetTop + track * COLOR_BAR_SIZE,
783
+ width: matrixWidth,
784
+ height: COLOR_BAR_SIZE - AXIS_MARGIN,
785
+ });
786
+ } else {
787
+ view = new deck.OrthographicView({
788
+ id: `colorsLeft-${track}`,
789
+ controller: true,
790
+ x: axisOffsetLeft + track * COLOR_BAR_SIZE,
791
+ y: offsetTop,
792
+ width: COLOR_BAR_SIZE - AXIS_MARGIN,
793
+ height: matrixHeight,
794
+ });
795
+ }
796
+ return view;
797
+ });
798
+
799
+ return result;
800
+ }, [numCellColorTracks, transpose, offsetLeft, axisOffsetTop,
801
+ matrixWidth, axisOffsetLeft, offsetTop, matrixHeight]);
802
+
803
+ return (
804
+ <deck.DeckGL
805
+ id={`deckgl-overlay-${uuid}`}
806
+ ref={deckRef}
807
+ onWebGLInitialized={setGlContext}
808
+ views={[
809
+ // Note that there are multiple views here,
810
+ // but only one viewState.
811
+ new deck.OrthographicView({
812
+ id: 'heatmap',
813
+ controller: true,
814
+ x: offsetLeft,
815
+ y: offsetTop,
816
+ width: matrixWidth,
817
+ height: matrixHeight,
818
+ }),
819
+ new deck.OrthographicView({
820
+ id: 'axisLeft',
821
+ controller: false,
822
+ x: 0,
823
+ y: offsetTop,
824
+ width: axisOffsetLeft,
825
+ height: matrixHeight,
826
+ }),
827
+ new deck.OrthographicView({
828
+ id: 'axisTop',
829
+ controller: false,
830
+ x: offsetLeft,
831
+ y: 0,
832
+ width: matrixWidth,
833
+ height: axisOffsetTop,
834
+ }),
835
+ new deck.OrthographicView({
836
+ id: 'cellColorLabel',
837
+ controller: false,
838
+ x: (transpose ? 0 : axisOffsetLeft),
839
+ y: (transpose ? axisOffsetTop : 0),
840
+ width: (transpose ? axisOffsetLeft : COLOR_BAR_SIZE * numCellColorTracks),
841
+ height: (transpose ? COLOR_BAR_SIZE * numCellColorTracks : axisOffsetTop),
842
+ }),
843
+ ...cellColorsViews,
844
+ ]}
845
+ layers={layers}
846
+ layerFilter={layerFilter}
847
+ getCursor={interactionState => (interactionState.isDragging ? 'grabbing' : 'default')}
848
+ glOptions={DEFAULT_GL_OPTIONS}
849
+ onViewStateChange={onViewStateChange}
850
+ viewState={viewState}
851
+ onHover={onHover}
852
+ useDevicePixels={useDevicePixels}
853
+ />
854
+ );
855
+ });
856
+
857
+ export default Heatmap;