@vitessce/scatterplot 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 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.
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ export default function EmptyMessage(props) {
4
+ const { visible, message, } = props;
5
+ return visible ? (_jsx("div", { children: message })) : null;
6
+ }
@@ -0,0 +1,304 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /* eslint-disable no-param-reassign */
3
+ import React, { forwardRef } from 'react';
4
+ import { forceSimulation } from 'd3-force';
5
+ import { deck, getSelectionLayers, ScaledExpressionExtension, SelectionExtension, } from '@vitessce/gl';
6
+ import { getDefaultColor } from '@vitessce/utils';
7
+ import { AbstractSpatialOrScatterplot, createQuadTree, forceCollideRects, getOnHoverCallback, } from './shared-spatial-scatterplot';
8
+ const CELLS_LAYER_ID = 'scatterplot';
9
+ const LABEL_FONT_FAMILY = "-apple-system, 'Helvetica Neue', Arial, sans-serif";
10
+ const NUM_FORCE_SIMULATION_TICKS = 100;
11
+ const LABEL_UPDATE_ZOOM_DELTA = 0.25;
12
+ // Default getter function props.
13
+ const makeDefaultGetCellColors = (cellColors, obsIndex, theme) => (object, { index }) => {
14
+ const [r, g, b, a] = (cellColors && obsIndex && cellColors.get(obsIndex[index]))
15
+ || getDefaultColor(theme);
16
+ return [r, g, b, 255 * (a || 1)];
17
+ };
18
+ const makeDefaultGetObsCoords = obsEmbedding => i => ([
19
+ obsEmbedding.data[0][i],
20
+ obsEmbedding.data[1][i],
21
+ 0,
22
+ ]);
23
+ const makeFlippedGetObsCoords = obsEmbedding => i => ([
24
+ obsEmbedding.data[0][i],
25
+ -obsEmbedding.data[1][i],
26
+ 0,
27
+ ]);
28
+ const getPosition = (object, { index, data, target }) => {
29
+ target[0] = data.src.obsEmbedding.data[0][index];
30
+ target[1] = -data.src.obsEmbedding.data[1][index];
31
+ target[2] = 0;
32
+ return target;
33
+ };
34
+ /**
35
+ * React component which renders a scatterplot from cell data.
36
+ * @param {object} props
37
+ * @param {string} props.uuid A unique identifier for this component.
38
+ * @param {string} props.theme The current vitessce theme.
39
+ * @param {object} props.viewState The deck.gl view state.
40
+ * @param {function} props.setViewState Function to call to update the deck.gl view state.
41
+ * @param {object} props.cells
42
+ * @param {string} props.mapping The name of the coordinate mapping field,
43
+ * for each cell, for example "PCA" or "t-SNE".
44
+ * @param {Map} props.cellColors Mapping of cell IDs to colors.
45
+ * @param {array} props.cellSelection Array of selected cell IDs.
46
+ * @param {array} props.cellFilter Array of filtered cell IDs. By default, null.
47
+ * @param {number} props.cellRadius The value for `radiusScale` to pass
48
+ * to the deck.gl cells ScatterplotLayer.
49
+ * @param {number} props.cellOpacity The value for `opacity` to pass
50
+ * to the deck.gl cells ScatterplotLayer.
51
+ * @param {function} props.getCellCoords Getter function for cell coordinates
52
+ * (used by the selection layer).
53
+ * @param {function} props.getCellPosition Getter function for cell [x, y, z] position.
54
+ * @param {function} props.getCellColor Getter function for cell color as [r, g, b] array.
55
+ * @param {function} props.getExpressionValue Getter function for cell expression value.
56
+ * @param {function} props.getCellIsSelected Getter function for cell layer isSelected.
57
+ * @param {function} props.setCellSelection
58
+ * @param {function} props.setCellHighlight
59
+ * @param {function} props.updateViewInfo
60
+ * @param {function} props.onToolChange Callback for tool changes
61
+ * (lasso/pan/rectangle selection tools).
62
+ * @param {function} props.onCellClick Getter function for cell layer onClick.
63
+ */
64
+ class Scatterplot extends AbstractSpatialOrScatterplot {
65
+ constructor(props) {
66
+ super(props);
67
+ // To avoid storing large arrays/objects
68
+ // in React state, this component
69
+ // uses instance variables.
70
+ // All instance variables used in this class:
71
+ this.cellsQuadTree = null;
72
+ this.cellsLayer = null;
73
+ this.cellsData = null;
74
+ this.cellSetsForceSimulation = forceCollideRects();
75
+ this.cellSetsLabelPrevZoom = null;
76
+ this.cellSetsLayers = [];
77
+ // Initialize data and layers.
78
+ this.onUpdateCellsData();
79
+ this.onUpdateCellsLayer();
80
+ this.onUpdateCellSetsLayers();
81
+ }
82
+ createCellsLayer() {
83
+ const { obsEmbeddingIndex: obsIndex, theme, cellRadius = 1.0, cellOpacity = 1.0,
84
+ // cellFilter,
85
+ cellSelection, setCellHighlight, setComponentHover, getCellIsSelected, cellColors, getCellColor = makeDefaultGetCellColors(cellColors, obsIndex, theme), getExpressionValue, onCellClick, geneExpressionColormap, geneExpressionColormapRange = [0.0, 1.0], cellColorEncoding, } = this.props;
86
+ return new deck.ScatterplotLayer({
87
+ id: CELLS_LAYER_ID,
88
+ // Note that the reference for the object passed to the data prop should not change,
89
+ // otherwise DeckGL will need to do a full re-render every time .createCellsLayer is called,
90
+ // which can be very often to handle cellOpacity and cellRadius updates for dynamic opacity.
91
+ data: this.cellsData,
92
+ coordinateSystem: deck.COORDINATE_SYSTEM.CARTESIAN,
93
+ visible: true,
94
+ pickable: true,
95
+ autoHighlight: true,
96
+ filled: true,
97
+ stroked: true,
98
+ backgroundColor: (theme === 'dark' ? [0, 0, 0] : [241, 241, 241]),
99
+ getCellIsSelected,
100
+ opacity: cellOpacity,
101
+ radiusScale: cellRadius,
102
+ radiusMinPixels: 1,
103
+ radiusMaxPixels: 30,
104
+ // Our radius pixel setters measure in pixels.
105
+ radiusUnits: 'pixels',
106
+ lineWidthUnits: 'pixels',
107
+ getPosition,
108
+ getFillColor: getCellColor,
109
+ getLineColor: getCellColor,
110
+ getRadius: 1,
111
+ getExpressionValue,
112
+ getLineWidth: 0,
113
+ extensions: [
114
+ new ScaledExpressionExtension(),
115
+ new SelectionExtension({ instanced: true }),
116
+ ],
117
+ colorScaleLo: geneExpressionColormapRange[0],
118
+ colorScaleHi: geneExpressionColormapRange[1],
119
+ isExpressionMode: (cellColorEncoding === 'geneSelection'),
120
+ colormap: geneExpressionColormap,
121
+ onClick: (info) => {
122
+ if (onCellClick) {
123
+ onCellClick(info);
124
+ }
125
+ },
126
+ onHover: getOnHoverCallback(obsIndex, setCellHighlight, setComponentHover),
127
+ updateTriggers: {
128
+ getExpressionValue,
129
+ getFillColor: [cellColorEncoding, cellSelection, cellColors],
130
+ getLineColor: [cellColorEncoding, cellSelection, cellColors],
131
+ getCellIsSelected,
132
+ },
133
+ });
134
+ }
135
+ createCellSetsLayers() {
136
+ const { theme, cellSetPolygons, viewState, cellSetPolygonsVisible, cellSetLabelsVisible, cellSetLabelSize, } = this.props;
137
+ const result = [];
138
+ if (cellSetPolygonsVisible) {
139
+ result.push(new deck.PolygonLayer({
140
+ id: 'cell-sets-polygon-layer',
141
+ data: cellSetPolygons,
142
+ stroked: true,
143
+ filled: false,
144
+ wireframe: true,
145
+ lineWidthMaxPixels: 1,
146
+ getPolygon: d => d.hull,
147
+ getLineColor: d => d.color,
148
+ getLineWidth: 1,
149
+ }));
150
+ }
151
+ if (cellSetLabelsVisible) {
152
+ const { zoom } = viewState;
153
+ const nodes = cellSetPolygons.map(p => ({
154
+ x: p.centroid[0],
155
+ y: p.centroid[1],
156
+ label: p.name,
157
+ }));
158
+ const collisionForce = this.cellSetsForceSimulation
159
+ .size(d => ([
160
+ cellSetLabelSize * 1 / (2 ** zoom) * 4 * d.label.length,
161
+ cellSetLabelSize * 1 / (2 ** zoom) * 1.5,
162
+ ]));
163
+ forceSimulation()
164
+ .nodes(nodes)
165
+ .force('collision', collisionForce)
166
+ .tick(NUM_FORCE_SIMULATION_TICKS);
167
+ result.push(new deck.TextLayer({
168
+ id: 'cell-sets-text-layer',
169
+ data: nodes,
170
+ getPosition: d => ([d.x, d.y]),
171
+ getText: d => d.label,
172
+ getColor: (theme === 'dark' ? [255, 255, 255] : [0, 0, 0]),
173
+ getSize: cellSetLabelSize,
174
+ getAngle: 0,
175
+ getTextAnchor: 'middle',
176
+ getAlignmentBaseline: 'center',
177
+ fontFamily: LABEL_FONT_FAMILY,
178
+ fontWeight: 'normal',
179
+ }));
180
+ }
181
+ return result;
182
+ }
183
+ createSelectionLayers() {
184
+ const { obsEmbeddingIndex: obsIndex, obsEmbedding, viewState, setCellSelection, } = this.props;
185
+ const { tool } = this.state;
186
+ const { cellsQuadTree } = this;
187
+ const flipYTooltip = true;
188
+ const getCellCoords = makeDefaultGetObsCoords(obsEmbedding);
189
+ return getSelectionLayers(tool, viewState.zoom, CELLS_LAYER_ID, getCellCoords, obsIndex, setCellSelection, cellsQuadTree, flipYTooltip);
190
+ }
191
+ getLayers() {
192
+ const { cellsLayer, cellSetsLayers, } = this;
193
+ return [
194
+ cellsLayer,
195
+ ...cellSetsLayers,
196
+ ...this.createSelectionLayers(),
197
+ ];
198
+ }
199
+ onUpdateCellsData() {
200
+ const { obsEmbedding } = this.props;
201
+ if (obsEmbedding) {
202
+ const getCellCoords = makeDefaultGetObsCoords(obsEmbedding);
203
+ this.cellsQuadTree = createQuadTree(obsEmbedding, getCellCoords);
204
+ this.cellsData = {
205
+ src: {
206
+ obsEmbedding,
207
+ },
208
+ length: obsEmbedding.shape[1],
209
+ };
210
+ }
211
+ }
212
+ onUpdateCellsLayer() {
213
+ const { obsEmbeddingIndex, obsEmbedding } = this.props;
214
+ if (obsEmbeddingIndex && obsEmbedding) {
215
+ this.cellsLayer = this.createCellsLayer();
216
+ }
217
+ else {
218
+ this.cellsLayer = null;
219
+ }
220
+ }
221
+ onUpdateCellSetsLayers(onlyViewStateChange) {
222
+ // Because the label sizes for the force simulation depend on the zoom level,
223
+ // we _could_ run the simulation every time the zoom level changes.
224
+ // However, this has a performance impact in firefox.
225
+ if (onlyViewStateChange) {
226
+ const { viewState, cellSetLabelsVisible } = this.props;
227
+ const { zoom } = viewState;
228
+ const { cellSetsLabelPrevZoom } = this;
229
+ // Instead, we can just check if the zoom level has changed
230
+ // by some relatively large delta, to be more conservative
231
+ // about re-running the force simulation.
232
+ if (cellSetLabelsVisible
233
+ && (cellSetsLabelPrevZoom === null
234
+ || Math.abs(cellSetsLabelPrevZoom - zoom) > LABEL_UPDATE_ZOOM_DELTA)) {
235
+ this.cellSetsLayers = this.createCellSetsLayers();
236
+ this.cellSetsLabelPrevZoom = zoom;
237
+ }
238
+ }
239
+ else {
240
+ // Otherwise, something more substantial than just
241
+ // the viewState has changed, such as the label array
242
+ // itself, so we always want to update the layer
243
+ // in this case.
244
+ this.cellSetsLayers = this.createCellSetsLayers();
245
+ }
246
+ }
247
+ viewInfoDidUpdate() {
248
+ const { obsEmbeddingIndex, obsEmbedding, } = this.props;
249
+ super.viewInfoDidUpdate(obsEmbeddingIndex, obsEmbedding, makeFlippedGetObsCoords);
250
+ }
251
+ /**
252
+ * Here, asynchronously check whether props have
253
+ * updated which require re-computing memoized variables,
254
+ * followed by a re-render.
255
+ * This function does not follow React conventions or paradigms,
256
+ * it is only implemented this way to try to squeeze out
257
+ * performance.
258
+ * @param {object} prevProps The previous props to diff against.
259
+ */
260
+ componentDidUpdate(prevProps) {
261
+ this.viewInfoDidUpdate();
262
+ const shallowDiff = propName => (prevProps[propName] !== this.props[propName]);
263
+ let forceUpdate = false;
264
+ if (['obsEmbedding'].some(shallowDiff)) {
265
+ // Cells data changed.
266
+ this.onUpdateCellsData();
267
+ forceUpdate = true;
268
+ }
269
+ if ([
270
+ 'obsEmbeddingIndex', 'obsEmbedding', 'cellFilter', 'cellSelection', 'cellColors',
271
+ 'cellRadius', 'cellOpacity', 'cellRadiusMode', 'geneExpressionColormap',
272
+ 'geneExpressionColormapRange', 'geneSelection', 'cellColorEncoding',
273
+ ].some(shallowDiff)) {
274
+ // Cells layer props changed.
275
+ this.onUpdateCellsLayer();
276
+ forceUpdate = true;
277
+ }
278
+ if ([
279
+ 'cellSetPolygons', 'cellSetPolygonsVisible',
280
+ 'cellSetLabelsVisible', 'cellSetLabelSize',
281
+ ].some(shallowDiff)) {
282
+ // Cell sets layer props changed.
283
+ this.onUpdateCellSetsLayers(false);
284
+ forceUpdate = true;
285
+ }
286
+ if (shallowDiff('viewState')) {
287
+ // The viewState prop has changed (due to zoom or pan).
288
+ this.onUpdateCellSetsLayers(true);
289
+ forceUpdate = true;
290
+ }
291
+ if (forceUpdate) {
292
+ this.forceUpdate();
293
+ }
294
+ }
295
+ }
296
+ /**
297
+ * Need this wrapper function here,
298
+ * since we want to pass a forwardRef
299
+ * so that outer components can
300
+ * access the grandchild DeckGL ref,
301
+ * but we are using a class component.
302
+ */
303
+ const ScatterplotWrapper = forwardRef((props, deckRef) => (_jsx(Scatterplot, { ...props, deckRef: deckRef })));
304
+ export default ScatterplotWrapper;
@@ -0,0 +1,50 @@
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 Checkbox from '@material-ui/core/Checkbox';
5
+ import Slider from '@material-ui/core/Slider';
6
+ import TableCell from '@material-ui/core/TableCell';
7
+ import TableRow from '@material-ui/core/TableRow';
8
+ import { capitalize } from '@vitessce/utils';
9
+ import { usePlotOptionsStyles, CellColorEncodingOption, OptionsContainer, OptionSelect, } from '@vitessce/vit-s';
10
+ import { GLSL_COLORMAPS } from '@vitessce/gl';
11
+ export default function ScatterplotOptions(props) {
12
+ const { children, observationsLabel, cellRadius, setCellRadius, cellRadiusMode, setCellRadiusMode, cellOpacity, setCellOpacity, cellOpacityMode, setCellOpacityMode, cellSetLabelsVisible, setCellSetLabelsVisible, cellSetLabelSize, setCellSetLabelSize, cellSetPolygonsVisible, setCellSetPolygonsVisible, cellColorEncoding, setCellColorEncoding, geneExpressionColormap, setGeneExpressionColormap, geneExpressionColormapRange, setGeneExpressionColormapRange, } = props;
13
+ const observationsLabelNice = capitalize(observationsLabel);
14
+ const classes = usePlotOptionsStyles();
15
+ function handleCellRadiusModeChange(event) {
16
+ setCellRadiusMode(event.target.value);
17
+ }
18
+ function handleCellOpacityModeChange(event) {
19
+ setCellOpacityMode(event.target.value);
20
+ }
21
+ function handleRadiusChange(event, value) {
22
+ setCellRadius(value);
23
+ }
24
+ function handleOpacityChange(event, value) {
25
+ setCellOpacity(value);
26
+ }
27
+ function handleLabelVisibilityChange(event) {
28
+ setCellSetLabelsVisible(event.target.checked);
29
+ }
30
+ function handleLabelSizeChange(event, value) {
31
+ setCellSetLabelSize(value);
32
+ }
33
+ function handlePolygonVisibilityChange(event) {
34
+ setCellSetPolygonsVisible(event.target.checked);
35
+ }
36
+ function handleGeneExpressionColormapChange(event) {
37
+ setGeneExpressionColormap(event.target.value);
38
+ }
39
+ function handleColormapRangeChange(event, value) {
40
+ setGeneExpressionColormapRange(value);
41
+ }
42
+ const handleColormapRangeChangeDebounced = useCallback(debounce(handleColormapRangeChange, 5, { trailing: true }), [handleColormapRangeChange]);
43
+ return (_jsxs(OptionsContainer, { children: [children, _jsx(CellColorEncodingOption, { observationsLabel: observationsLabel, cellColorEncoding: cellColorEncoding, setCellColorEncoding: setCellColorEncoding }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, children: [observationsLabelNice, " Set Labels Visible"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Checkbox, { className: classes.checkbox, checked: cellSetLabelsVisible, onChange: handleLabelVisibilityChange, name: "scatterplot-option-cell-set-labels", color: "default" }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, children: [observationsLabelNice, " Set Label Size"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Slider, { disabled: !cellSetLabelsVisible, classes: { root: classes.slider, valueLabel: classes.sliderValueLabel }, value: cellSetLabelSize, onChange: handleLabelSizeChange, "aria-labelledby": "cell-set-label-size-slider", valueLabelDisplay: "auto", step: 1, min: 8, max: 36 }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, children: [observationsLabelNice, " Set Polygons Visible"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Checkbox, { className: classes.checkbox, checked: cellSetPolygonsVisible, onChange: handlePolygonVisibilityChange, name: "scatterplot-option-cell-set-polygons", color: "default" }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, htmlFor: "cell-radius-mode-select", children: [observationsLabelNice, " Radius Mode"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsxs(OptionSelect, { className: classes.select, value: cellRadiusMode, onChange: handleCellRadiusModeChange, inputProps: {
44
+ id: 'cell-radius-mode-select',
45
+ }, children: [_jsx("option", { value: "auto", children: "Auto" }), _jsx("option", { value: "manual", children: "Manual" })] }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, children: [observationsLabelNice, " Radius"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Slider, { disabled: cellRadiusMode !== 'manual', classes: { root: classes.slider, valueLabel: classes.sliderValueLabel }, value: cellRadius, onChange: handleRadiusChange, "aria-labelledby": "cell-radius-slider", valueLabelDisplay: "auto", step: 0.01, min: 0.01, max: 10 }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, htmlFor: "cell-opacity-mode-select", children: [observationsLabelNice, " Opacity Mode"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsxs(OptionSelect, { className: classes.select, value: cellOpacityMode, onChange: handleCellOpacityModeChange, inputProps: {
46
+ id: 'cell-opacity-mode-select',
47
+ }, children: [_jsx("option", { value: "auto", children: "Auto" }), _jsx("option", { value: "manual", children: "Manual" })] }) })] }), _jsxs(TableRow, { children: [_jsxs(TableCell, { className: classes.labelCell, children: [observationsLabelNice, " Opacity"] }), _jsx(TableCell, { className: classes.inputCell, children: _jsx(Slider, { disabled: cellOpacityMode !== 'manual', classes: { root: classes.slider, valueLabel: classes.sliderValueLabel }, value: cellOpacity, onChange: handleOpacityChange, "aria-labelledby": "cell-opacity-slider", valueLabelDisplay: "auto", step: 0.05, min: 0.0, max: 1.0 }) })] }), _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: {
48
+ id: 'gene-expression-colormap-select',
49
+ }, 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 }) })] })] }));
50
+ }
@@ -0,0 +1,14 @@
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 ScatterplotTooltipSubscriber(props) {
6
+ const { parentUuid, obsHighlight, width, height, getObsInfo, } = props;
7
+ const sourceUuid = useComponentHover();
8
+ const viewInfo = useComponentViewInfo(parentUuid);
9
+ const [cellInfo, x, y] = (obsHighlight && getObsInfo ? ([
10
+ getObsInfo(obsHighlight),
11
+ ...(viewInfo && viewInfo.project ? viewInfo.project(obsHighlight) : [null, null]),
12
+ ]) : ([null, null, null]));
13
+ return ((cellInfo ? (_jsx(Tooltip2D, { x: x, y: y, parentUuid: parentUuid, sourceUuid: sourceUuid, parentWidth: width, parentHeight: height, children: _jsx(TooltipContent, { info: cellInfo }) })) : null));
14
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { default as Scatterplot } from './Scatterplot';
2
+ export { default as ScatterplotOptions } from './ScatterplotOptions';
3
+ export { default as ScatterplotTooltipSubscriber } from './ScatterplotTooltipSubscriber';
4
+ export { default as EmptyMessage } from './EmptyMessage';
5
+ export { getPointSizeDevicePixels, getPointOpacity, getOnHoverCallback, createQuadTree, AbstractSpatialOrScatterplot, } from './shared-spatial-scatterplot/index';
@@ -0,0 +1,213 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { PureComponent } from 'react';
3
+ import { deck, DEFAULT_GL_OPTIONS } from '@vitessce/gl';
4
+ import ToolMenu from './ToolMenu';
5
+ import { getCursor, getCursorWithTool } from './cursor';
6
+ /**
7
+ * Abstract class component intended to be inherited by
8
+ * the Spatial and Scatterplot class components.
9
+ * Contains a common constructor, common DeckGL callbacks,
10
+ * and common render function.
11
+ */
12
+ export default class AbstractSpatialOrScatterplot extends PureComponent {
13
+ constructor(props) {
14
+ super(props);
15
+ this.state = {
16
+ gl: null,
17
+ tool: null,
18
+ };
19
+ this.viewport = null;
20
+ this.onViewStateChange = this.onViewStateChange.bind(this);
21
+ this.onInitializeViewInfo = this.onInitializeViewInfo.bind(this);
22
+ this.onWebGLInitialized = this.onWebGLInitialized.bind(this);
23
+ this.onToolChange = this.onToolChange.bind(this);
24
+ this.onHover = this.onHover.bind(this);
25
+ }
26
+ /**
27
+ * Called by DeckGL upon a viewState change,
28
+ * for example zoom or pan interaction.
29
+ * Emit the new viewState to the `setViewState`
30
+ * handler prop.
31
+ * @param {object} params
32
+ * @param {object} params.viewState The next deck.gl viewState.
33
+ */
34
+ onViewStateChange({ viewState: nextViewState }) {
35
+ const { setViewState, viewState, layers, spatialAxisFixed, } = this.props;
36
+ const use3d = layers?.some(l => l.use3d);
37
+ setViewState({
38
+ ...nextViewState,
39
+ // If the axis is fixed, just use the current target in state i.e don't change target.
40
+ target: spatialAxisFixed && use3d ? viewState.target : nextViewState.target,
41
+ });
42
+ }
43
+ /**
44
+ * Called by DeckGL upon viewport
45
+ * initialization.
46
+ * @param {object} viewState
47
+ * @param {object} viewState.viewport
48
+ */
49
+ onInitializeViewInfo({ viewport }) {
50
+ this.viewport = viewport;
51
+ }
52
+ /**
53
+ * Called by DeckGL upon initialization,
54
+ * helps to understand when to pass layers
55
+ * to the DeckGL component.
56
+ * @param {object} gl The WebGL context object.
57
+ */
58
+ onWebGLInitialized(gl) {
59
+ this.setState({ gl });
60
+ }
61
+ /**
62
+ * Called by the ToolMenu buttons.
63
+ * Emits the new tool value to the
64
+ * `onToolChange` prop.
65
+ * @param {string} tool Name of tool.
66
+ */
67
+ onToolChange(tool) {
68
+ const { onToolChange: onToolChangeProp } = this.props;
69
+ this.setState({ tool });
70
+ if (onToolChangeProp) {
71
+ onToolChangeProp(tool);
72
+ }
73
+ }
74
+ /**
75
+ * Create the DeckGL layers.
76
+ * @returns {object[]} Array of
77
+ * DeckGL layer objects.
78
+ * Intended to be overriden by descendants.
79
+ */
80
+ // eslint-disable-next-line class-methods-use-this
81
+ getLayers() {
82
+ return [];
83
+ }
84
+ // eslint-disable-next-line consistent-return
85
+ onHover(info) {
86
+ const { coordinate, sourceLayer: layer, tile, } = info;
87
+ const { setCellHighlight, cellHighlight, setComponentHover, layers, } = this.props;
88
+ const hasBitmask = (layers || []).some(l => l.type === 'bitmask');
89
+ if (!setCellHighlight || !tile) {
90
+ return null;
91
+ }
92
+ if (!layer || !coordinate) {
93
+ if (cellHighlight && hasBitmask) {
94
+ setCellHighlight(null);
95
+ }
96
+ return null;
97
+ }
98
+ const { content, bbox, z } = tile;
99
+ if (!content) {
100
+ if (cellHighlight && hasBitmask) {
101
+ setCellHighlight(null);
102
+ }
103
+ return null;
104
+ }
105
+ const { data, width, height } = content;
106
+ const { left, right, top, bottom, } = bbox;
107
+ const bounds = [
108
+ left,
109
+ data.height < layer.tileSize ? height : bottom,
110
+ data.width < layer.tileSize ? width : right,
111
+ top,
112
+ ];
113
+ if (!data) {
114
+ if (cellHighlight && hasBitmask) {
115
+ setCellHighlight(null);
116
+ }
117
+ return null;
118
+ }
119
+ // Tiled layer needs a custom layerZoomScale.
120
+ if (layer.id.includes('bitmask')) {
121
+ // The zoomed out layer needs to use the fixed zoom at which it is rendered.
122
+ const layerZoomScale = Math.max(1, 2 ** Math.round(-z));
123
+ const dataCoords = [
124
+ Math.floor((coordinate[0] - bounds[0]) / layerZoomScale),
125
+ Math.floor((coordinate[1] - bounds[3]) / layerZoomScale),
126
+ ];
127
+ const coords = dataCoords[1] * width + dataCoords[0];
128
+ const hoverData = data.map(d => d[coords]);
129
+ const cellId = hoverData.find(i => i > 0);
130
+ if (cellId !== Number(cellHighlight)) {
131
+ if (setComponentHover) {
132
+ setComponentHover();
133
+ }
134
+ // eslint-disable-next-line no-unused-expressions
135
+ setCellHighlight(cellId ? String(cellId) : null);
136
+ }
137
+ }
138
+ }
139
+ /**
140
+ * Emits a function to project from the
141
+ * cell ID space to the scatterplot or
142
+ * spatial coordinate space, via the
143
+ * `updateViewInfo` prop.
144
+ */
145
+ viewInfoDidUpdate(obsIndex, obsLocations, makeGetObsCoords) {
146
+ const { updateViewInfo, uuid } = this.props;
147
+ const { viewport } = this;
148
+ if (updateViewInfo && viewport) {
149
+ updateViewInfo({
150
+ uuid,
151
+ project: (obsId) => {
152
+ try {
153
+ if (obsIndex && obsLocations) {
154
+ const getObsCoords = makeGetObsCoords(obsLocations);
155
+ const obsIdx = obsIndex.indexOf(obsId);
156
+ const obsCoord = getObsCoords(obsIdx);
157
+ return viewport.project(obsCoord);
158
+ }
159
+ return [null, null];
160
+ }
161
+ catch (e) {
162
+ return [null, null];
163
+ }
164
+ },
165
+ });
166
+ }
167
+ }
168
+ /**
169
+ * Intended to be overridden by descendants.
170
+ */
171
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
172
+ componentDidUpdate() {
173
+ }
174
+ /**
175
+ * Intended to be overridden by descendants.
176
+ * @returns {boolean} Whether or not any layers are 3D.
177
+ */
178
+ // eslint-disable-next-line class-methods-use-this
179
+ use3d() {
180
+ return false;
181
+ }
182
+ /**
183
+ * A common render function for both Spatial
184
+ * and Scatterplot components.
185
+ */
186
+ render() {
187
+ const { deckRef, viewState, uuid, hideTools, } = this.props;
188
+ const { gl, tool } = this.state;
189
+ const layers = this.getLayers();
190
+ const use3d = this.use3d();
191
+ const showCellSelectionTools = this.obsSegmentationsData !== null;
192
+ const showPanTool = layers.length > 0;
193
+ // For large datasets or ray casting, the visual quality takes only a small
194
+ // hit in exchange for much better performance by setting this to false:
195
+ // https://deck.gl/docs/api-reference/core/deck#usedevicepixels
196
+ const useDevicePixels = (!use3d
197
+ && (this.obsSegmentationsData?.shape?.[0] < 100000
198
+ || this.obsLocationsData?.shape?.[1] < 100000));
199
+ return (_jsxs(_Fragment, { children: [_jsx(ToolMenu, { activeTool: tool, setActiveTool: this.onToolChange, visibleTools: {
200
+ pan: showPanTool && !hideTools,
201
+ selectRectangle: showCellSelectionTools && !hideTools,
202
+ selectLasso: showCellSelectionTools && !hideTools,
203
+ } }), _jsx(deck.DeckGL, { id: `deckgl-overlay-${uuid}`, ref: deckRef, views: [
204
+ use3d
205
+ ? new deck.OrbitView({ id: 'orbit', controller: true, orbitAxis: 'Y' })
206
+ : new deck.OrthographicView({
207
+ id: 'ortho',
208
+ }),
209
+ ], layers: gl && viewState.target.slice(0, 2).every(i => typeof i === 'number')
210
+ ? layers
211
+ : [], glOptions: DEFAULT_GL_OPTIONS, onWebGLInitialized: this.onWebGLInitialized, onViewStateChange: this.onViewStateChange, viewState: viewState, useDevicePixels: useDevicePixels, controller: tool ? { dragPan: false } : true, getCursor: tool ? getCursorWithTool : getCursor, onHover: this.onHover, children: this.onInitializeViewInfo })] }));
212
+ }
213
+ }
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import clsx from 'clsx';
4
+ import { SELECTION_TYPE } from '@vitessce/gl';
5
+ import { PointerIconSVG, SelectRectangleIconSVG, SelectLassoIconSVG } from '@vitessce/icons';
6
+ import { makeStyles } from '@material-ui/core';
7
+ const useStyles = makeStyles(() => ({
8
+ tool: {
9
+ position: 'absolute',
10
+ display: 'inline',
11
+ zIndex: '1000',
12
+ opacity: '.65',
13
+ color: 'black',
14
+ '&:hover': {
15
+ opacity: '.90',
16
+ },
17
+ },
18
+ iconButton: {
19
+ // btn btn-outline-secondary mr-2 icon
20
+ padding: '0',
21
+ height: '2em',
22
+ width: '2em',
23
+ backgroundColor: 'white',
24
+ display: 'inline-block',
25
+ fontWeight: '400',
26
+ textAlign: 'center',
27
+ verticalAlign: 'middle',
28
+ cursor: 'pointer',
29
+ userSelect: 'none',
30
+ border: '1px solid #6c757d',
31
+ fontSize: '1rem',
32
+ lineHeight: '1.5',
33
+ borderRadius: '0.25rem',
34
+ transition: 'color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
35
+ color: '#6c757d',
36
+ marginRight: '0.5rem',
37
+ '& > svg': {
38
+ verticalAlign: 'middle',
39
+ },
40
+ },
41
+ iconButtonActive: {
42
+ // active
43
+ color: '#fff',
44
+ backgroundColor: '#6c757d',
45
+ borderColor: '#6c757d',
46
+ boxShadow: '0 0 0 0.2rem rgba(108, 117, 125, 0.5)',
47
+ },
48
+ }));
49
+ export function IconButton(props) {
50
+ const { alt, onClick, isActive, children, } = props;
51
+ const classes = useStyles();
52
+ return (_jsx("button", { className: clsx(classes.iconButton, { [classes.iconButtonActive]: isActive }), onClick: onClick, type: "button", title: alt, children: children }));
53
+ }
54
+ export default function ToolMenu(props) {
55
+ const { setActiveTool, activeTool, visibleTools = { pan: true, selectRectangle: true, selectLasso: true }, } = props;
56
+ const classes = useStyles();
57
+ return (_jsxs("div", { className: classes.tool, children: [visibleTools.pan && (_jsx(IconButton, { alt: "pointer tool", onClick: () => setActiveTool(null), isActive: activeTool === null, children: _jsx(PointerIconSVG, {}) })), visibleTools.selectRectangle ? (_jsx(IconButton, { alt: "select rectangle", onClick: () => setActiveTool(SELECTION_TYPE.RECTANGLE), isActive: activeTool === SELECTION_TYPE.RECTANGLE, children: _jsx(SelectRectangleIconSVG, {}) })) : null, visibleTools.selectLasso ? (_jsx(IconButton, { alt: "select lasso", onClick: () => setActiveTool(SELECTION_TYPE.POLYGON), isActive: activeTool === SELECTION_TYPE.POLYGON, children: _jsx(SelectLassoIconSVG, {}) })) : null] }));
58
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import '@testing-library/jest-dom';
3
+ import { cleanup, render } from '@testing-library/react';
4
+ import { afterEach, expect } from 'vitest';
5
+ import { IconButton } from './ToolMenu';
6
+ afterEach(() => {
7
+ cleanup();
8
+ });
9
+ describe('ToolMenu.js', () => {
10
+ describe('<IconButton />', () => {
11
+ it('renders with title attribute', () => {
12
+ const { container } = render(_jsx(IconButton, { isActive: true, alt: "Lasso" }));
13
+ expect(container.querySelectorAll('[title="Lasso"]').length).toEqual(1);
14
+ });
15
+ });
16
+ });
@@ -0,0 +1,22 @@
1
+ export const getCursorWithTool = () => 'crosshair';
2
+ export const getCursor = interactionState => (interactionState.isDragging
3
+ ? 'grabbing' : 'default');
4
+ export function getOnHoverCallback(obsIndex, setObsHighlight, setComponentHover) {
5
+ return (info) => {
6
+ // Notify the parent component that its child component is
7
+ // the "hover source".
8
+ if (setComponentHover) {
9
+ setComponentHover();
10
+ }
11
+ if (info.index) {
12
+ const obsId = obsIndex[info.index];
13
+ if (setObsHighlight) {
14
+ setObsHighlight(obsId);
15
+ }
16
+ }
17
+ else if (setObsHighlight) {
18
+ // Clear the currently-hovered cell info by passing null.
19
+ setObsHighlight(null);
20
+ }
21
+ };
22
+ }
@@ -0,0 +1,47 @@
1
+ import { deck } from '@vitessce/gl';
2
+ import clamp from 'lodash/clamp';
3
+ // Reference: https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds
4
+ // Reference: https://observablehq.com/@bmschmidt/dot-density-election-maps-with-webgl
5
+ export function getPointSizeDevicePixels(devicePixelRatio, zoom, xRange, yRange, width, height) {
6
+ // Size of a point, in units of the diagonal axis.
7
+ const pointSize = 0.0005;
8
+ // Point size maximum, in screen pixels.
9
+ const pointScreenSizeMax = 10;
10
+ // Point size minimum, in screen pixels.
11
+ const pointScreenSizeMin = 1 / devicePixelRatio;
12
+ const scaleFactor = 2 ** zoom;
13
+ const xAxisRange = 2.0 / ((xRange * scaleFactor) / width);
14
+ const yAxisRange = 2.0 / ((yRange * scaleFactor) / height);
15
+ // The diagonal screen size as a fraction of the current diagonal axis range,
16
+ // then converted to device pixels.
17
+ const diagonalScreenSize = Math.sqrt((width ** 2) + (height ** 2));
18
+ const diagonalAxisRange = Math.sqrt((xAxisRange ** 2) + (yAxisRange ** 2));
19
+ const diagonalFraction = pointSize / diagonalAxisRange;
20
+ const deviceSize = diagonalFraction * diagonalScreenSize;
21
+ const pointSizeDevicePixels = clamp(deviceSize, pointScreenSizeMin, pointScreenSizeMax);
22
+ return pointSizeDevicePixels;
23
+ }
24
+ // Reference: https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds
25
+ export function getPointOpacity(zoom, xRange, yRange, width, height, numCells, avgFillDensity) {
26
+ const N = numCells;
27
+ const [minX, minY, maxX, maxY] = new deck.OrthographicView({ zoom }).makeViewport({
28
+ height,
29
+ width,
30
+ viewState: { zoom, target: [0, 0, 0] },
31
+ }).getBounds();
32
+ const X = maxY - minY;
33
+ const Y = maxX - minX;
34
+ const X0 = xRange;
35
+ const Y0 = yRange;
36
+ const W = width;
37
+ const H = height;
38
+ let rho = avgFillDensity;
39
+ if (!rho) {
40
+ rho = Math.min(1, 1 / (10 ** (Math.log10(N) - 3)));
41
+ }
42
+ // p in the calculation is the pixel length/width of a given point, which for us is 1
43
+ // so it does not factor into our calculation here.
44
+ const alpha = ((rho * W * H) / N) * (Y0 / Y) * (X0 / X);
45
+ const pointOpacity = clamp(alpha, 1.01 / 255, 1.0);
46
+ return pointOpacity;
47
+ }
@@ -0,0 +1,28 @@
1
+ import { getPointSizeDevicePixels, getPointOpacity } from './dynamic-opacity';
2
+ describe('dynamic-opacity.js', () => {
3
+ describe('getPointSizeDevicePixels', () => {
4
+ it('calculates point size', () => {
5
+ const devicePixelRatio = 2.0;
6
+ const zoom = null;
7
+ const xRange = 20;
8
+ const yRange = 18;
9
+ const width = 1000;
10
+ const height = 650;
11
+ const pointSize = getPointSizeDevicePixels(devicePixelRatio, zoom, xRange, yRange, width, height);
12
+ expect(pointSize).toBeCloseTo(0.5);
13
+ });
14
+ });
15
+ describe('getPointOpacity', () => {
16
+ it('calculates point opacity', () => {
17
+ const zoom = null;
18
+ const width = 1000;
19
+ const height = 650;
20
+ const xRange = 20;
21
+ const yRange = 18;
22
+ const numCells = 500000;
23
+ const avgFillDensity = undefined;
24
+ const pointOpacity = getPointOpacity(zoom, xRange, yRange, width, height, numCells, avgFillDensity);
25
+ expect(pointOpacity).toBeCloseTo(0.005);
26
+ });
27
+ });
28
+ });
@@ -0,0 +1,169 @@
1
+ /* eslint-disable no-plusplus */
2
+ /* eslint-disable no-param-reassign */
3
+ import { quadtree } from 'd3-quadtree';
4
+ /**
5
+ * Returns a closure that returns a constant value.
6
+ */
7
+ function constant(v) {
8
+ return (() => v);
9
+ }
10
+ /**
11
+ * Adds a tiny bit of randomness to a number.
12
+ */
13
+ function jiggle(v) {
14
+ return v + (Math.random() - 0.5) * 1e-6;
15
+ }
16
+ /**
17
+ * A force function to be used with d3.forceSimulation.
18
+ * This has been adapted for use here, with comments explaining each part.
19
+ * Reference: https://bl.ocks.org/cmgiven/547658968d365bcc324f3e62e175709b
20
+ */
21
+ export function forceCollideRects() {
22
+ // D3 implements things with function prototypes rather than classes.
23
+ // Pretend these variables are the "instance members" of a class.
24
+ // Note that this function actually returns the internal force() function,
25
+ // but that the force() function is a closure with access to these instance members.
26
+ let nodes;
27
+ let masses;
28
+ let strength = 1;
29
+ let iterations = 1;
30
+ let sizes;
31
+ let size = constant([0, 0]);
32
+ // Given a node, return the center point along the x-axis.
33
+ function xCenter(d) {
34
+ return d.x + d.vx + sizes[d.index][0] / 2;
35
+ }
36
+ // Given a node, return the center point along the y-axis.
37
+ function yCenter(d) {
38
+ return d.y + d.vy + sizes[d.index][1] / 2;
39
+ }
40
+ // Given a quadtree node, initialize its .size property.
41
+ function prepare(quad) {
42
+ if (quad.data) {
43
+ // This is a leaf node, so we set quad.size to the node's size.
44
+ // (No need to compute the max of internal nodes,
45
+ // since leaf nodes do not have any internal nodes).
46
+ quad.size = sizes[quad.data.index];
47
+ }
48
+ else {
49
+ quad.size = [0, 0];
50
+ // Internal nodes of the quadtree are represented
51
+ // as four-element arrays in left-to-right, top-to-bottom order.
52
+ // Here, we are setting quad.size to [maxWidth, maxHeight]
53
+ // among the internal nodes of this current `quad` node.
54
+ for (let i = 0; i < 4; i++) {
55
+ if (quad[i] && quad[i].size) {
56
+ quad.size[0] = Math.max(quad.size[0], quad[i].size[0]);
57
+ quad.size[1] = Math.max(quad.size[1], quad[i].size[1]);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ function force() {
63
+ let node;
64
+ let nodeSize;
65
+ let nodeMass;
66
+ let xi;
67
+ let yi;
68
+ // Create a quadtree based on node center points.
69
+ // Initialize each quadtree node's .size property by calling
70
+ // the prepare() function on each quadtree node.
71
+ const tree = quadtree(nodes, xCenter, yCenter).visitAfter(prepare);
72
+ // Update the .vx and .vy properties of both `node` and `data`
73
+ // (the current node pair).
74
+ function apply(quad, x0, y0, x1, y1) {
75
+ // `quad` is a quadtree node.
76
+ const { data } = quad;
77
+ const xSize = (nodeSize[0] + quad.size[0]) / 2;
78
+ const ySize = (nodeSize[1] + quad.size[1]) / 2;
79
+ if (data && data.index > node.index) {
80
+ // This is a leaf node because `data` is defined.
81
+ // `x` is the difference in x centers between `node` and `data`.
82
+ // `y` is the difference in y centers between `node` and `data`.
83
+ let x = jiggle(xi - xCenter(data));
84
+ let y = jiggle(yi - yCenter(data));
85
+ const xd = Math.abs(x) - xSize;
86
+ const yd = Math.abs(y) - ySize;
87
+ // If `xd` and `yd` is less than zero,
88
+ // then there is an overlap between the two nodes.
89
+ if (xd < 0 && yd < 0) {
90
+ const l = Math.sqrt(x * x + y * y);
91
+ const m = masses[data.index] / (nodeMass + masses[data.index]);
92
+ // We move the nodes either in the x or y direction.
93
+ // Nodes are moved proportionally to:
94
+ // their distance apart (`l`), their amount of overlap (`xd` or `yd`), their masses (`m`),
95
+ // and the strength parameter (`strength`).
96
+ if (Math.abs(xd) < Math.abs(yd)) {
97
+ node.vx -= (x *= xd / l * strength) * m;
98
+ data.vx += x * (1 - m);
99
+ }
100
+ else {
101
+ node.vy -= (y *= yd / l * strength) * m;
102
+ data.vy += y * (1 - m);
103
+ }
104
+ }
105
+ // When the quadtree.visit callback returns _true_ for a node,
106
+ // then the node's children will _not_ be visited.
107
+ return x0 > xi + xSize || x1 < xi - xSize || y0 > yi + ySize || y1 < yi - ySize;
108
+ }
109
+ return false;
110
+ }
111
+ function iterate() {
112
+ // On every iteration, use the `apply` function to visit every node
113
+ // which has an index greater than the current node's index,
114
+ // (visiting every node pair).
115
+ for (let j = 0; j < nodes.length; j++) {
116
+ node = nodes[j];
117
+ nodeSize = sizes[j];
118
+ nodeMass = masses[j];
119
+ xi = xCenter(node);
120
+ yi = yCenter(node);
121
+ tree.visit(apply);
122
+ }
123
+ }
124
+ // Do the specified number of iterations.
125
+ for (let i = 0; i < iterations; i++) {
126
+ iterate();
127
+ }
128
+ }
129
+ // The "constructor".
130
+ // Takes a list of nodes as input.
131
+ force.initialize = (v) => {
132
+ nodes = v;
133
+ // Get the size [w, h] of each node using the size getter function.
134
+ sizes = nodes.map(size);
135
+ // Get the mass of each node,
136
+ // which is the sum of its horizontal and vertical edge lengths.
137
+ masses = sizes.map(d => d[0] + d[1]);
138
+ };
139
+ // Set the number of iterations.
140
+ // If no value is provided as a parameter, this acts as a getter function.
141
+ force.iterations = (...v) => {
142
+ if (v.length) {
143
+ iterations = +v[0];
144
+ return force;
145
+ }
146
+ return iterations;
147
+ };
148
+ // Set the strength value.
149
+ // If no value is provided as a parameter, this acts as a getter function.
150
+ force.strength = (...v) => {
151
+ if (v.length) {
152
+ strength = +v[0];
153
+ return force;
154
+ }
155
+ return strength;
156
+ };
157
+ // Set the size function.
158
+ // The size function takes a node as a parameter and returns its size.
159
+ // If no size function is provided as a parameter, this acts as a getter function.
160
+ force.size = (...v) => {
161
+ if (v.length) {
162
+ size = (typeof v[0] === 'function' ? v[0] : constant(v[0]));
163
+ return force;
164
+ }
165
+ return size;
166
+ };
167
+ // Returns the force closure.
168
+ return force;
169
+ }
@@ -0,0 +1,58 @@
1
+ import { forceSimulation } from 'd3-force';
2
+ import { forceCollideRects } from './force-collide-rects';
3
+ describe('force-collide-rects.js', () => {
4
+ describe('forceCollideRects', () => {
5
+ it('can be initialized with a size function', () => {
6
+ const collisionForce = forceCollideRects()
7
+ .size(d => ([d.width, d.height]));
8
+ const size = collisionForce.size();
9
+ const [w, h] = size({ width: 2, height: 3 });
10
+ expect(w).toEqual(2);
11
+ expect(h).toEqual(3);
12
+ });
13
+ it('cannot prevent a collision of rects after few iterations', () => {
14
+ const collisionForce = forceCollideRects()
15
+ .size(d => ([d.width, d.height]));
16
+ const nodes = [
17
+ {
18
+ label: 'A', width: 100, height: 100, x: 2, y: 2,
19
+ },
20
+ {
21
+ label: 'B', width: 100, height: 100, x: 3, y: 3,
22
+ },
23
+ {
24
+ label: 'C', width: 100, height: 100, x: 3, y: 2,
25
+ },
26
+ ];
27
+ forceSimulation()
28
+ .nodes(nodes)
29
+ .force('collision', collisionForce)
30
+ .tick(2);
31
+ const collisionAB = (Math.abs(nodes[0].x - nodes[1].x) < 100
32
+ && Math.abs(nodes[0].y - nodes[1].y) < 100);
33
+ expect(collisionAB).toEqual(true);
34
+ });
35
+ it('can prevent a collision of rects after many iterations', () => {
36
+ const collisionForce = forceCollideRects()
37
+ .size(d => ([d.width, d.height]));
38
+ const nodes = [
39
+ {
40
+ label: 'A', width: 100, height: 100, x: 2, y: 2,
41
+ },
42
+ {
43
+ label: 'B', width: 100, height: 100, x: 3, y: 3,
44
+ },
45
+ {
46
+ label: 'C', width: 100, height: 100, x: 3, y: 2,
47
+ },
48
+ ];
49
+ forceSimulation()
50
+ .nodes(nodes)
51
+ .force('collision', collisionForce)
52
+ .tick(50);
53
+ const collisionAB = (Math.abs(nodes[0].x - nodes[1].x) < 100
54
+ && Math.abs(nodes[0].y - nodes[1].y) < 100);
55
+ expect(collisionAB).toEqual(false);
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,5 @@
1
+ export { default as AbstractSpatialOrScatterplot } from './AbstractSpatialOrScatterplot';
2
+ export { createQuadTree } from './quadtree';
3
+ export { forceCollideRects } from './force-collide-rects';
4
+ export { getOnHoverCallback } from './cursor';
5
+ export { getPointSizeDevicePixels, getPointOpacity, } from './dynamic-opacity';
@@ -0,0 +1,26 @@
1
+ import { quadtree } from 'd3-quadtree';
2
+ import range from 'lodash/range';
3
+ /**
4
+ * Create a d3-quadtree object for cells data points.
5
+ * @param {array} cellsEntries Array of [cellId, cell] tuples,
6
+ * resulting from running Object.entries on the cells object.
7
+ * @param {function} getCellCoords Given a cell object, return the
8
+ * spatial/scatterplot coordinates [x, y].
9
+ * @returns {object} Quadtree instance.
10
+ */
11
+ export function createQuadTree(obsEmbedding, getCellCoords) {
12
+ // Use the cellsEntries variable since it is already
13
+ // an array, converted by Object.entries().
14
+ // Only use cellsEntries in quadtree calculation if there is
15
+ // centroid data in the cells (i.e not just ids).
16
+ // eslint-disable-next-line no-unused-vars
17
+ if (!obsEmbedding) {
18
+ // Abort if the cells data is not yet available.
19
+ return null;
20
+ }
21
+ const tree = quadtree()
22
+ .x(i => getCellCoords(i)[0])
23
+ .y(i => getCellCoords(i)[1])
24
+ .addAll(range(obsEmbedding.shape[1]));
25
+ return tree;
26
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vitessce/scatterplot",
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/icons": "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
+ "clsx": "^1.1.1",
24
+ "d3-force": "^2.1.1",
25
+ "d3-quadtree": "^1.0.7",
26
+ "lodash": "^4.17.21"
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
+ }