@vitessce/statistical-plots 3.5.5 → 3.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Treemap.js ADDED
@@ -0,0 +1,217 @@
1
+ /* eslint-disable indent */
2
+ /* eslint-disable camelcase */
3
+ import React, { useMemo, useEffect, useRef } from 'react';
4
+ import { scaleOrdinal } from 'd3-scale';
5
+ import { select } from 'd3-selection';
6
+ import { treemap, treemapBinary, hierarchy as d3_hierarchy } from 'd3-hierarchy';
7
+ import { rollup as d3_rollup } from 'd3-array';
8
+ import { isEqual } from 'lodash-es';
9
+ import { colorArrayToString } from '@vitessce/sets-utils';
10
+ import { getDefaultColor, pluralize as plur } from '@vitessce/utils';
11
+
12
+ // Based on Observable's built-in DOM.uid function.
13
+ // This is intended to be used with SVG clipPaths
14
+ // which require a unique href value to reference
15
+ // other elements contained in the DOM.
16
+ function uidGenerator(prefix) {
17
+ let i = 0;
18
+ return () => {
19
+ i += 1;
20
+ return { id: `${prefix}-${i}`, href: `#${prefix}-${i}` };
21
+ };
22
+ }
23
+
24
+ // Create a d3-scale ordinal scale mapping set paths to color strings.
25
+ function getColorScale(setSelectionArr, setColorArr, theme) {
26
+ return scaleOrdinal()
27
+ .domain(setSelectionArr || [])
28
+ .range(
29
+ setSelectionArr
30
+ ?.map(setNamePath => (
31
+ setColorArr?.find(d => isEqual(d.path, setNamePath))?.color
32
+ || getDefaultColor(theme)
33
+ ))
34
+ ?.map(colorArrayToString) || [],
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Renders a treemap plot using D3.
40
+ * References:
41
+ * - https://observablehq.com/@d3/treemap-component
42
+ * - https://observablehq.com/@d3/treemap-stratify
43
+ * - https://observablehq.com/@d3/json-treemap
44
+ * - https://observablehq.com/@d3/nested-treemap
45
+ * @returns
46
+ */
47
+ export default function Treemap(props) {
48
+ const {
49
+ obsCounts,
50
+ obsColorEncoding,
51
+ hierarchyLevels,
52
+ theme,
53
+ width,
54
+ height,
55
+ obsType,
56
+ sampleType,
57
+ obsSetColor,
58
+ sampleSetColor,
59
+ obsSetSelection,
60
+ sampleSetSelection,
61
+ marginTop = 5,
62
+ marginRight = 5,
63
+ marginLeft = 80,
64
+ marginBottom,
65
+ } = props;
66
+
67
+ const hierarchyData = useMemo(() => {
68
+ // Support both sampleSet->obsSet and
69
+ // obsSet->sampleSet hierarchy modes
70
+ if (!obsCounts) {
71
+ return null;
72
+ }
73
+ let map;
74
+ if (isEqual(hierarchyLevels, ['sampleSet', 'obsSet'])) {
75
+ map = d3_rollup(
76
+ obsCounts,
77
+ D => D[0].value,
78
+ d => d.sampleSetPath,
79
+ d => d.obsSetPath,
80
+ );
81
+ } else if (isEqual(hierarchyLevels, ['obsSet', 'sampleSet'])) {
82
+ map = d3_rollup(
83
+ obsCounts,
84
+ D => D[0].value,
85
+ d => d.obsSetPath,
86
+ d => d.sampleSetPath,
87
+ );
88
+ } else {
89
+ throw new Error('Unexpected levels value.');
90
+ }
91
+ return d3_hierarchy(map);
92
+ }, [obsCounts, hierarchyLevels]);
93
+
94
+ const [obsSetColorScale, sampleSetColorScale] = useMemo(() => [
95
+ getColorScale(obsSetSelection, obsSetColor, theme),
96
+ getColorScale(sampleSetSelection, sampleSetColor, theme),
97
+ ], [obsSetSelection, sampleSetSelection, sampleSetColor, obsSetColor, theme]);
98
+
99
+ const treemapLeaves = useMemo(() => {
100
+ const treemapFunc = treemap()
101
+ .tile(treemapBinary)
102
+ .size([width, height])
103
+ .padding(1)
104
+ .round(true);
105
+
106
+ // When d3.hierarchy is passed a Map object,
107
+ // the nodes are represented like [key, value] tuples.
108
+ // So in `.sum` and `.sort` below,
109
+ // `d[1]` accesses the value (i.e., cell count).
110
+ // Reference: https://d3js.org/d3-hierarchy/hierarchy#hierarchy
111
+ const treemapLayout = treemapFunc(hierarchyData
112
+ .sum(d => d[1])
113
+ .sort((a, b) => b[1] - a[1]));
114
+ return treemapLayout.leaves();
115
+ }, [hierarchyData, width, height]);
116
+
117
+ const svgRef = useRef();
118
+
119
+ useEffect(() => {
120
+ const domElement = svgRef.current;
121
+
122
+ const svg = select(domElement);
123
+ svg.selectAll('g').remove();
124
+ svg
125
+ .attr('width', width)
126
+ .attr('height', height)
127
+ .attr('viewBox', [0, 0, width, height])
128
+ .attr('style', 'font: 10px sans-serif');
129
+
130
+ if (!treemapLeaves || !obsSetSelection || !sampleSetSelection) {
131
+ return;
132
+ }
133
+
134
+ // Add a group for each leaf of the hierarchy.
135
+ const leaf = svg.selectAll('g')
136
+ .data(treemapLeaves)
137
+ .join('g')
138
+ .attr('transform', d => `translate(${d.x0},${d.y0})`);
139
+
140
+ // Append a tooltip.
141
+ leaf.append('title')
142
+ .text((d) => {
143
+ const cellCount = d.data?.[1];
144
+ const primaryPathString = JSON.stringify(d.data[0]);
145
+ const secondaryPathString = JSON.stringify(d.parent.data[0]);
146
+ return `${cellCount.toLocaleString()} ${plur(obsType, cellCount)} in ${primaryPathString} and ${secondaryPathString}`;
147
+ });
148
+
149
+ const getLeafUid = uidGenerator('leaf');
150
+ const getClipUid = uidGenerator('clip');
151
+
152
+ const colorScale = obsColorEncoding === 'sampleSetSelection'
153
+ ? sampleSetColorScale
154
+ : obsSetColorScale;
155
+ const getPathForColoring = d => (
156
+ // eslint-disable-next-line no-nested-ternary
157
+ obsColorEncoding === 'sampleSetSelection'
158
+ ? (hierarchyLevels[0] === 'obsSet' ? d.data?.[0] : d.parent?.data?.[0])
159
+ : (hierarchyLevels[0] === 'sampleSet' ? d.data?.[0] : d.parent?.data?.[0])
160
+ );
161
+
162
+ // Append a color rectangle for each leaf.
163
+ leaf.append('rect')
164
+ .attr('id', (d) => {
165
+ // eslint-disable-next-line no-param-reassign
166
+ d.leafUid = getLeafUid();
167
+ return d.leafUid.id;
168
+ })
169
+ .attr('fill', d => colorScale(getPathForColoring(d)))
170
+ .attr('fill-opacity', 0.8)
171
+ .attr('width', d => d.x1 - d.x0)
172
+ .attr('height', d => d.y1 - d.y0);
173
+
174
+ // Append a clipPath to ensure text does not overflow.
175
+ leaf.append('clipPath')
176
+ .attr('id', (d) => {
177
+ // eslint-disable-next-line no-param-reassign
178
+ d.clipUid = getClipUid();
179
+ return d.clipUid.id;
180
+ })
181
+ .append('use')
182
+ .attr('xlink:href', d => d.leafUid.href);
183
+
184
+ // Append multiline text.
185
+ leaf.append('text')
186
+ .attr('clip-path', d => `url(${d.clipUid.href})`)
187
+ .selectAll('tspan')
188
+ .data(d => ([
189
+ // Each element in this array corresponds to a line of text.
190
+ d.data?.[0]?.at(-1),
191
+ d.parent?.data?.[0]?.at(-1),
192
+ `${d.data?.[1].toLocaleString()} ${plur(obsType, d.data?.[1])}`,
193
+ ]))
194
+ .join('tspan')
195
+ .attr('x', 3)
196
+ // eslint-disable-next-line no-unused-vars
197
+ .attr('y', (d, i, nodes) => `${(i === nodes.length - 1) * 0.3 + 1.1 + i * 0.9}em`)
198
+ .text(d => d);
199
+ }, [width, height, marginLeft, marginBottom, theme, marginTop, marginRight,
200
+ obsType, sampleType, treemapLeaves, sampleSetColor, sampleSetSelection,
201
+ obsSetSelection, obsSetColor, obsSetColorScale, sampleSetColorScale,
202
+ obsColorEncoding, hierarchyLevels,
203
+ ]);
204
+
205
+ return (
206
+ <svg
207
+ ref={svgRef}
208
+ style={{
209
+ top: 0,
210
+ left: 0,
211
+ width: `${width}px`,
212
+ height: `${height}px`,
213
+ position: 'relative',
214
+ }}
215
+ />
216
+ );
217
+ }
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { useId } from 'react-aria';
3
+ import { isEqual } from 'lodash-es';
4
+ import { TableCell, TableRow } from '@material-ui/core';
5
+ import { capitalize } from '@vitessce/utils';
6
+ import {
7
+ usePlotOptionsStyles, OptionSelect, OptionsContainer,
8
+ } from '@vitessce/vit-s';
9
+
10
+ export default function TreemapOptions(props) {
11
+ const {
12
+ children,
13
+ obsType,
14
+ sampleType,
15
+
16
+ hierarchyLevels,
17
+ setHierarchyLevels,
18
+
19
+ obsColorEncoding,
20
+ setObsColorEncoding,
21
+
22
+ } = props;
23
+
24
+ const treemapOptionsId = useId();
25
+ const classes = usePlotOptionsStyles();
26
+
27
+ function handleColorEncodingChange(event) {
28
+ setObsColorEncoding(event.target.value);
29
+ }
30
+
31
+ function handleHierarchyLevelsOrderingChange(event) {
32
+ if (event.target.value === 'sampleSet') {
33
+ setHierarchyLevels(['sampleSet', 'obsSet']);
34
+ } else {
35
+ setHierarchyLevels(['obsSet', 'sampleSet']);
36
+ }
37
+ }
38
+
39
+ const primaryHierarchyLevel = isEqual(hierarchyLevels, ['sampleSet', 'obsSet']) ? 'sampleSet' : 'obsSet';
40
+
41
+ return (
42
+ <OptionsContainer>
43
+ {children}
44
+ <TableRow>
45
+ <TableCell className={classes.labelCell} variant="head" scope="row">
46
+ <label
47
+ htmlFor={`cell-color-encoding-select-${treemapOptionsId}`}
48
+ >
49
+ Color Encoding
50
+ </label>
51
+ </TableCell>
52
+ <TableCell className={classes.inputCell} variant="body">
53
+ <OptionSelect
54
+ className={classes.select}
55
+ value={obsColorEncoding}
56
+ onChange={handleColorEncodingChange}
57
+ inputProps={{
58
+ id: `cell-color-encoding-select-${treemapOptionsId}`,
59
+ }}
60
+ >
61
+ <option value="cellSetSelection">{capitalize(obsType)} Sets</option>
62
+ <option value="sampleSetSelection">{capitalize(sampleType)} Sets</option>
63
+ </OptionSelect>
64
+ </TableCell>
65
+ </TableRow>
66
+ <TableRow>
67
+ <TableCell className={classes.labelCell} variant="head" scope="row">
68
+ <label
69
+ htmlFor={`treemap-set-hierarchy-levels-${treemapOptionsId}`}
70
+ >
71
+ Primary Hierarchy Level
72
+ </label>
73
+ </TableCell>
74
+ <TableCell className={classes.inputCell} variant="body">
75
+ <OptionSelect
76
+ className={classes.select}
77
+ value={primaryHierarchyLevel}
78
+ onChange={handleHierarchyLevelsOrderingChange}
79
+ inputProps={{
80
+ id: `hierarchy-level-select-${treemapOptionsId}`,
81
+ }}
82
+ >
83
+ <option value="obsSet">{capitalize(obsType)} Sets</option>
84
+ <option value="sampleSet">{capitalize(sampleType)} Sets</option>
85
+ </OptionSelect>
86
+ </TableCell>
87
+ </TableRow>
88
+ </OptionsContainer>
89
+ );
90
+ }
@@ -0,0 +1,261 @@
1
+ /* eslint-disable no-unused-vars */
2
+ import React, { useMemo } from 'react';
3
+ import {
4
+ TitleInfo,
5
+ useCoordination,
6
+ useLoaders,
7
+ useUrls,
8
+ useReady,
9
+ useGridItemSize,
10
+ useObsFeatureMatrixIndices,
11
+ useObsSetsData,
12
+ useSampleEdgesData,
13
+ useSampleSetsData,
14
+ } from '@vitessce/vit-s';
15
+ import { ViewType, COMPONENT_COORDINATION_TYPES, ViewHelpMapping } from '@vitessce/constants-internal';
16
+ import { treeToSelectedSetMap, treeToSetSizesBySetNames, mergeObsSets } from '@vitessce/sets-utils';
17
+ import { pluralize as plur, commaNumber, unnestMap, capitalize } from '@vitessce/utils';
18
+ import { InternMap } from 'internmap';
19
+ import { isEqual } from 'lodash-es';
20
+ import Treemap from './Treemap.js';
21
+ import { useStyles } from './styles.js';
22
+ import TreemapOptions from './TreemapOptions.js';
23
+
24
+ const DEFAULT_HIERARCHY_LEVELS = ['obsSet', 'sampleSet'];
25
+
26
+ export function TreemapSubscriber(props) {
27
+ const {
28
+ coordinationScopes,
29
+ removeGridComponent,
30
+ theme,
31
+ helpText = ViewHelpMapping.TREEMAP,
32
+ } = props;
33
+
34
+ const classes = useStyles();
35
+ const loaders = useLoaders();
36
+
37
+ // Get "props" from the coordination space.
38
+ const [{
39
+ dataset,
40
+ obsType,
41
+ featureType,
42
+ featureValueType,
43
+ obsFilter,
44
+ obsHighlight,
45
+ obsSetSelection,
46
+ obsSetFilter,
47
+ obsSelection,
48
+ obsSelectionMode,
49
+ obsSetHighlight,
50
+ obsSetColor,
51
+ obsColorEncoding,
52
+ additionalObsSets,
53
+ sampleType,
54
+ sampleSetSelection,
55
+ sampleSetFilter,
56
+ sampleSetColor,
57
+ sampleSelection,
58
+ sampleSelectionMode,
59
+ sampleFilter,
60
+ sampleFilterMode,
61
+ sampleHighlight,
62
+ hierarchyLevels,
63
+ }, {
64
+ setObsFilter,
65
+ setObsSelection,
66
+ setObsSetFilter,
67
+ setObsSetSelection,
68
+ setObsSelectionMode,
69
+ setObsFilterMode,
70
+ setObsHighlight,
71
+ setObsSetColor,
72
+ setObsColorEncoding,
73
+ setAdditionalObsSets,
74
+ setSampleFilter,
75
+ setSampleSetFilter,
76
+ setSampleFilterMode,
77
+ setSampleSelection,
78
+ setSampleSetSelection,
79
+ setSampleSelectionMode,
80
+ setSampleHighlight,
81
+ setSampleSetColor,
82
+ setHierarchyLevels,
83
+ }] = useCoordination(
84
+ COMPONENT_COORDINATION_TYPES[ViewType.TREEMAP],
85
+ coordinationScopes,
86
+ );
87
+
88
+ const [width, height, containerRef] = useGridItemSize();
89
+
90
+ // TODO: how to deal with multimodal cases (multiple obsIndex, one per modality)?
91
+ const [{ obsIndex }, matrixIndicesStatus, matrixIndicesUrls] = useObsFeatureMatrixIndices(
92
+ loaders, dataset, false,
93
+ { obsType, featureType, featureValueType },
94
+ );
95
+ const [{ obsSets }, obsSetsStatus, obsSetsUrls] = useObsSetsData(
96
+ loaders, dataset, true, {}, {},
97
+ { obsType },
98
+ );
99
+
100
+ const [{ sampleIndex, sampleSets }, sampleSetsStatus, sampleSetsUrls] = useSampleSetsData(
101
+ loaders,
102
+ dataset,
103
+ // TODO: support `false`, i.e., configurations in which
104
+ // there are no sampleSets
105
+ true,
106
+ { setSampleSetColor },
107
+ { sampleSetColor },
108
+ { sampleType },
109
+ );
110
+
111
+ const [{ sampleEdges }, sampleEdgesStatus, sampleEdgesUrls] = useSampleEdgesData(
112
+ loaders,
113
+ dataset,
114
+ // TODO: support `false`, i.e., configurations in which
115
+ // there are no sampleEdges
116
+ true,
117
+ {},
118
+ {},
119
+ { obsType, sampleType },
120
+ );
121
+
122
+ const isReady = useReady([
123
+ matrixIndicesStatus,
124
+ obsSetsStatus,
125
+ sampleSetsStatus,
126
+ sampleEdgesStatus,
127
+ ]);
128
+ const urls = useUrls([
129
+ matrixIndicesUrls,
130
+ obsSetsUrls,
131
+ sampleSetsUrls,
132
+ sampleEdgesUrls,
133
+ ]);
134
+
135
+ const mergedObsSets = useMemo(
136
+ () => mergeObsSets(obsSets, additionalObsSets),
137
+ [obsSets, additionalObsSets],
138
+ );
139
+ const mergedSampleSets = useMemo(
140
+ () => mergeObsSets(sampleSets, null),
141
+ [sampleSets],
142
+ );
143
+
144
+ const obsCount = obsIndex?.length || 0;
145
+ const sampleCount = sampleIndex?.length || 0;
146
+
147
+ // TODO: use obsFilter / sampleFilter to display
148
+ // _all_ cells/samples in gray / transparent in background,
149
+ // and use obsSetSelection/sampleSetSelection to display
150
+ // the _selected_ samples in color in the foreground.
151
+ const [obsCounts, sampleCounts] = useMemo(() => {
152
+ const obsResult = new InternMap([], JSON.stringify);
153
+ const sampleResult = new InternMap([], JSON.stringify);
154
+
155
+ const hasSampleSetSelection = (
156
+ Array.isArray(sampleSetSelection)
157
+ && sampleSetSelection.length > 0
158
+ );
159
+ const hasCellSetSelection = (
160
+ Array.isArray(obsSetSelection)
161
+ && obsSetSelection.length > 0
162
+ );
163
+
164
+ const sampleSetKeys = hasSampleSetSelection ? sampleSetSelection : [null];
165
+ const cellSetKeys = hasCellSetSelection ? obsSetSelection : [null];
166
+
167
+ // First level: cell set
168
+ cellSetKeys.forEach((cellSetKey) => {
169
+ obsResult.set(cellSetKey, new InternMap([], JSON.stringify));
170
+ // Second level: sample set
171
+ sampleSetKeys.forEach((sampleSetKey) => {
172
+ obsResult.get(cellSetKey).set(sampleSetKey, 0);
173
+ });
174
+ });
175
+
176
+ const sampleSetSizes = treeToSetSizesBySetNames(
177
+ mergedSampleSets, sampleSetSelection, sampleSetSelection, sampleSetColor, theme,
178
+ );
179
+
180
+ sampleSetKeys.forEach((sampleSetKey) => {
181
+ const sampleSetSize = sampleSetSizes.find(d => isEqual(d.setNamePath, sampleSetKey))?.size;
182
+ sampleResult.set(sampleSetKey, sampleSetSize || 0);
183
+ });
184
+
185
+ if (mergedObsSets && obsSetSelection) {
186
+ const sampleIdToSetMap = sampleSets && sampleSetSelection
187
+ ? treeToSelectedSetMap(sampleSets, sampleSetSelection)
188
+ : null;
189
+ const cellIdToSetMap = treeToSelectedSetMap(mergedObsSets, obsSetSelection);
190
+
191
+ for (let i = 0; i < obsIndex.length; i += 1) {
192
+ const obsId = obsIndex[i];
193
+
194
+ const cellSet = cellIdToSetMap?.get(obsId);
195
+ const sampleId = sampleEdges?.get(obsId);
196
+ const sampleSet = sampleId ? sampleIdToSetMap?.get(sampleId) : null;
197
+
198
+ if (hasSampleSetSelection && !sampleSet) {
199
+ // Skip this sample if it is not in the selected sample set.
200
+ // eslint-disable-next-line no-continue
201
+ continue;
202
+ }
203
+ const prevObsCount = obsResult.get(cellSet)?.get(sampleSet);
204
+ obsResult.get(cellSet)?.set(sampleSet, prevObsCount + 1);
205
+ }
206
+ }
207
+
208
+ return [
209
+ unnestMap(obsResult, ['obsSetPath', 'sampleSetPath', 'value']),
210
+ unnestMap(sampleResult, ['sampleSetPath', 'value']),
211
+ ];
212
+ }, [obsIndex, sampleEdges, sampleSets, obsSetColor,
213
+ sampleSetColor, mergedObsSets, obsSetSelection, mergedSampleSets,
214
+ // TODO: consider filtering-related coordination values
215
+ ]);
216
+
217
+ return (
218
+ <TitleInfo
219
+ title={`Treemap of ${capitalize(plur(obsType, 2))}`}
220
+ info={`${commaNumber(obsCount)} ${plur(obsType, obsCount)} from ${commaNumber(sampleCount)} ${plur(sampleType, sampleCount)}`}
221
+ removeGridComponent={removeGridComponent}
222
+ urls={urls}
223
+ theme={theme}
224
+ isReady={isReady}
225
+ helpText={helpText}
226
+ options={(
227
+ <TreemapOptions
228
+ obsType={obsType}
229
+ sampleType={sampleType}
230
+ obsColorEncoding={obsColorEncoding}
231
+ setObsColorEncoding={setObsColorEncoding}
232
+ hierarchyLevels={hierarchyLevels || DEFAULT_HIERARCHY_LEVELS}
233
+ setHierarchyLevels={setHierarchyLevels}
234
+ // TODO:
235
+ // - Add option to only include cells in treemap which express selected gene
236
+ // above some threshold (kind of like a dot plot)
237
+ // - Add option to _only_ consider sampleSets or obsSets
238
+ // (not both sampleSets and obsSets)
239
+ />
240
+ )}
241
+ >
242
+ <div ref={containerRef} className={classes.vegaContainer}>
243
+ <Treemap
244
+ obsCounts={obsCounts}
245
+ sampleCounts={sampleCounts}
246
+ obsColorEncoding={obsColorEncoding}
247
+ hierarchyLevels={hierarchyLevels || DEFAULT_HIERARCHY_LEVELS}
248
+ theme={theme}
249
+ width={width}
250
+ height={height}
251
+ obsType={obsType}
252
+ sampleType={sampleType}
253
+ obsSetColor={obsSetColor}
254
+ sampleSetColor={sampleSetColor}
255
+ obsSetSelection={obsSetSelection}
256
+ sampleSetSelection={sampleSetSelection}
257
+ />
258
+ </div>
259
+ </TitleInfo>
260
+ );
261
+ }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ export { CellSetSizesPlotSubscriber } from './CellSetSizesPlotSubscriber.js';
3
3
  export { ExpressionHistogramSubscriber } from './ExpressionHistogramSubscriber.js';
4
4
  export { DotPlotSubscriber } from './DotPlotSubscriber.js';
5
5
  export { FeatureBarPlotSubscriber } from './FeatureBarPlotSubscriber.js';
6
+ export { TreemapSubscriber } from './TreemapSubscriber.js';
6
7
  export { default as CellSetSizesPlot } from './CellSetSizesPlot.js';
7
8
  export { default as CellSetExpressionPlot } from './CellSetExpressionPlot.js';
8
9
  export { default as ExpressionHistogram } from './ExpressionHistogram.js';