@vitessce/heatmap 2.0.0-beta.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/LICENSE +21 -0
- package/dist/Heatmap.js +689 -0
- package/dist/Heatmap.test.fixtures.js +20 -0
- package/dist/Heatmap.test.js +16 -0
- package/dist/HeatmapOptions.js +22 -0
- package/dist/HeatmapSubscriber.js +104 -0
- package/dist/HeatmapTooltipSubscriber.js +24 -0
- package/dist/HeatmapWorkerPool.js +49 -0
- package/dist/heatmap.worker.js +56 -0
- package/dist/index.js +2 -0
- package/dist/utils.js +181 -0
- package/dist/utils.test.js +71 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/Heatmap.js
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable react/display-name */
|
|
3
|
+
import React, { useRef, useState, useCallback, useMemo, useEffect, useReducer, forwardRef, } from 'react';
|
|
4
|
+
import uuidv4 from 'uuid/v4';
|
|
5
|
+
import { deck, luma, HeatmapCompositeTextLayer, PixelatedBitmapLayer, PaddedExpressionHeatmapBitmapLayer, HeatmapBitmapLayer, TILE_SIZE, MAX_ROW_AGG, MIN_ROW_AGG, COLOR_BAR_SIZE, AXIS_MARGIN, DATA_TEXTURE_SIZE, PIXELATED_TEXTURE_PARAMETERS, } from '@vitessce/gl';
|
|
6
|
+
import range from 'lodash/range';
|
|
7
|
+
import clamp from 'lodash/clamp';
|
|
8
|
+
import isEqual from 'lodash/isEqual';
|
|
9
|
+
import { getLongestString, DEFAULT_GL_OPTIONS, createDefaultUpdateCellsHover, createDefaultUpdateGenesHover, createDefaultUpdateTracksHover, createDefaultUpdateViewInfo, copyUint8Array, getDefaultColor, } from '@vitessce/utils';
|
|
10
|
+
import { layerFilter, getAxisSizes, mouseToHeatmapPosition, heatmapToMousePosition, mouseToCellColorPosition, } from './utils';
|
|
11
|
+
import HeatmapWorkerPool from './HeatmapWorkerPool';
|
|
12
|
+
// Only allocate the memory once for the container
|
|
13
|
+
const paddedExpressionContainer = new Uint8Array(DATA_TEXTURE_SIZE * DATA_TEXTURE_SIZE);
|
|
14
|
+
/**
|
|
15
|
+
* Should the "padded" implementation
|
|
16
|
+
* be used? Only works if the number of heatmap values is
|
|
17
|
+
* <= 4096^2 = ~16 million.
|
|
18
|
+
* @param {number|null} dataLength The number of heatmap values.
|
|
19
|
+
* @returns {boolean} Whether the more efficient implementation should be used.
|
|
20
|
+
*/
|
|
21
|
+
function shouldUsePaddedImplementation(dataLength) {
|
|
22
|
+
return dataLength <= DATA_TEXTURE_SIZE ** 2;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A heatmap component for cell x gene matrices.
|
|
26
|
+
* @param {object} props
|
|
27
|
+
* @param {string} props.uuid The uuid of this component,
|
|
28
|
+
* used by tooltips to determine whether to render a tooltip or
|
|
29
|
+
* a crosshair.
|
|
30
|
+
* @param {string} props.theme The current theme name.
|
|
31
|
+
* @param {object} props.viewState The viewState for
|
|
32
|
+
* DeckGL.
|
|
33
|
+
* @param {function} props.setViewState The viewState setter
|
|
34
|
+
* for DeckGL.
|
|
35
|
+
* @param {number} props.width The width of the canvas.
|
|
36
|
+
* @param {number} props.height The height of the canvas.
|
|
37
|
+
* @param {object} props.expressionMatrix An object { rows, cols, matrix },
|
|
38
|
+
* where matrix is a flat Uint8Array, rows is a list of cell ID strings,
|
|
39
|
+
* and cols is a list of gene ID strings.
|
|
40
|
+
* @param {Map} props.cellColors Map of cell ID to color. Optional.
|
|
41
|
+
* If defined, the key ordering is used to order the cell axis of the heatmap.
|
|
42
|
+
* @param {array} props.cellColorLabels array of labels to place beside cell color
|
|
43
|
+
* tracks. Only works for transpose=true.
|
|
44
|
+
* @param {function} props.clearPleaseWait The clear please wait callback,
|
|
45
|
+
* called when the expression matrix has loaded (is not null).
|
|
46
|
+
* @param {function} props.setCellHighlight Callback function called on
|
|
47
|
+
* hover with the cell ID. Optional.
|
|
48
|
+
* @param {function} props.setGeneHighlight Callback function called on
|
|
49
|
+
* hover with the gene ID. Optional.
|
|
50
|
+
* @param {function} props.updateViewInfo Callback function that gets called with an
|
|
51
|
+
* object { uuid, project() } where project is a function that maps (cellId, geneId)
|
|
52
|
+
* to canvas (x,y) coordinates. Used to show tooltips. Optional.
|
|
53
|
+
* @param {boolean} props.transpose By default, false.
|
|
54
|
+
* @param {string} props.variablesTitle By default, 'Genes'.
|
|
55
|
+
* @param {string} props.observationsTitle By default, 'Cells'.
|
|
56
|
+
* @param {number} props.useDevicePixels By default, 1. Higher values
|
|
57
|
+
* e.g. 2 increase text sharpness.
|
|
58
|
+
* @param {boolean} props.hideObservationLabels By default false.
|
|
59
|
+
* @param {boolean} props.hideVariableLabels By default false.
|
|
60
|
+
* @param {string} props.colormap The name of the colormap function to use.
|
|
61
|
+
* @param {array} props.colormapRange A tuple [lower, upper] to adjust the color scale.
|
|
62
|
+
* @param {function} props.setColormapRange The setter function for colormapRange.
|
|
63
|
+
*/
|
|
64
|
+
const Heatmap = forwardRef((props, deckRef) => {
|
|
65
|
+
const { uuid, theme, viewState: rawViewState, setViewState, width: viewWidth, height: viewHeight, expressionMatrix: expression, cellColors, cellColorLabels = [''], colormap, colormapRange, clearPleaseWait, setComponentHover, setCellHighlight = createDefaultUpdateCellsHover('Heatmap'), setGeneHighlight = createDefaultUpdateGenesHover('Heatmap'), setTrackHighlight = createDefaultUpdateTracksHover('Heatmap'), updateViewInfo = createDefaultUpdateViewInfo('Heatmap'), setIsRendering = () => { }, transpose = false, variablesTitle = 'Genes', observationsTitle = 'Cells', variablesDashes = true, observationsDashes = true, useDevicePixels = 1, hideObservationLabels = false, hideVariableLabels = false, } = props;
|
|
66
|
+
const viewState = {
|
|
67
|
+
...rawViewState,
|
|
68
|
+
target: (transpose ? [rawViewState.target[1], rawViewState.target[0]] : rawViewState.target),
|
|
69
|
+
minZoom: 0,
|
|
70
|
+
};
|
|
71
|
+
const axisLeftTitle = (transpose ? variablesTitle : observationsTitle);
|
|
72
|
+
const axisTopTitle = (transpose ? observationsTitle : variablesTitle);
|
|
73
|
+
const workerPool = useMemo(() => new HeatmapWorkerPool(), []);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (clearPleaseWait && expression) {
|
|
76
|
+
clearPleaseWait('expression-matrix');
|
|
77
|
+
}
|
|
78
|
+
}, [clearPleaseWait, expression]);
|
|
79
|
+
const tilesRef = useRef();
|
|
80
|
+
const dataRef = useRef();
|
|
81
|
+
const [axisLeftLabels, setAxisLeftLabels] = useState([]);
|
|
82
|
+
const [axisTopLabels, setAxisTopLabels] = useState([]);
|
|
83
|
+
const [numCellColorTracks, setNumCellColorTracks] = useState([]);
|
|
84
|
+
// Since we are storing the tile data in a ref,
|
|
85
|
+
// and updating it asynchronously when the worker finishes,
|
|
86
|
+
// we need to tie it to a piece of state through this iteration value.
|
|
87
|
+
const [tileIteration, incTileIteration] = useReducer(i => i + 1, 0);
|
|
88
|
+
// We need to keep a backlog of the tasks for the worker thread,
|
|
89
|
+
// since the array buffer can only be held by one thread at a time.
|
|
90
|
+
const [backlog, setBacklog] = useState([]);
|
|
91
|
+
// Store a reference to the matrix Uint8Array in the dataRef,
|
|
92
|
+
// since we need to access its array buffer to transfer
|
|
93
|
+
// it back and forth from the worker thread.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
// Store the expression matrix Uint8Array in the dataRef.
|
|
96
|
+
if (expression && expression.matrix
|
|
97
|
+
&& !shouldUsePaddedImplementation(expression.matrix.length)) {
|
|
98
|
+
dataRef.current = copyUint8Array(expression.matrix);
|
|
99
|
+
}
|
|
100
|
+
}, [dataRef, expression]);
|
|
101
|
+
// Check if the ordering of axis labels needs to be changed,
|
|
102
|
+
// for example if the cells "selected" (technically just colored)
|
|
103
|
+
// have changed.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!expression) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const newCellOrdering = (!cellColors || cellColors.size === 0
|
|
109
|
+
? expression.rows
|
|
110
|
+
: Array.from(cellColors.keys()));
|
|
111
|
+
const oldCellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
|
|
112
|
+
if (!isEqual(oldCellOrdering, newCellOrdering)) {
|
|
113
|
+
if (transpose) {
|
|
114
|
+
setAxisTopLabels(newCellOrdering);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
setAxisLeftLabels(newCellOrdering);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}, [expression, cellColors, axisTopLabels, axisLeftLabels, transpose]);
|
|
121
|
+
// Set the genes ordering.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!expression) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (transpose) {
|
|
127
|
+
setAxisLeftLabels(expression.cols);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
setAxisTopLabels(expression.cols);
|
|
131
|
+
}
|
|
132
|
+
}, [expression, transpose]);
|
|
133
|
+
const [longestCellLabel, longestGeneLabel] = useMemo(() => {
|
|
134
|
+
if (!expression) {
|
|
135
|
+
return ['', ''];
|
|
136
|
+
}
|
|
137
|
+
return [
|
|
138
|
+
getLongestString(expression.rows),
|
|
139
|
+
getLongestString([...expression.cols, ...cellColorLabels]),
|
|
140
|
+
];
|
|
141
|
+
}, [expression, cellColorLabels]);
|
|
142
|
+
// Creating a look up dictionary once is faster than calling indexOf many times
|
|
143
|
+
// i.e when cell ordering changes.
|
|
144
|
+
const expressionRowLookUp = useMemo(() => {
|
|
145
|
+
const lookUp = new Map();
|
|
146
|
+
if (expression?.rows) {
|
|
147
|
+
// eslint-disable-next-line no-return-assign
|
|
148
|
+
expression.rows.forEach((cell, j) => (lookUp.set(cell, j)));
|
|
149
|
+
}
|
|
150
|
+
return lookUp;
|
|
151
|
+
}, [expression]);
|
|
152
|
+
const width = axisTopLabels.length;
|
|
153
|
+
const height = axisLeftLabels.length;
|
|
154
|
+
const [axisOffsetLeft, axisOffsetTop] = getAxisSizes(transpose, longestGeneLabel, longestCellLabel, hideObservationLabels, hideVariableLabels);
|
|
155
|
+
const [gl, setGlContext] = useState(null);
|
|
156
|
+
const offsetTop = axisOffsetTop + COLOR_BAR_SIZE * (transpose ? numCellColorTracks : 0);
|
|
157
|
+
const offsetLeft = axisOffsetLeft + COLOR_BAR_SIZE * (transpose ? 0 : numCellColorTracks);
|
|
158
|
+
const matrixWidth = viewWidth - offsetLeft;
|
|
159
|
+
const matrixHeight = viewHeight - offsetTop;
|
|
160
|
+
const matrixLeft = -matrixWidth / 2;
|
|
161
|
+
const matrixRight = matrixWidth / 2;
|
|
162
|
+
const matrixTop = -matrixHeight / 2;
|
|
163
|
+
const matrixBottom = matrixHeight / 2;
|
|
164
|
+
const xTiles = Math.ceil(width / TILE_SIZE);
|
|
165
|
+
const yTiles = Math.ceil(height / TILE_SIZE);
|
|
166
|
+
const widthRatio = 1 - (TILE_SIZE - (width % TILE_SIZE)) / (xTiles * TILE_SIZE);
|
|
167
|
+
const heightRatio = 1 - (TILE_SIZE - (height % TILE_SIZE)) / (yTiles * TILE_SIZE);
|
|
168
|
+
const tileWidth = (matrixWidth / widthRatio) / (xTiles);
|
|
169
|
+
const tileHeight = (matrixHeight / heightRatio) / (yTiles);
|
|
170
|
+
const scaleFactor = 2 ** viewState.zoom;
|
|
171
|
+
const cellHeight = (matrixHeight * scaleFactor) / height;
|
|
172
|
+
const cellWidth = (matrixWidth * scaleFactor) / width;
|
|
173
|
+
// Get power of 2 between 1 and 16,
|
|
174
|
+
// for number of cells to aggregate together in each direction.
|
|
175
|
+
const aggSizeX = clamp(2 ** Math.ceil(Math.log2(1 / cellWidth)), MIN_ROW_AGG, MAX_ROW_AGG);
|
|
176
|
+
const aggSizeY = clamp(2 ** Math.ceil(Math.log2(1 / cellHeight)), MIN_ROW_AGG, MAX_ROW_AGG);
|
|
177
|
+
const [targetX, targetY] = viewState.target;
|
|
178
|
+
// Emit the viewInfo object on viewState updates
|
|
179
|
+
// (used by tooltips / crosshair elements).
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
updateViewInfo({
|
|
182
|
+
uuid,
|
|
183
|
+
project: (cellId, geneId) => {
|
|
184
|
+
const colI = transpose ? axisTopLabels.indexOf(cellId) : axisTopLabels.indexOf(geneId);
|
|
185
|
+
const rowI = transpose ? axisLeftLabels.indexOf(geneId) : axisLeftLabels.indexOf(cellId);
|
|
186
|
+
return heatmapToMousePosition(colI, rowI, {
|
|
187
|
+
offsetLeft,
|
|
188
|
+
offsetTop,
|
|
189
|
+
targetX: viewState.target[0],
|
|
190
|
+
targetY: viewState.target[1],
|
|
191
|
+
scaleFactor,
|
|
192
|
+
matrixWidth,
|
|
193
|
+
matrixHeight,
|
|
194
|
+
numRows: height,
|
|
195
|
+
numCols: width,
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}, [uuid, updateViewInfo, transpose, axisTopLabels, axisLeftLabels, offsetLeft,
|
|
200
|
+
offsetTop, viewState, scaleFactor, matrixWidth, matrixHeight, height, width]);
|
|
201
|
+
// Listen for viewState changes.
|
|
202
|
+
// Do not allow the user to zoom and pan outside of the initial window.
|
|
203
|
+
const onViewStateChange = useCallback(({ viewState: nextViewState }) => {
|
|
204
|
+
const { zoom: nextZoom } = nextViewState;
|
|
205
|
+
const nextScaleFactor = 2 ** nextZoom;
|
|
206
|
+
const minTargetX = nextZoom === 0 ? 0 : -(matrixRight - (matrixRight / nextScaleFactor));
|
|
207
|
+
const maxTargetX = -1 * minTargetX;
|
|
208
|
+
const minTargetY = nextZoom === 0 ? 0 : -(matrixBottom - (matrixBottom / nextScaleFactor));
|
|
209
|
+
const maxTargetY = -1 * minTargetY;
|
|
210
|
+
// Manipulate view state if necessary to keep the user in the window.
|
|
211
|
+
const nextTarget = [
|
|
212
|
+
clamp(nextViewState.target[0], minTargetX, maxTargetX),
|
|
213
|
+
clamp(nextViewState.target[1], minTargetY, maxTargetY),
|
|
214
|
+
];
|
|
215
|
+
setViewState({
|
|
216
|
+
zoom: nextZoom,
|
|
217
|
+
target: (transpose ? [nextTarget[1], nextTarget[0]] : nextTarget),
|
|
218
|
+
});
|
|
219
|
+
}, [matrixRight, matrixBottom, transpose, setViewState]);
|
|
220
|
+
// If `expression` or `cellOrdering` have changed,
|
|
221
|
+
// then new tiles need to be generated,
|
|
222
|
+
// so add a new task to the backlog.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!expression || !expression.matrix || expression.matrix.length < DATA_TEXTURE_SIZE ** 2) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Use a uuid to give the task a unique ID,
|
|
228
|
+
// to help identify where in the list it is located
|
|
229
|
+
// after the worker thread asynchronously sends the data back
|
|
230
|
+
// to this thread.
|
|
231
|
+
if (axisTopLabels && axisLeftLabels && xTiles && yTiles) {
|
|
232
|
+
setBacklog(prev => [...prev, uuidv4()]);
|
|
233
|
+
}
|
|
234
|
+
}, [dataRef, expression, axisTopLabels, axisLeftLabels, xTiles, yTiles]);
|
|
235
|
+
// When the backlog has updated, a new worker job can be submitted if:
|
|
236
|
+
// - the backlog has length >= 1 (at least one job is waiting), and
|
|
237
|
+
// - buffer.byteLength is not zero, so the worker does not currently "own" the buffer.
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (backlog.length < 1 || shouldUsePaddedImplementation(dataRef.current.length)) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const curr = backlog[backlog.length - 1];
|
|
243
|
+
if (dataRef.current
|
|
244
|
+
&& dataRef.current.buffer.byteLength && expressionRowLookUp.size > 0
|
|
245
|
+
&& !shouldUsePaddedImplementation(dataRef.current.length)) {
|
|
246
|
+
const { cols, matrix } = expression;
|
|
247
|
+
const promises = range(yTiles).map(i => range(xTiles).map(async (j) => workerPool.process({
|
|
248
|
+
curr,
|
|
249
|
+
tileI: i,
|
|
250
|
+
tileJ: j,
|
|
251
|
+
tileSize: TILE_SIZE,
|
|
252
|
+
cellOrdering: transpose ? axisTopLabels : axisLeftLabels,
|
|
253
|
+
cols,
|
|
254
|
+
transpose,
|
|
255
|
+
data: matrix.buffer.slice(),
|
|
256
|
+
expressionRowLookUp,
|
|
257
|
+
})));
|
|
258
|
+
const process = async () => {
|
|
259
|
+
const tiles = await Promise.all(promises.flat());
|
|
260
|
+
tilesRef.current = tiles.map(i => i.tile);
|
|
261
|
+
incTileIteration();
|
|
262
|
+
dataRef.current = new Uint8Array(tiles[0].buffer);
|
|
263
|
+
const { curr: currWork } = tiles[0];
|
|
264
|
+
setBacklog((prev) => {
|
|
265
|
+
const currIndex = prev.indexOf(currWork);
|
|
266
|
+
return prev.slice(currIndex + 1, prev.length);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
process();
|
|
270
|
+
}
|
|
271
|
+
}, [axisLeftLabels, axisTopLabels, backlog, expression, transpose,
|
|
272
|
+
xTiles, yTiles, workerPool, expressionRowLookUp]);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
setIsRendering(backlog.length > 0);
|
|
275
|
+
}, [backlog, setIsRendering]);
|
|
276
|
+
// Create the padded expression matrix for holding data which can then be bound to the GPU.
|
|
277
|
+
const paddedExpressions = useMemo(() => {
|
|
278
|
+
const cellOrdering = transpose ? axisTopLabels : axisLeftLabels;
|
|
279
|
+
if (expression?.matrix && cellOrdering.length
|
|
280
|
+
&& gl && shouldUsePaddedImplementation(expression.matrix.length)) {
|
|
281
|
+
let newIndex = 0;
|
|
282
|
+
for (let cellOrderingIndex = 0; cellOrderingIndex < cellOrdering.length; cellOrderingIndex += 1) {
|
|
283
|
+
const cell = cellOrdering[cellOrderingIndex];
|
|
284
|
+
newIndex = transpose ? cellOrderingIndex : newIndex;
|
|
285
|
+
const cellIndex = expressionRowLookUp.get(cell);
|
|
286
|
+
for (let geneIndex = 0; geneIndex < expression.cols.length; geneIndex += 1) {
|
|
287
|
+
const index = cellIndex * expression.cols.length + geneIndex;
|
|
288
|
+
paddedExpressionContainer[newIndex % (DATA_TEXTURE_SIZE * DATA_TEXTURE_SIZE)] = expression.matrix[index];
|
|
289
|
+
newIndex = transpose ? newIndex + cellOrdering.length : newIndex + 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return gl ? new luma.Texture2D(gl, {
|
|
294
|
+
data: paddedExpressionContainer,
|
|
295
|
+
mipmaps: false,
|
|
296
|
+
parameters: PIXELATED_TEXTURE_PARAMETERS,
|
|
297
|
+
// Each color contains a single luminance value.
|
|
298
|
+
// When sampled, rgb are all set to this luminance, alpha is 1.0.
|
|
299
|
+
// Reference: https://luma.gl/docs/api-reference/webgl/texture#texture-formats
|
|
300
|
+
format: luma.GL.LUMINANCE,
|
|
301
|
+
dataFormat: luma.GL.LUMINANCE,
|
|
302
|
+
type: luma.GL.UNSIGNED_BYTE,
|
|
303
|
+
width: DATA_TEXTURE_SIZE,
|
|
304
|
+
height: DATA_TEXTURE_SIZE,
|
|
305
|
+
}) : paddedExpressionContainer;
|
|
306
|
+
}, [
|
|
307
|
+
transpose,
|
|
308
|
+
axisTopLabels,
|
|
309
|
+
axisLeftLabels,
|
|
310
|
+
expression,
|
|
311
|
+
expressionRowLookUp,
|
|
312
|
+
gl,
|
|
313
|
+
]);
|
|
314
|
+
// Update the heatmap tiles if:
|
|
315
|
+
// - new tiles are available (`tileIteration` has changed), or
|
|
316
|
+
// - the matrix bounds have changed, or
|
|
317
|
+
// - the `aggSizeX` or `aggSizeY` have changed, or
|
|
318
|
+
// - the cell ordering has changed.
|
|
319
|
+
const heatmapLayers = useMemo(() => {
|
|
320
|
+
const usePaddedExpressions = expression?.matrix
|
|
321
|
+
&& shouldUsePaddedImplementation(expression?.matrix.length);
|
|
322
|
+
if ((!tilesRef.current || backlog.length) && !usePaddedExpressions) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
if (usePaddedExpressions) {
|
|
326
|
+
const cellOrdering = transpose ? axisTopLabels : axisLeftLabels;
|
|
327
|
+
// eslint-disable-next-line no-inner-declarations, no-shadow
|
|
328
|
+
function getLayer(i, j) {
|
|
329
|
+
const { cols } = expression;
|
|
330
|
+
return new PaddedExpressionHeatmapBitmapLayer({
|
|
331
|
+
id: `heatmapLayer-${i}-${j}`,
|
|
332
|
+
image: paddedExpressions,
|
|
333
|
+
bounds: [
|
|
334
|
+
matrixLeft + j * tileWidth,
|
|
335
|
+
matrixTop + i * tileHeight,
|
|
336
|
+
matrixLeft + (j + 1) * tileWidth,
|
|
337
|
+
matrixTop + (i + 1) * tileHeight,
|
|
338
|
+
],
|
|
339
|
+
tileI: i,
|
|
340
|
+
tileJ: j,
|
|
341
|
+
numXTiles: xTiles,
|
|
342
|
+
numYTiles: yTiles,
|
|
343
|
+
origDataSize: transpose
|
|
344
|
+
? [cols.length, cellOrdering.length]
|
|
345
|
+
: [cellOrdering.length, cols.length],
|
|
346
|
+
aggSizeX,
|
|
347
|
+
aggSizeY,
|
|
348
|
+
colormap,
|
|
349
|
+
colorScaleLo: colormapRange[0],
|
|
350
|
+
colorScaleHi: colormapRange[1],
|
|
351
|
+
updateTriggers: {
|
|
352
|
+
image: [axisLeftLabels, axisTopLabels],
|
|
353
|
+
bounds: [tileHeight, tileWidth],
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const layers = range(yTiles * xTiles).map(index => getLayer(Math.floor(index / xTiles), index % xTiles));
|
|
358
|
+
return layers;
|
|
359
|
+
}
|
|
360
|
+
function getLayer(i, j, tile) {
|
|
361
|
+
return new HeatmapBitmapLayer({
|
|
362
|
+
id: `heatmapLayer-${tileIteration}-${i}-${j}`,
|
|
363
|
+
image: tile,
|
|
364
|
+
bounds: [
|
|
365
|
+
matrixLeft + j * tileWidth,
|
|
366
|
+
matrixTop + i * tileHeight,
|
|
367
|
+
matrixLeft + (j + 1) * tileWidth,
|
|
368
|
+
matrixTop + (i + 1) * tileHeight,
|
|
369
|
+
],
|
|
370
|
+
aggSizeX,
|
|
371
|
+
aggSizeY,
|
|
372
|
+
colormap,
|
|
373
|
+
colorScaleLo: colormapRange[0],
|
|
374
|
+
colorScaleHi: colormapRange[1],
|
|
375
|
+
updateTriggers: {
|
|
376
|
+
image: [axisLeftLabels, axisTopLabels],
|
|
377
|
+
bounds: [tileHeight, tileWidth],
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const layers = tilesRef.current.map((tile, index) => getLayer(Math.floor(index / xTiles), index % xTiles, tile));
|
|
382
|
+
return layers;
|
|
383
|
+
}, [expression, backlog.length, transpose, axisTopLabels, axisLeftLabels, yTiles, xTiles,
|
|
384
|
+
paddedExpressions, matrixLeft, tileWidth, matrixTop, tileHeight,
|
|
385
|
+
aggSizeX, aggSizeY, colormap, colormapRange, tileIteration]);
|
|
386
|
+
const axisLeftDashes = (transpose ? variablesDashes : observationsDashes);
|
|
387
|
+
const axisTopDashes = (transpose ? observationsDashes : variablesDashes);
|
|
388
|
+
// Map cell and gene names to arrays with indices,
|
|
389
|
+
// to prepare to render the names in TextLayers.
|
|
390
|
+
const axisTopLabelData = useMemo(() => axisTopLabels.map((d, i) => [i, (axisTopDashes ? `- ${d}` : d)]), [axisTopLabels, axisTopDashes]);
|
|
391
|
+
const axisLeftLabelData = useMemo(() => axisLeftLabels.map((d, i) => [i, (axisLeftDashes ? `${d} -` : d)]), [axisLeftLabels, axisLeftDashes]);
|
|
392
|
+
const cellColorLabelsData = useMemo(() => cellColorLabels.map((d, i) => [i, d && (transpose ? `${d} -` : `- ${d}`)]), [cellColorLabels, transpose]);
|
|
393
|
+
const hideTopLabels = (transpose ? hideObservationLabels : hideVariableLabels);
|
|
394
|
+
const hideLeftLabels = (transpose ? hideVariableLabels : hideObservationLabels);
|
|
395
|
+
// Generate the axis label, axis title, and loading indicator text layers.
|
|
396
|
+
const textLayers = [
|
|
397
|
+
new HeatmapCompositeTextLayer({
|
|
398
|
+
axis: 'left',
|
|
399
|
+
id: 'axisLeftCompositeTextLayer',
|
|
400
|
+
targetX,
|
|
401
|
+
targetY,
|
|
402
|
+
scaleFactor,
|
|
403
|
+
axisLeftLabelData,
|
|
404
|
+
matrixTop,
|
|
405
|
+
height,
|
|
406
|
+
matrixHeight,
|
|
407
|
+
cellHeight,
|
|
408
|
+
cellWidth,
|
|
409
|
+
axisTopLabelData,
|
|
410
|
+
matrixLeft,
|
|
411
|
+
width,
|
|
412
|
+
matrixWidth,
|
|
413
|
+
viewHeight,
|
|
414
|
+
viewWidth,
|
|
415
|
+
theme,
|
|
416
|
+
axisLeftTitle,
|
|
417
|
+
axisTopTitle,
|
|
418
|
+
axisOffsetLeft,
|
|
419
|
+
axisOffsetTop,
|
|
420
|
+
hideTopLabels,
|
|
421
|
+
hideLeftLabels,
|
|
422
|
+
transpose,
|
|
423
|
+
}),
|
|
424
|
+
new HeatmapCompositeTextLayer({
|
|
425
|
+
axis: 'top',
|
|
426
|
+
id: 'axisTopCompositeTextLayer',
|
|
427
|
+
targetX,
|
|
428
|
+
targetY,
|
|
429
|
+
scaleFactor,
|
|
430
|
+
axisLeftLabelData,
|
|
431
|
+
matrixTop,
|
|
432
|
+
height,
|
|
433
|
+
matrixHeight,
|
|
434
|
+
cellHeight,
|
|
435
|
+
cellWidth,
|
|
436
|
+
axisTopLabelData,
|
|
437
|
+
matrixLeft,
|
|
438
|
+
width,
|
|
439
|
+
matrixWidth,
|
|
440
|
+
viewHeight,
|
|
441
|
+
viewWidth,
|
|
442
|
+
theme,
|
|
443
|
+
axisLeftTitle,
|
|
444
|
+
axisTopTitle,
|
|
445
|
+
axisOffsetLeft,
|
|
446
|
+
axisOffsetTop,
|
|
447
|
+
cellColorLabelsData,
|
|
448
|
+
hideTopLabels,
|
|
449
|
+
hideLeftLabels,
|
|
450
|
+
transpose,
|
|
451
|
+
}),
|
|
452
|
+
new HeatmapCompositeTextLayer({
|
|
453
|
+
axis: 'corner',
|
|
454
|
+
id: 'cellColorLabelCompositeTextLayer',
|
|
455
|
+
targetX,
|
|
456
|
+
targetY,
|
|
457
|
+
scaleFactor,
|
|
458
|
+
axisLeftLabelData,
|
|
459
|
+
matrixTop,
|
|
460
|
+
height,
|
|
461
|
+
matrixHeight,
|
|
462
|
+
cellHeight,
|
|
463
|
+
cellWidth,
|
|
464
|
+
axisTopLabelData,
|
|
465
|
+
matrixLeft,
|
|
466
|
+
width,
|
|
467
|
+
matrixWidth,
|
|
468
|
+
viewHeight,
|
|
469
|
+
viewWidth,
|
|
470
|
+
theme,
|
|
471
|
+
axisLeftTitle,
|
|
472
|
+
axisTopTitle,
|
|
473
|
+
axisOffsetLeft,
|
|
474
|
+
axisOffsetTop,
|
|
475
|
+
cellColorLabelsData,
|
|
476
|
+
hideTopLabels,
|
|
477
|
+
hideLeftLabels,
|
|
478
|
+
transpose,
|
|
479
|
+
}),
|
|
480
|
+
];
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
setNumCellColorTracks(cellColorLabels.length);
|
|
483
|
+
}, [cellColorLabels]);
|
|
484
|
+
// Create the left color bar with a BitmapLayer.
|
|
485
|
+
// TODO: find a way to do aggregation for this as well.
|
|
486
|
+
const cellColorsTilesList = useMemo(() => {
|
|
487
|
+
if (!cellColors) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
let cellId;
|
|
491
|
+
let offset;
|
|
492
|
+
let color;
|
|
493
|
+
let rowI;
|
|
494
|
+
const cellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
|
|
495
|
+
const colorBarTileWidthPx = (transpose ? TILE_SIZE : 1);
|
|
496
|
+
const colorBarTileHeightPx = (transpose ? 1 : TILE_SIZE);
|
|
497
|
+
const result = range(numCellColorTracks).map((track) => {
|
|
498
|
+
const trackResult = range((transpose ? xTiles : yTiles)).map((i) => {
|
|
499
|
+
const tileData = new Uint8ClampedArray(TILE_SIZE * 1 * 4);
|
|
500
|
+
range(TILE_SIZE).forEach((tileY) => {
|
|
501
|
+
rowI = (i * TILE_SIZE) + tileY; // the row / cell index
|
|
502
|
+
if (rowI < cellOrdering.length) {
|
|
503
|
+
cellId = cellOrdering[rowI];
|
|
504
|
+
color = cellColors.get(cellId);
|
|
505
|
+
offset = (transpose ? tileY : (TILE_SIZE - tileY - 1)) * 4;
|
|
506
|
+
if (color) {
|
|
507
|
+
// allows color to be [R, G, B] or array of arrays of [R, G, B]
|
|
508
|
+
if (typeof color[0] !== 'number')
|
|
509
|
+
color = color[track] ?? getDefaultColor(theme);
|
|
510
|
+
const [rValue, gValue, bValue] = color;
|
|
511
|
+
tileData[offset + 0] = rValue;
|
|
512
|
+
tileData[offset + 1] = gValue;
|
|
513
|
+
tileData[offset + 2] = bValue;
|
|
514
|
+
tileData[offset + 3] = 255;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
return new ImageData(tileData, colorBarTileWidthPx, colorBarTileHeightPx);
|
|
519
|
+
});
|
|
520
|
+
return trackResult;
|
|
521
|
+
});
|
|
522
|
+
return result;
|
|
523
|
+
}, [cellColors, transpose, axisTopLabels, axisLeftLabels,
|
|
524
|
+
numCellColorTracks, xTiles, yTiles, theme]);
|
|
525
|
+
const cellColorsLayersList = useMemo(() => {
|
|
526
|
+
if (!cellColorsTilesList) {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
const result = cellColorsTilesList.map((cellColorsTiles, track) => (cellColorsTiles
|
|
530
|
+
? cellColorsTiles.map((tile, i) => new PixelatedBitmapLayer({
|
|
531
|
+
id: `${(transpose ? 'colorsTopLayer' : 'colorsLeftLayer')}-${track}-${i}-${uuidv4()}`,
|
|
532
|
+
image: tile,
|
|
533
|
+
bounds: (transpose ? [
|
|
534
|
+
matrixLeft + i * tileWidth,
|
|
535
|
+
-matrixHeight / 2,
|
|
536
|
+
matrixLeft + (i + 1) * tileWidth,
|
|
537
|
+
matrixHeight / 2,
|
|
538
|
+
] : [
|
|
539
|
+
-matrixWidth / 2,
|
|
540
|
+
matrixTop + i * tileHeight,
|
|
541
|
+
matrixWidth / 2,
|
|
542
|
+
matrixTop + (i + 1) * tileHeight,
|
|
543
|
+
]),
|
|
544
|
+
}))
|
|
545
|
+
: []));
|
|
546
|
+
return (result);
|
|
547
|
+
}, [cellColorsTilesList, matrixTop, matrixLeft, matrixHeight,
|
|
548
|
+
matrixWidth, tileWidth, tileHeight, transpose]);
|
|
549
|
+
const layers = heatmapLayers
|
|
550
|
+
.concat(textLayers)
|
|
551
|
+
.concat(...cellColorsLayersList);
|
|
552
|
+
// Set up the onHover function.
|
|
553
|
+
function onHover(info, event) {
|
|
554
|
+
if (!expression) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const { x: mouseX, y: mouseY } = event.offsetCenter;
|
|
558
|
+
const [trackColI, trackI] = mouseToCellColorPosition(mouseX, mouseY, {
|
|
559
|
+
axisOffsetTop,
|
|
560
|
+
axisOffsetLeft,
|
|
561
|
+
offsetTop,
|
|
562
|
+
offsetLeft,
|
|
563
|
+
colorBarSize: COLOR_BAR_SIZE,
|
|
564
|
+
numCellColorTracks,
|
|
565
|
+
transpose,
|
|
566
|
+
targetX,
|
|
567
|
+
targetY,
|
|
568
|
+
scaleFactor,
|
|
569
|
+
matrixWidth,
|
|
570
|
+
matrixHeight,
|
|
571
|
+
numRows: height,
|
|
572
|
+
numCols: width,
|
|
573
|
+
});
|
|
574
|
+
if (trackI === null || trackColI === null) {
|
|
575
|
+
setTrackHighlight(null);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
const obsI = expression.rows.indexOf(axisTopLabels[trackColI]);
|
|
579
|
+
const cellIndex = expression.rows[obsI];
|
|
580
|
+
setTrackHighlight([cellIndex, trackI, mouseX, mouseY]);
|
|
581
|
+
}
|
|
582
|
+
const [colI, rowI] = mouseToHeatmapPosition(mouseX, mouseY, {
|
|
583
|
+
offsetLeft,
|
|
584
|
+
offsetTop,
|
|
585
|
+
targetX,
|
|
586
|
+
targetY,
|
|
587
|
+
scaleFactor,
|
|
588
|
+
matrixWidth,
|
|
589
|
+
matrixHeight,
|
|
590
|
+
numRows: height,
|
|
591
|
+
numCols: width,
|
|
592
|
+
});
|
|
593
|
+
if (colI === null) {
|
|
594
|
+
if (transpose) {
|
|
595
|
+
setCellHighlight(null);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
setGeneHighlight(null);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (rowI === null) {
|
|
602
|
+
if (transpose) {
|
|
603
|
+
setGeneHighlight(null);
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
setCellHighlight(null);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const obsI = expression.rows.indexOf(transpose
|
|
610
|
+
? axisTopLabels[colI]
|
|
611
|
+
: axisLeftLabels[rowI]);
|
|
612
|
+
const varI = expression.cols.indexOf(transpose
|
|
613
|
+
? axisLeftLabels[rowI]
|
|
614
|
+
: axisTopLabels[colI]);
|
|
615
|
+
const obsId = expression.rows[obsI];
|
|
616
|
+
const varId = expression.cols[varI];
|
|
617
|
+
if (setComponentHover) {
|
|
618
|
+
setComponentHover();
|
|
619
|
+
}
|
|
620
|
+
setCellHighlight(obsId || null);
|
|
621
|
+
setGeneHighlight(varId || null);
|
|
622
|
+
}
|
|
623
|
+
const cellColorsViews = useMemo(() => {
|
|
624
|
+
const result = range(numCellColorTracks).map((track) => {
|
|
625
|
+
let view;
|
|
626
|
+
if (transpose) {
|
|
627
|
+
view = new deck.OrthographicView({
|
|
628
|
+
id: `colorsTop-${track}`,
|
|
629
|
+
controller: true,
|
|
630
|
+
x: offsetLeft,
|
|
631
|
+
y: axisOffsetTop + track * COLOR_BAR_SIZE,
|
|
632
|
+
width: matrixWidth,
|
|
633
|
+
height: COLOR_BAR_SIZE - AXIS_MARGIN,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
view = new deck.OrthographicView({
|
|
638
|
+
id: `colorsLeft-${track}`,
|
|
639
|
+
controller: true,
|
|
640
|
+
x: axisOffsetLeft + track * COLOR_BAR_SIZE,
|
|
641
|
+
y: offsetTop,
|
|
642
|
+
width: COLOR_BAR_SIZE - AXIS_MARGIN,
|
|
643
|
+
height: matrixHeight,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return view;
|
|
647
|
+
});
|
|
648
|
+
return result;
|
|
649
|
+
}, [numCellColorTracks, transpose, offsetLeft, axisOffsetTop,
|
|
650
|
+
matrixWidth, axisOffsetLeft, offsetTop, matrixHeight]);
|
|
651
|
+
return (_jsx(deck.DeckGL, { id: `deckgl-overlay-${uuid}`, ref: deckRef, onWebGLInitialized: setGlContext, views: [
|
|
652
|
+
// Note that there are multiple views here,
|
|
653
|
+
// but only one viewState.
|
|
654
|
+
new deck.OrthographicView({
|
|
655
|
+
id: 'heatmap',
|
|
656
|
+
controller: true,
|
|
657
|
+
x: offsetLeft,
|
|
658
|
+
y: offsetTop,
|
|
659
|
+
width: matrixWidth,
|
|
660
|
+
height: matrixHeight,
|
|
661
|
+
}),
|
|
662
|
+
new deck.OrthographicView({
|
|
663
|
+
id: 'axisLeft',
|
|
664
|
+
controller: false,
|
|
665
|
+
x: 0,
|
|
666
|
+
y: offsetTop,
|
|
667
|
+
width: axisOffsetLeft,
|
|
668
|
+
height: matrixHeight,
|
|
669
|
+
}),
|
|
670
|
+
new deck.OrthographicView({
|
|
671
|
+
id: 'axisTop',
|
|
672
|
+
controller: false,
|
|
673
|
+
x: offsetLeft,
|
|
674
|
+
y: 0,
|
|
675
|
+
width: matrixWidth,
|
|
676
|
+
height: axisOffsetTop,
|
|
677
|
+
}),
|
|
678
|
+
new deck.OrthographicView({
|
|
679
|
+
id: 'cellColorLabel',
|
|
680
|
+
controller: false,
|
|
681
|
+
x: (transpose ? 0 : axisOffsetLeft),
|
|
682
|
+
y: (transpose ? axisOffsetTop : 0),
|
|
683
|
+
width: (transpose ? axisOffsetLeft : COLOR_BAR_SIZE * numCellColorTracks),
|
|
684
|
+
height: (transpose ? COLOR_BAR_SIZE * numCellColorTracks : axisOffsetTop),
|
|
685
|
+
}),
|
|
686
|
+
...cellColorsViews,
|
|
687
|
+
], layers: layers, layerFilter: layerFilter, getCursor: interactionState => (interactionState.isDragging ? 'grabbing' : 'default'), glOptions: DEFAULT_GL_OPTIONS, onViewStateChange: onViewStateChange, viewState: viewState, onHover: onHover, useDevicePixels: useDevicePixels }));
|
|
688
|
+
});
|
|
689
|
+
export default Heatmap;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const expressionMatrix = {
|
|
2
|
+
rows: ['cell-0', 'cell-1', 'cell-2', 'cell-3', 'cell-4'],
|
|
3
|
+
cols: ['gene-0', 'gene-1', 'gene-2', 'gene-3'],
|
|
4
|
+
// "An image with an 'F' on it has a clear direction so it's easy to tell
|
|
5
|
+
// if it's turned or flipped etc when we use it as a texture."
|
|
6
|
+
// - https://webglfundamentals.org/webgl/lessons/webgl-3d-textures.html
|
|
7
|
+
matrix: Uint8Array.from([
|
|
8
|
+
0, 255, 255, 0,
|
|
9
|
+
0, 255, 0, 0,
|
|
10
|
+
0, 255, 255, 0,
|
|
11
|
+
0, 255, 0, 0,
|
|
12
|
+
0, 255, 0, 0,
|
|
13
|
+
]),
|
|
14
|
+
};
|
|
15
|
+
export const cellColors = new Map([
|
|
16
|
+
['cell-1', [0, 0, 255]],
|
|
17
|
+
['cell-0', [0, 0, 255]],
|
|
18
|
+
['cell-3', [255, 0, 0]],
|
|
19
|
+
['cell-2', [255, 0, 0]],
|
|
20
|
+
]);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable func-names */
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { cleanup, render } from '@testing-library/react';
|
|
5
|
+
import { afterEach } from 'vitest';
|
|
6
|
+
import Heatmap from './Heatmap';
|
|
7
|
+
import { expressionMatrix, cellColors } from './Heatmap.test.fixtures';
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup();
|
|
10
|
+
});
|
|
11
|
+
describe('<Heatmap/>', () => {
|
|
12
|
+
it('renders a DeckGL element', function () {
|
|
13
|
+
const { container } = render(_jsx(Heatmap, { uuid: "heatmap-0", theme: "dark", width: 100, height: 100, colormap: "plasma", colormapRange: [0.0, 1.0], expressionMatrix: expressionMatrix, cellColors: cellColors, transpose: true, viewState: { zoom: 0, target: [0, 0] } }));
|
|
14
|
+
expect(container.querySelectorAll('#deckgl-overlay-heatmap-0-wrapper').length).toEqual(1);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback } from 'react';
|
|
3
|
+
import debounce from 'lodash/debounce';
|
|
4
|
+
import Slider from '@material-ui/core/Slider';
|
|
5
|
+
import TableCell from '@material-ui/core/TableCell';
|
|
6
|
+
import TableRow from '@material-ui/core/TableRow';
|
|
7
|
+
import { usePlotOptionsStyles, OptionsContainer, OptionSelect } from '@vitessce/vit-s';
|
|
8
|
+
import { GLSL_COLORMAPS } from '@vitessce/gl';
|
|
9
|
+
export default function HeatmapOptions(props) {
|
|
10
|
+
const { geneExpressionColormap, setGeneExpressionColormap, geneExpressionColormapRange, setGeneExpressionColormapRange, } = props;
|
|
11
|
+
const classes = usePlotOptionsStyles();
|
|
12
|
+
function handleGeneExpressionColormapChange(event) {
|
|
13
|
+
setGeneExpressionColormap(event.target.value);
|
|
14
|
+
}
|
|
15
|
+
function handleColormapRangeChange(event, value) {
|
|
16
|
+
setGeneExpressionColormapRange(value);
|
|
17
|
+
}
|
|
18
|
+
const handleColormapRangeChangeDebounced = useCallback(debounce(handleColormapRangeChange, 5, { trailing: true }), [handleColormapRangeChange]);
|
|
19
|
+
return (_jsxs(OptionsContainer, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { className: classes.labelCell, htmlFor: "gene-expression-colormap-select", children: "Gene Expression Colormap" }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(OptionSelect, { className: classes.select, value: geneExpressionColormap, onChange: handleGeneExpressionColormapChange, inputProps: {
|
|
20
|
+
id: 'gene-expression-colormap-select',
|
|
21
|
+
}, children: GLSL_COLORMAPS.map(cmap => (_jsx("option", { value: cmap, children: cmap }, cmap))) }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { className: classes.labelCell, children: "Gene Expression Colormap Range" }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Slider, { classes: { root: classes.slider, valueLabel: classes.sliderValueLabel }, value: geneExpressionColormapRange, onChange: handleColormapRangeChangeDebounced, "aria-labelledby": "gene-expression-colormap-range-slider", valueLabelDisplay: "auto", step: 0.005, min: 0.0, max: 1.0 }) })] })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useCallback, useMemo, } from 'react';
|
|
3
|
+
import plur from 'plur';
|
|
4
|
+
import { TitleInfo, useDeckCanvasSize, useGetObsInfo, useReady, useUrls, useObsSetsData, useObsFeatureMatrixData, useMultiObsLabels, useFeatureLabelsData, useCoordination, useLoaders, useSetComponentHover, useSetComponentViewInfo, registerPluginViewType, } from '@vitessce/vit-s';
|
|
5
|
+
import { capitalize, commaNumber, getCellColors } from '@vitessce/utils';
|
|
6
|
+
import { mergeObsSets } from '@vitessce/sets';
|
|
7
|
+
import { COMPONENT_COORDINATION_TYPES, ViewType } from '@vitessce/constants-internal';
|
|
8
|
+
import Heatmap from './Heatmap';
|
|
9
|
+
import HeatmapTooltipSubscriber from './HeatmapTooltipSubscriber';
|
|
10
|
+
import HeatmapOptions from './HeatmapOptions';
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} props
|
|
13
|
+
* @param {number} props.uuid The unique identifier for this component.
|
|
14
|
+
* @param {object} props.coordinationScopes The mapping from coordination types to coordination
|
|
15
|
+
* scopes.
|
|
16
|
+
* @param {function} props.removeGridComponent The callback function to pass to TitleInfo,
|
|
17
|
+
* to call when the component has been removed from the grid.
|
|
18
|
+
* @param {string} props.title The component title.
|
|
19
|
+
* @param {boolean} props.transpose Whether to
|
|
20
|
+
* render as cell-by-gene or gene-by-cell.
|
|
21
|
+
* @param {boolean} props.disableTooltip Whether to disable the
|
|
22
|
+
* tooltip on mouse hover.
|
|
23
|
+
*/
|
|
24
|
+
export function HeatmapSubscriber(props) {
|
|
25
|
+
const { uuid, coordinationScopes, removeGridComponent, theme, transpose, observationsLabelOverride, variablesLabelOverride, disableTooltip = false, title = 'Heatmap', } = props;
|
|
26
|
+
const loaders = useLoaders();
|
|
27
|
+
const setComponentHover = useSetComponentHover();
|
|
28
|
+
const setComponentViewInfo = useSetComponentViewInfo(uuid);
|
|
29
|
+
// Get "props" from the coordination space.
|
|
30
|
+
const [{ dataset, obsType, featureType, featureValueType, heatmapZoomX: zoomX, heatmapTargetX: targetX, heatmapTargetY: targetY, featureSelection: geneSelection, obsHighlight: cellHighlight, featureHighlight: geneHighlight, obsSetSelection: cellSetSelection, obsSetColor: cellSetColor, additionalObsSets: additionalCellSets, featureValueColormap: geneExpressionColormap, featureValueColormapRange: geneExpressionColormapRange, }, { setHeatmapZoomX: setZoomX, setHeatmapZoomY: setZoomY, setHeatmapTargetX: setTargetX, setHeatmapTargetY: setTargetY, setObsHighlight: setCellHighlight, setFeatureHighlight: setGeneHighlight, setObsSetSelection: setCellSetSelection, setObsSetColor: setCellSetColor, setFeatureValueColormapRange: setGeneExpressionColormapRange, setFeatureValueColormap: setGeneExpressionColormap, }] = useCoordination(COMPONENT_COORDINATION_TYPES[ViewType.HEATMAP], coordinationScopes);
|
|
31
|
+
const observationsLabel = observationsLabelOverride || obsType;
|
|
32
|
+
const observationsPluralLabel = plur(observationsLabel);
|
|
33
|
+
const variablesLabel = variablesLabelOverride || featureType;
|
|
34
|
+
const variablesPluralLabel = plur(variablesLabel);
|
|
35
|
+
const observationsTitle = capitalize(observationsPluralLabel);
|
|
36
|
+
const variablesTitle = capitalize(variablesPluralLabel);
|
|
37
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
38
|
+
const [urls, addUrl] = useUrls(loaders, dataset);
|
|
39
|
+
const [width, height, deckRef] = useDeckCanvasSize();
|
|
40
|
+
// Get data from loaders using the data hooks.
|
|
41
|
+
const [obsLabelsTypes, obsLabelsData] = useMultiObsLabels(coordinationScopes, obsType, loaders, dataset, addUrl);
|
|
42
|
+
// TODO: support multiple feature labels using featureLabelsType coordination values.
|
|
43
|
+
const [{ featureLabelsMap }, featureLabelsStatus] = useFeatureLabelsData(loaders, dataset, addUrl, false, {}, {}, { featureType });
|
|
44
|
+
const [{ obsIndex, featureIndex, obsFeatureMatrix }, matrixStatus] = useObsFeatureMatrixData(loaders, dataset, addUrl, true, {}, {}, { obsType, featureType, featureValueType });
|
|
45
|
+
const [{ obsSets: cellSets, obsSetsMembership }, obsSetsStatus] = useObsSetsData(loaders, dataset, addUrl, false, { setObsSetSelection: setCellSetSelection, setObsSetColor: setCellSetColor }, { obsSetSelection: cellSetSelection, obsSetColor: cellSetColor }, { obsType });
|
|
46
|
+
const isReady = useReady([
|
|
47
|
+
featureLabelsStatus,
|
|
48
|
+
matrixStatus,
|
|
49
|
+
obsSetsStatus,
|
|
50
|
+
]);
|
|
51
|
+
const mergedCellSets = useMemo(() => mergeObsSets(cellSets, additionalCellSets), [cellSets, additionalCellSets]);
|
|
52
|
+
const cellColors = useMemo(() => getCellColors({
|
|
53
|
+
// Only show cell set selection on heatmap labels.
|
|
54
|
+
cellColorEncoding: 'cellSetSelection',
|
|
55
|
+
geneSelection,
|
|
56
|
+
cellSets: mergedCellSets,
|
|
57
|
+
cellSetSelection,
|
|
58
|
+
cellSetColor,
|
|
59
|
+
obsIndex,
|
|
60
|
+
theme,
|
|
61
|
+
}), [mergedCellSets, geneSelection, theme,
|
|
62
|
+
cellSetColor, cellSetSelection, obsIndex]);
|
|
63
|
+
const getObsInfo = useGetObsInfo(observationsLabel, obsLabelsTypes, obsLabelsData, obsSetsMembership);
|
|
64
|
+
const getFeatureInfo = useCallback((featureId) => {
|
|
65
|
+
if (featureId) {
|
|
66
|
+
return { [`${capitalize(variablesLabel)} ID`]: featureId };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}, [variablesLabel]);
|
|
70
|
+
const expressionMatrix = useMemo(() => {
|
|
71
|
+
if (obsIndex && featureIndex && obsFeatureMatrix) {
|
|
72
|
+
return {
|
|
73
|
+
rows: obsIndex,
|
|
74
|
+
cols: (featureLabelsMap
|
|
75
|
+
? featureIndex.map(key => featureLabelsMap.get(key) || key)
|
|
76
|
+
: featureIndex),
|
|
77
|
+
matrix: obsFeatureMatrix.data,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}, [obsIndex, featureIndex, obsFeatureMatrix, featureLabelsMap]);
|
|
82
|
+
const cellsCount = obsIndex ? obsIndex.length : 0;
|
|
83
|
+
const genesCount = featureIndex ? featureIndex.length : 0;
|
|
84
|
+
const setTrackHighlight = useCallback(() => {
|
|
85
|
+
// No-op, since the default handler
|
|
86
|
+
// logs in the console on every hover event.
|
|
87
|
+
}, []);
|
|
88
|
+
const cellColorLabels = useMemo(() => ([
|
|
89
|
+
`${capitalize(observationsLabel)} Set`,
|
|
90
|
+
]), [observationsLabel]);
|
|
91
|
+
const selectedCount = cellColors.size;
|
|
92
|
+
return (_jsxs(TitleInfo, { title: title, info: `${commaNumber(cellsCount)} ${plur(observationsLabel, cellsCount)} × ${commaNumber(genesCount)} ${plur(variablesLabel, genesCount)},
|
|
93
|
+
with ${commaNumber(selectedCount)} ${plur(observationsLabel, selectedCount)} selected`, urls: urls, theme: theme, removeGridComponent: removeGridComponent, isReady: isReady && !isRendering, options: (_jsx(HeatmapOptions, { geneExpressionColormap: geneExpressionColormap, setGeneExpressionColormap: setGeneExpressionColormap, geneExpressionColormapRange: geneExpressionColormapRange, setGeneExpressionColormapRange: setGeneExpressionColormapRange })), children: [_jsx(Heatmap, { ref: deckRef, transpose: transpose, viewState: { zoom: zoomX, target: [targetX, targetY] }, setViewState: ({ zoom, target }) => {
|
|
94
|
+
setZoomX(zoom);
|
|
95
|
+
setZoomY(zoom);
|
|
96
|
+
setTargetX(target[0]);
|
|
97
|
+
setTargetY(target[1]);
|
|
98
|
+
}, colormapRange: geneExpressionColormapRange, setColormapRange: setGeneExpressionColormapRange, height: height, width: width, theme: theme, uuid: uuid, expressionMatrix: expressionMatrix, cellColors: cellColors, colormap: geneExpressionColormap, setIsRendering: setIsRendering, setCellHighlight: setCellHighlight, setGeneHighlight: setGeneHighlight, setTrackHighlight: setTrackHighlight, setComponentHover: () => {
|
|
99
|
+
setComponentHover(uuid);
|
|
100
|
+
}, updateViewInfo: setComponentViewInfo, observationsTitle: observationsTitle, variablesTitle: variablesTitle, variablesDashes: false, observationsDashes: false, cellColorLabels: cellColorLabels, useDevicePixels: true }), !disableTooltip && (_jsx(HeatmapTooltipSubscriber, { parentUuid: uuid, width: width, height: height, transpose: transpose, getObsInfo: getObsInfo, getFeatureInfo: getFeatureInfo, obsHighlight: cellHighlight, featureHighlight: geneHighlight }))] }));
|
|
101
|
+
}
|
|
102
|
+
export function register() {
|
|
103
|
+
registerPluginViewType(ViewType.HEATMAP, HeatmapSubscriber, COMPONENT_COORDINATION_TYPES[ViewType.HEATMAP]);
|
|
104
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Tooltip2D, TooltipContent } from '@vitessce/tooltip';
|
|
4
|
+
import { useComponentHover, useComponentViewInfo } from '@vitessce/vit-s';
|
|
5
|
+
export default function HeatmapTooltipSubscriber(props) {
|
|
6
|
+
const { parentUuid, width, height, transpose, getObsInfo, getFeatureInfo, obsHighlight, featureHighlight, } = props;
|
|
7
|
+
const sourceUuid = useComponentHover();
|
|
8
|
+
const viewInfo = useComponentViewInfo(parentUuid);
|
|
9
|
+
const [cellInfo, cellCoord] = (obsHighlight && getObsInfo ? ([
|
|
10
|
+
getObsInfo(obsHighlight),
|
|
11
|
+
(viewInfo && viewInfo.project
|
|
12
|
+
? viewInfo.project(obsHighlight, null)[(transpose ? 0 : 1)]
|
|
13
|
+
: null),
|
|
14
|
+
]) : ([null, null]));
|
|
15
|
+
const [geneInfo, geneCoord] = (featureHighlight && getFeatureInfo ? ([
|
|
16
|
+
getFeatureInfo(featureHighlight),
|
|
17
|
+
(viewInfo && viewInfo.project
|
|
18
|
+
? viewInfo.project(null, featureHighlight)[(transpose ? 1 : 0)]
|
|
19
|
+
: null),
|
|
20
|
+
]) : ([null, null]));
|
|
21
|
+
const x = (transpose ? cellCoord : geneCoord);
|
|
22
|
+
const y = (transpose ? geneCoord : cellCoord);
|
|
23
|
+
return ((cellInfo || geneInfo ? (_jsx(Tooltip2D, { x: x, y: y, parentUuid: parentUuid, parentWidth: width, parentHeight: height, sourceUuid: sourceUuid, children: _jsx(TooltipContent, { info: { ...geneInfo, ...cellInfo } }) })) : null));
|
|
24
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { HeatmapWorker } from '@vitessce/workers';
|
|
2
|
+
import { Pool } from '@vitessce/utils';
|
|
3
|
+
// Reference: https://github.com/developit/jsdom-worker/issues/14#issuecomment-1268070123
|
|
4
|
+
function createWorker() {
|
|
5
|
+
return new HeatmapWorker();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Pool for workers to decode chunks of the images.
|
|
9
|
+
* This is a line-for-line copy of GeoTIFFs old implementation: https://github.com/geotiffjs/geotiff.js/blob/v1.0.0-beta.6/src/pool.js
|
|
10
|
+
*/
|
|
11
|
+
export default class HeatmapPool extends Pool {
|
|
12
|
+
constructor() {
|
|
13
|
+
super(createWorker);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Process each heatmap tile
|
|
17
|
+
* @param {object} params The arguments passed to the heatmap worker.
|
|
18
|
+
* @param {string} params.curr The current task uuid.
|
|
19
|
+
* @param {number} params.xTiles How many tiles required in the x direction?
|
|
20
|
+
* @param {number} params.yTiles How many tiles required in the y direction?
|
|
21
|
+
* @param {number} params.tileSize How many entries along each tile axis?
|
|
22
|
+
* @param {string[]} params.cellOrdering The current ordering of cells.
|
|
23
|
+
* @param {string[]} params.rows The name of each row (cell ID).
|
|
24
|
+
* Does not take transpose into account (always cells).
|
|
25
|
+
* @param {string[]} params.cols The name of each column (gene ID).
|
|
26
|
+
* Does not take transpose into account (always genes).
|
|
27
|
+
* @param {ArrayBuffer} params.data The array buffer.
|
|
28
|
+
* Need to transfer back to main thread when done.
|
|
29
|
+
* @param {boolean} params.transpose Is the heatmap transposed?
|
|
30
|
+
* @returns {array} [message, transfers]
|
|
31
|
+
* @returns {Promise.<ArrayBuffer>} the decoded result as a `Promise`
|
|
32
|
+
*/
|
|
33
|
+
async process(args) {
|
|
34
|
+
const currentWorker = await this.waitForWorker();
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
currentWorker.onmessage = (event) => {
|
|
37
|
+
// this.workers.push(currentWorker);
|
|
38
|
+
this.finishTask(currentWorker);
|
|
39
|
+
resolve(event.data);
|
|
40
|
+
};
|
|
41
|
+
currentWorker.onerror = (error) => {
|
|
42
|
+
// this.workers.push(currentWorker);
|
|
43
|
+
this.finishTask(currentWorker);
|
|
44
|
+
reject(error);
|
|
45
|
+
};
|
|
46
|
+
currentWorker.postMessage(['getTile', args], [args.data]);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-globals */
|
|
2
|
+
import { getCellByGeneTile, getGeneByCellTile } from './utils';
|
|
3
|
+
/**
|
|
4
|
+
* Map a gene expression matrix onto multiple square array tiles,
|
|
5
|
+
* taking into account the ordering/selection of cells.
|
|
6
|
+
* @param {object} params
|
|
7
|
+
* @param {string} params.curr The current task uuid.
|
|
8
|
+
* @param {number} params.xTiles How many tiles required in the x direction?
|
|
9
|
+
* @param {number} params.yTiles How many tiles required in the y direction?
|
|
10
|
+
* @param {number} params.tileSize How many entries along each tile axis?
|
|
11
|
+
* @param {string[]} params.cellOrdering The current ordering of cells.
|
|
12
|
+
* @param {string[]} params.cols The name of each column (gene ID).
|
|
13
|
+
* Does not take transpose into account (always genes).
|
|
14
|
+
* @param {ArrayBuffer} params.data The array buffer.
|
|
15
|
+
* Need to transfer back to main thread when done.
|
|
16
|
+
* @param {boolean} params.transpose Is the heatmap transposed?
|
|
17
|
+
* @param {boolean} params.expressionRowLookUp A lookup table for the array index of a given cell.
|
|
18
|
+
* This is needed for performance reasons instead of calling `indexOf` repeatedly.
|
|
19
|
+
* @returns {array} [message, transfers]
|
|
20
|
+
*/
|
|
21
|
+
function getTile({ curr, tileI, tileJ, tileSize, cellOrdering, cols, data, transpose, expressionRowLookUp, }) {
|
|
22
|
+
const view = new Uint8Array(data);
|
|
23
|
+
const numGenes = cols.length;
|
|
24
|
+
const numCells = cellOrdering.length;
|
|
25
|
+
const getTileFunction = (transpose ? getGeneByCellTile : getCellByGeneTile);
|
|
26
|
+
const result = getTileFunction(view, {
|
|
27
|
+
tileSize,
|
|
28
|
+
tileI,
|
|
29
|
+
tileJ,
|
|
30
|
+
numCells,
|
|
31
|
+
numGenes,
|
|
32
|
+
cellOrdering,
|
|
33
|
+
expressionRowLookUp,
|
|
34
|
+
});
|
|
35
|
+
return [{ tile: result, buffer: data, curr }, [data]];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Worker message passing logic.
|
|
39
|
+
*/
|
|
40
|
+
if (typeof self !== 'undefined') {
|
|
41
|
+
const nameToFunction = {
|
|
42
|
+
getTile,
|
|
43
|
+
};
|
|
44
|
+
self.addEventListener('message', (event) => {
|
|
45
|
+
try {
|
|
46
|
+
if (Array.isArray(event.data)) {
|
|
47
|
+
const [name, args] = event.data;
|
|
48
|
+
const [message, transfers] = nameToFunction[name](args);
|
|
49
|
+
self.postMessage(message, transfers);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
console.warn(e);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
package/dist/index.js
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import clamp from 'lodash/clamp';
|
|
2
|
+
import { AXIS_LABEL_TEXT_SIZE, AXIS_FONT_FAMILY, AXIS_PADDING, AXIS_MIN_SIZE, AXIS_MAX_SIZE, } from '@vitessce/gl';
|
|
3
|
+
/**
|
|
4
|
+
* Called before a layer is drawn to determine whether it should be rendered.
|
|
5
|
+
* Reference: https://deck.gl/docs/api-reference/core/deck#layerfilter
|
|
6
|
+
* @param {object} params A viewport, layer pair.
|
|
7
|
+
* @param {object} params.layer The layer to check.
|
|
8
|
+
* @param {object} params.viewport The viewport to check.
|
|
9
|
+
* @returns {boolean} Should this layer be rendered in this viewport?
|
|
10
|
+
*/
|
|
11
|
+
export function layerFilter({ layer, viewport }) {
|
|
12
|
+
if (viewport.id === 'axisLeft') {
|
|
13
|
+
return layer.id.startsWith('axisLeft');
|
|
14
|
+
}
|
|
15
|
+
if (viewport.id === 'axisTop') {
|
|
16
|
+
return layer.id.startsWith('axisTop');
|
|
17
|
+
}
|
|
18
|
+
if (viewport.id === 'cellColorLabel') {
|
|
19
|
+
return layer.id.startsWith('cellColorLabel');
|
|
20
|
+
}
|
|
21
|
+
if (viewport.id === 'heatmap') {
|
|
22
|
+
return layer.id.startsWith('heatmap');
|
|
23
|
+
}
|
|
24
|
+
if (viewport.id.startsWith('colorsLeft')) {
|
|
25
|
+
const matches = viewport.id.match(/-(\d)/);
|
|
26
|
+
if (matches)
|
|
27
|
+
return layer.id.startsWith(`colorsLeftLayer-${matches[1]}`);
|
|
28
|
+
}
|
|
29
|
+
if (viewport.id.startsWith('colorsTop')) {
|
|
30
|
+
const matches = viewport.id.match(/-(\d)/);
|
|
31
|
+
if (matches)
|
|
32
|
+
return layer.id.startsWith(`colorsTopLayer-${matches[1]}`);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Uses canvas.measureText to compute and return the width of the given text
|
|
38
|
+
* of given font in pixels.
|
|
39
|
+
*
|
|
40
|
+
* @param {String} text The text to be rendered.
|
|
41
|
+
* @param {String} font The css font descriptor that text is to be rendered
|
|
42
|
+
* with (e.g. "bold 14px verdana").
|
|
43
|
+
*
|
|
44
|
+
* @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
|
|
45
|
+
*/
|
|
46
|
+
function getTextWidth(text, font) {
|
|
47
|
+
// re-use canvas object for better performance
|
|
48
|
+
const canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement('canvas'));
|
|
49
|
+
const context = canvas.getContext('2d');
|
|
50
|
+
context.font = font;
|
|
51
|
+
const metrics = context.measureText(text);
|
|
52
|
+
return metrics.width;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get the size of the left and top heatmap axes,
|
|
56
|
+
* taking into account the maximum label string lengths.
|
|
57
|
+
* @param {boolean} transpose Is the heatmap transposed?
|
|
58
|
+
* @param {String} longestGeneLabel longest gene label
|
|
59
|
+
* @param {String} longestCellLabel longest cell label
|
|
60
|
+
* @param {boolean} hideObservationLabels are cell labels hidden?
|
|
61
|
+
* @param {boolean} hideVariableLabels are gene labels hidden?
|
|
62
|
+
* Increases vertical space for heatmap
|
|
63
|
+
* @returns {number[]} [axisOffsetLeft, axisOffsetTop]
|
|
64
|
+
*/
|
|
65
|
+
export function getAxisSizes(transpose, longestGeneLabel, longestCellLabel, hideObservationLabels, hideVariableLabels) {
|
|
66
|
+
const font = `${AXIS_LABEL_TEXT_SIZE}pt ${AXIS_FONT_FAMILY}`;
|
|
67
|
+
const geneLabelMaxWidth = hideVariableLabels
|
|
68
|
+
? 0 : getTextWidth(longestGeneLabel, font) + AXIS_PADDING;
|
|
69
|
+
const cellLabelMaxWidth = hideObservationLabels
|
|
70
|
+
? 0 : getTextWidth(longestCellLabel, font) + AXIS_PADDING;
|
|
71
|
+
const axisOffsetLeft = clamp((transpose ? geneLabelMaxWidth : cellLabelMaxWidth), AXIS_MIN_SIZE, AXIS_MAX_SIZE);
|
|
72
|
+
const axisOffsetTop = clamp((transpose ? cellLabelMaxWidth : geneLabelMaxWidth), AXIS_MIN_SIZE, AXIS_MAX_SIZE);
|
|
73
|
+
return [axisOffsetLeft, axisOffsetTop];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Convert a mouse coordinate (x, y) to a heatmap coordinate (col index, row index).
|
|
77
|
+
* @param {number} mouseX The mouse X of interest.
|
|
78
|
+
* @param {number} mouseY The mouse Y of interest.
|
|
79
|
+
* @param {object} param2 An object containing current sizes and scale factors.
|
|
80
|
+
* @returns {number[]} [colI, rowI]
|
|
81
|
+
*/
|
|
82
|
+
export function mouseToHeatmapPosition(mouseX, mouseY, { offsetLeft, offsetTop, targetX, targetY, scaleFactor, matrixWidth, matrixHeight, numRows, numCols, }) {
|
|
83
|
+
// TODO: use linear algebra
|
|
84
|
+
const viewMouseX = mouseX - offsetLeft;
|
|
85
|
+
const viewMouseY = mouseY - offsetTop;
|
|
86
|
+
if (viewMouseX < 0 || viewMouseY < 0) {
|
|
87
|
+
// The mouse is outside the heatmap.
|
|
88
|
+
return [null, null];
|
|
89
|
+
}
|
|
90
|
+
// Determine the rowI and colI values based on the current viewState.
|
|
91
|
+
const bboxTargetX = targetX * scaleFactor + matrixWidth * scaleFactor / 2;
|
|
92
|
+
const bboxTargetY = targetY * scaleFactor + matrixHeight * scaleFactor / 2;
|
|
93
|
+
const bboxLeft = bboxTargetX - matrixWidth / 2;
|
|
94
|
+
const bboxTop = bboxTargetY - matrixHeight / 2;
|
|
95
|
+
const zoomedOffsetLeft = bboxLeft / (matrixWidth * scaleFactor);
|
|
96
|
+
const zoomedOffsetTop = bboxTop / (matrixHeight * scaleFactor);
|
|
97
|
+
const zoomedViewMouseX = viewMouseX / (matrixWidth * scaleFactor);
|
|
98
|
+
const zoomedViewMouseY = viewMouseY / (matrixHeight * scaleFactor);
|
|
99
|
+
const zoomedMouseX = zoomedOffsetLeft + zoomedViewMouseX;
|
|
100
|
+
const zoomedMouseY = zoomedOffsetTop + zoomedViewMouseY;
|
|
101
|
+
const rowI = Math.floor(zoomedMouseY * numRows);
|
|
102
|
+
const colI = Math.floor(zoomedMouseX * numCols);
|
|
103
|
+
return [colI, rowI];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Convert a heatmap coordinate (col index, row index) to a mouse coordinate (x, y).
|
|
107
|
+
* @param {number} colI The column index of interest.
|
|
108
|
+
* @param {number} rowI The row index of interest.
|
|
109
|
+
* @param {object} param2 An object containing current sizes and scale factors.
|
|
110
|
+
* @returns {number[]} [x, y]
|
|
111
|
+
*/
|
|
112
|
+
export function heatmapToMousePosition(colI, rowI, { offsetLeft, offsetTop, targetX, targetY, scaleFactor, matrixWidth, matrixHeight, numRows, numCols, }) {
|
|
113
|
+
// TODO: use linear algebra
|
|
114
|
+
let zoomedMouseY = null;
|
|
115
|
+
let zoomedMouseX = null;
|
|
116
|
+
if (rowI !== null) {
|
|
117
|
+
const minY = -matrixHeight * scaleFactor / 2;
|
|
118
|
+
const maxY = matrixHeight * scaleFactor / 2;
|
|
119
|
+
const totalHeight = maxY - minY;
|
|
120
|
+
const minInViewY = (targetY * scaleFactor) - (matrixHeight / 2);
|
|
121
|
+
const maxInViewY = (targetY * scaleFactor) + (matrixHeight / 2);
|
|
122
|
+
const inViewHeight = maxInViewY - minInViewY;
|
|
123
|
+
const normalizedRowY = (rowI + 0.5) / numRows;
|
|
124
|
+
const globalRowY = minY + (normalizedRowY * totalHeight);
|
|
125
|
+
if (minInViewY <= globalRowY && globalRowY <= maxInViewY) {
|
|
126
|
+
zoomedMouseY = offsetTop + ((globalRowY - minInViewY) / inViewHeight) * matrixHeight;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (colI !== null) {
|
|
130
|
+
const minX = -matrixWidth * scaleFactor / 2;
|
|
131
|
+
const maxX = matrixWidth * scaleFactor / 2;
|
|
132
|
+
const totalWidth = maxX - minX;
|
|
133
|
+
const minInViewX = (targetX * scaleFactor) - (matrixWidth / 2);
|
|
134
|
+
const maxInViewX = (targetX * scaleFactor) + (matrixWidth / 2);
|
|
135
|
+
const inViewWidth = maxInViewX - minInViewX;
|
|
136
|
+
const normalizedRowX = (colI + 0.5) / numCols;
|
|
137
|
+
const globalRowX = minX + (normalizedRowX * totalWidth);
|
|
138
|
+
if (minInViewX <= globalRowX && globalRowX <= maxInViewX) {
|
|
139
|
+
zoomedMouseX = offsetLeft + ((globalRowX - minInViewX) / inViewWidth) * matrixWidth;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return [zoomedMouseX, zoomedMouseY];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Convert a mouse coordinate (x, y) to a heatmap color bar coordinate (cell index, track index).
|
|
146
|
+
* @param {number} mouseX The mouse X of interest.
|
|
147
|
+
* @param {number} mouseY The mouse Y of interest.
|
|
148
|
+
* @param {object} param2 An object containing current sizes and scale factors.
|
|
149
|
+
* @returns {number[]} [cellI, trackI]
|
|
150
|
+
*/
|
|
151
|
+
export function mouseToCellColorPosition(mouseX, mouseY, { axisOffsetTop, axisOffsetLeft, offsetTop, offsetLeft, colorBarSize, numCellColorTracks, transpose, targetX, targetY, scaleFactor, matrixWidth, matrixHeight, numRows, numCols, }) {
|
|
152
|
+
const cellPosition = transpose ? mouseX - offsetLeft : mouseY - offsetTop;
|
|
153
|
+
const trackPosition = transpose ? mouseY - axisOffsetTop : mouseX - axisOffsetLeft;
|
|
154
|
+
const tracksWidth = numCellColorTracks * colorBarSize;
|
|
155
|
+
// outside of cell color tracks
|
|
156
|
+
if (cellPosition < 0 || trackPosition < 0 || trackPosition >= tracksWidth) {
|
|
157
|
+
return [null, null];
|
|
158
|
+
}
|
|
159
|
+
// Determine the trackI and cellI values based on the current viewState.
|
|
160
|
+
const trackI = Math.floor(trackPosition / colorBarSize);
|
|
161
|
+
let cellI;
|
|
162
|
+
if (transpose) {
|
|
163
|
+
const viewMouseX = mouseX - offsetLeft;
|
|
164
|
+
const bboxTargetX = targetX * scaleFactor + matrixWidth * scaleFactor / 2;
|
|
165
|
+
const bboxLeft = bboxTargetX - matrixWidth / 2;
|
|
166
|
+
const zoomedOffsetLeft = bboxLeft / (matrixWidth * scaleFactor);
|
|
167
|
+
const zoomedViewMouseX = viewMouseX / (matrixWidth * scaleFactor);
|
|
168
|
+
const zoomedMouseX = zoomedOffsetLeft + zoomedViewMouseX;
|
|
169
|
+
cellI = Math.floor(zoomedMouseX * numCols);
|
|
170
|
+
return [cellI, trackI];
|
|
171
|
+
}
|
|
172
|
+
// Not transposed
|
|
173
|
+
const viewMouseY = mouseY - axisOffsetTop;
|
|
174
|
+
const bboxTargetY = targetY * scaleFactor + matrixHeight * scaleFactor / 2;
|
|
175
|
+
const bboxTop = bboxTargetY - matrixHeight / 2;
|
|
176
|
+
const zoomedOffsetTop = bboxTop / (matrixHeight * scaleFactor);
|
|
177
|
+
const zoomedViewMouseY = viewMouseY / (matrixHeight * scaleFactor);
|
|
178
|
+
const zoomedMouseY = zoomedOffsetTop + zoomedViewMouseY;
|
|
179
|
+
cellI = Math.floor(zoomedMouseY * numRows);
|
|
180
|
+
return [cellI, trackI];
|
|
181
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mouseToHeatmapPosition, heatmapToMousePosition } from './utils';
|
|
2
|
+
describe('heatmap tooltip utils', () => {
|
|
3
|
+
it('transforms mouse coordinates to row and column indices when zoomed out', () => {
|
|
4
|
+
const mouseX = 35;
|
|
5
|
+
const mouseY = 78;
|
|
6
|
+
const [colI, rowI] = mouseToHeatmapPosition(mouseX, mouseY, {
|
|
7
|
+
offsetLeft: 10,
|
|
8
|
+
offsetTop: 10,
|
|
9
|
+
targetX: 0,
|
|
10
|
+
targetY: 0,
|
|
11
|
+
scaleFactor: 1,
|
|
12
|
+
matrixWidth: 100,
|
|
13
|
+
matrixHeight: 100,
|
|
14
|
+
numRows: 5,
|
|
15
|
+
numCols: 4,
|
|
16
|
+
});
|
|
17
|
+
expect(colI).toEqual(1);
|
|
18
|
+
expect(rowI).toEqual(3);
|
|
19
|
+
});
|
|
20
|
+
it('transforms mouse coordinates to row and column indices when zoomed in', () => {
|
|
21
|
+
const mouseX = 35;
|
|
22
|
+
const mouseY = 78;
|
|
23
|
+
const [colI, rowI] = mouseToHeatmapPosition(mouseX, mouseY, {
|
|
24
|
+
offsetLeft: 10,
|
|
25
|
+
offsetTop: 10,
|
|
26
|
+
targetX: 21,
|
|
27
|
+
targetY: -11,
|
|
28
|
+
scaleFactor: 4,
|
|
29
|
+
matrixWidth: 100,
|
|
30
|
+
matrixHeight: 100,
|
|
31
|
+
numRows: 5,
|
|
32
|
+
numCols: 4,
|
|
33
|
+
});
|
|
34
|
+
expect(colI).toEqual(2);
|
|
35
|
+
expect(rowI).toEqual(2);
|
|
36
|
+
});
|
|
37
|
+
it('transforms row and column indices when zoomed out', () => {
|
|
38
|
+
const colI = 1;
|
|
39
|
+
const rowI = 3;
|
|
40
|
+
const [mouseX, mouseY] = heatmapToMousePosition(colI, rowI, {
|
|
41
|
+
offsetLeft: 10,
|
|
42
|
+
offsetTop: 10,
|
|
43
|
+
targetX: 0,
|
|
44
|
+
targetY: 0,
|
|
45
|
+
scaleFactor: 1,
|
|
46
|
+
matrixWidth: 100,
|
|
47
|
+
matrixHeight: 100,
|
|
48
|
+
numRows: 5,
|
|
49
|
+
numCols: 4,
|
|
50
|
+
});
|
|
51
|
+
expect(mouseX).toEqual(47.5);
|
|
52
|
+
expect(mouseY).toEqual(80);
|
|
53
|
+
});
|
|
54
|
+
it('transforms row and column indices when zoomed in', () => {
|
|
55
|
+
const colI = 2;
|
|
56
|
+
const rowI = 2;
|
|
57
|
+
const [mouseX, mouseY] = heatmapToMousePosition(colI, rowI, {
|
|
58
|
+
offsetLeft: 10,
|
|
59
|
+
offsetTop: 10,
|
|
60
|
+
targetX: 21,
|
|
61
|
+
targetY: -11,
|
|
62
|
+
scaleFactor: 4,
|
|
63
|
+
matrixWidth: 100,
|
|
64
|
+
matrixHeight: 100,
|
|
65
|
+
numRows: 5,
|
|
66
|
+
numCols: 4,
|
|
67
|
+
});
|
|
68
|
+
expect(mouseX).toEqual(26);
|
|
69
|
+
expect(mouseY).toEqual(104);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vitessce/heatmap",
|
|
3
|
+
"version": "2.0.0-beta.0",
|
|
4
|
+
"author": "Gehlenborg Lab",
|
|
5
|
+
"homepage": "http://vitessce.io",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vitessce/vitessce.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@material-ui/core": "~4.12.3",
|
|
17
|
+
"@vitessce/constants-internal": "2.0.0-beta.0",
|
|
18
|
+
"@vitessce/gl": "2.0.0-beta.0",
|
|
19
|
+
"@vitessce/sets": "2.0.0-beta.0",
|
|
20
|
+
"@vitessce/tooltip": "2.0.0-beta.0",
|
|
21
|
+
"@vitessce/utils": "2.0.0-beta.0",
|
|
22
|
+
"@vitessce/vit-s": "2.0.0-beta.0",
|
|
23
|
+
"@vitessce/workers": "2.0.0-beta.0",
|
|
24
|
+
"lodash": "^4.17.21",
|
|
25
|
+
"plur": "^5.1.0",
|
|
26
|
+
"uuid": "^3.3.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"react": "^18.0.0",
|
|
30
|
+
"vite": "^3.0.0",
|
|
31
|
+
"vitest": "^0.23.4"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"start": "tsc --watch",
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "pnpm exec vitest --run -r ../../../ --dir ."
|
|
40
|
+
}
|
|
41
|
+
}
|