@vitessce/statistical-plots 3.3.4 → 3.3.5

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.
@@ -1,9 +1,65 @@
1
- import React from 'react';
2
- import { clamp } from 'lodash-es';
3
- import { VegaPlot, VEGA_THEMES, DATASET_NAME } from '@vitessce/vega';
1
+ /* eslint-disable indent */
2
+ /* eslint-disable camelcase */
3
+ import React, { useMemo, useEffect, useRef } from 'react';
4
+ import { scaleLinear } from 'd3-scale';
5
+ import { scale as vega_scale } from 'vega-scale';
6
+ import { axisBottom, axisLeft } from 'd3-axis';
7
+ import {
8
+ bin,
9
+ min,
10
+ max,
11
+ rollup as d3_rollup,
12
+ mean as d3_mean,
13
+ deviation as d3_deviation,
14
+ ascending as d3_ascending,
15
+ map as d3_map,
16
+ quantileSorted,
17
+ } from 'd3-array';
18
+ import { area as d3_area, curveBasis } from 'd3-shape';
19
+ import { select } from 'd3-selection';
4
20
  import { colorArrayToString } from '@vitessce/sets-utils';
5
21
  import { capitalize } from '@vitessce/utils';
6
22
 
23
+ const scaleBand = vega_scale('band');
24
+
25
+ const GROUP_KEY = 'set';
26
+ const VALUE_KEY = 'value';
27
+
28
+ // Reference: https://github.com/d3/d3-array/issues/180#issuecomment-851378012
29
+ function summarize(iterable, keepZeros) {
30
+ const values = d3_map(iterable, d => d[VALUE_KEY])
31
+ .filter(d => keepZeros || d !== 0.0)
32
+ .sort(d3_ascending);
33
+ const minVal = values[0];
34
+ const maxVal = values[values.length - 1];
35
+ const q1 = quantileSorted(values, 0.25);
36
+ const q2 = quantileSorted(values, 0.5);
37
+ const q3 = quantileSorted(values, 0.75);
38
+ const iqr = q3 - q1; // interquartile range
39
+ const r0 = Math.max(minVal, q1 - iqr * 1.5);
40
+ const r1 = Math.min(maxVal, q3 + iqr * 1.5);
41
+ let i = -1;
42
+ while (values[++i] < r0);
43
+ const w0 = values[i];
44
+ while (values[++i] <= r1);
45
+ const w1 = values[i - 1];
46
+
47
+ // Chauvenet
48
+ // Reference: https://en.wikipedia.org/wiki/Chauvenet%27s_criterion
49
+ const mean = d3_mean(values);
50
+ const stdv = d3_deviation(values);
51
+ const c0 = mean - 3 * stdv;
52
+ const c1 = mean + 3 * stdv;
53
+
54
+ return {
55
+ quartiles: [q1, q2, q3],
56
+ range: [r0, r1],
57
+ whiskers: [w0, w1],
58
+ chauvenetRange: [c0, c1],
59
+ nonOutliers: values.filter(v => c0 <= v && v <= c1),
60
+ };
61
+ }
62
+
7
63
  /**
8
64
  * Gene expression histogram displayed as a bar chart,
9
65
  * implemented with the VegaPlot component.
@@ -27,252 +83,228 @@ import { capitalize } from '@vitessce/utils';
27
83
  */
28
84
  export default function CellSetExpressionPlot(props) {
29
85
  const {
30
- domainMax = 100,
86
+ yMin: yMinProp,
87
+ yUnits,
88
+ jitter,
31
89
  colors,
32
90
  data,
33
91
  theme,
34
92
  width,
35
93
  height,
36
- marginRight = 90,
94
+ marginTop = 5,
95
+ marginRight = 5,
96
+ marginLeft = 50,
37
97
  marginBottom,
38
98
  obsType,
99
+ featureType,
39
100
  featureValueType,
40
101
  featureValueTransformName,
41
102
  } = props;
103
+
104
+ const svgRef = useRef();
105
+
42
106
  // Get the max characters in an axis label for autsizing the bottom margin.
43
- const maxCharactersForLabel = data.reduce((acc, val) => {
107
+ const maxCharactersForLabel = useMemo(() => data.reduce((acc, val) => {
44
108
  // eslint-disable-next-line no-param-reassign
45
- acc = acc === undefined || val.set.length > acc ? val.set.length : acc;
109
+ acc = acc === undefined || val[GROUP_KEY].length > acc ? val[GROUP_KEY].length : acc;
46
110
  return acc;
47
- }, 0);
48
- // Use a square-root term because the angle of the labels is 45 degrees (see below)
49
- // so the perpendicular distance to the bottom of the labels is proportional to the
50
- // square root of the length of the labels along the imaginary hypotenuse.
51
- // 30 is an estimate of the pixel size of a given character and seems to work well.
52
- const autoMarginBottom = marginBottom
53
- || 30 + Math.sqrt(maxCharactersForLabel / 2) * 30;
54
- // Manually set the color scale so that Vega-Lite does
55
- // not choose the colors automatically.
56
- const colorScale = {
57
- domain: colors.map(d => d.name),
58
- range: colors.map(d => colorArrayToString(d.color)),
59
- };
111
+ }, 0), [data]);
60
112
 
61
- const plotWidth = clamp(width - marginRight, 10, Infinity);
62
- const plotHeight = clamp(height - autoMarginBottom, 10, Infinity);
63
-
64
- const numBands = colors.length;
65
- const bandWidth = plotWidth / numBands;
66
-
67
- const rectColor = (theme === 'dark' ? 'white' : 'black');
68
-
69
- const spec = {
70
- $schema: 'https://vega.github.io/schema/vega/v5.json',
71
- description: `A violin plot showing distributions of expression levels for selected ${obsType} sets.`,
72
- width: plotWidth,
73
- height: plotHeight,
74
- config: {
75
- ...VEGA_THEMES[theme],
76
- axisBand: {
77
- bandPosition: 1,
78
- tickExtra: true,
79
- tickOffset: 0,
80
- },
81
- },
82
-
83
- signals: [
84
- { name: 'bandWidth', value: bandWidth },
85
- { name: 'width', value: plotWidth },
86
- { name: 'height', value: plotHeight },
87
- { name: 'trim', value: true },
88
- ],
89
-
90
- data: [
91
- {
92
- name: 'density',
93
- source: DATASET_NAME,
94
- transform: [
95
- {
96
- type: 'kde',
97
- field: 'value',
98
- groupby: ['set'],
99
- bandwidth: 0,
100
- extent: [0, domainMax],
101
- },
102
- ],
103
- },
104
- {
105
- name: 'stats',
106
- source: DATASET_NAME,
107
- transform: [
108
- {
109
- type: 'aggregate',
110
- groupby: ['set'],
111
- fields: ['value', 'value', 'value'],
112
- ops: ['q1', 'median', 'q3'],
113
- as: ['q1', 'median', 'q3'],
114
- },
115
- ],
116
- },
117
- ],
118
-
119
- scales: [
120
- {
121
- name: 'layout',
122
- type: 'band',
123
- range: 'width',
124
- domain: { data: DATASET_NAME, field: 'set' },
125
- },
126
- {
127
- name: 'yscale',
128
- type: 'linear',
129
- range: 'height',
130
- domain: [0, domainMax],
131
- },
132
- {
133
- name: 'wscale',
134
- type: 'linear',
135
- range: [0, { signal: 'bandWidth' }],
136
- domain: { data: 'density', field: 'density' },
137
- },
138
- {
139
- name: 'wscaleReversed',
140
- type: 'linear',
141
- reverse: true,
142
- range: [0, { signal: 'bandWidth' }],
143
- domain: { data: 'density', field: 'density' },
144
- },
145
- {
146
- name: 'color',
147
- type: 'ordinal',
148
- ...colorScale,
149
- },
150
- ],
151
-
152
- axes: [
153
- {
154
- orient: 'left',
155
- scale: 'yscale',
156
- zindex: 1,
157
- title: (featureValueTransformName && featureValueTransformName !== 'None')
158
- ? [`${featureValueTransformName}-Transformed`, `${capitalize(featureValueType)} Values`]
159
- : `${capitalize(featureValueType)} Values`,
160
- },
161
- {
162
- orient: 'bottom',
163
- scale: 'layout',
164
- tickCount: 5,
165
- zindex: 1,
166
- title: `${capitalize(obsType)} Set`,
167
- labelAngle: -45,
168
- labelAlign: 'right',
169
- },
170
- ],
171
-
172
- marks: [
173
- {
174
- type: 'group',
175
- from: {
176
- facet: {
177
- data: 'density',
178
- name: 'violin',
179
- groupby: 'set',
180
- },
181
- },
182
-
183
- encode: {
184
- enter: {
185
- xc: { scale: 'layout', field: 'set', band: 0.5 },
186
- width: { signal: 'bandWidth' },
187
- height: { signal: 'height' },
188
- },
189
- },
190
-
191
- data: [
192
- {
193
- name: 'summary',
194
- source: 'stats',
195
- transform: [
196
- {
197
- type: 'filter',
198
- expr: 'datum.set === parent.set',
199
- },
200
- ],
201
- },
202
- ],
203
-
204
- marks: [
205
- {
206
- type: 'area',
207
- orient: 'vertical',
208
- from: { data: 'violin' },
209
- encode: {
210
- enter: {
211
- fill: { scale: 'color', field: { parent: 'set' } },
212
- },
213
- update: {
214
- width: { scale: 'wscale', field: 'density' },
215
- xc: { signal: 'bandWidth / 2' },
216
- y2: { scale: 'yscale', field: 'value' },
217
- y: { scale: 'yscale', value: 0 },
218
- },
219
- },
220
- },
221
- {
222
- type: 'area',
223
- orient: 'vertical',
224
- from: { data: 'violin' },
225
- encode: {
226
- enter: {
227
- fill: { scale: 'color', field: { parent: 'set' } },
228
- },
229
- update: {
230
- width: { scale: 'wscaleReversed', field: 'density' },
231
- xc: { signal: 'bandWidth' },
232
- y2: { scale: 'yscale', field: 'value' },
233
- y: { scale: 'yscale', value: 0 },
234
- },
235
- },
236
- },
237
- {
238
- type: 'rect',
239
- from: { data: 'summary' },
240
- encode: {
241
- enter: {
242
- fill: { value: rectColor },
243
- width: { value: 2 },
244
- },
245
- update: {
246
- y: { scale: 'yscale', field: 'q1' },
247
- y2: { scale: 'yscale', field: 'q3' },
248
- xc: { signal: 'bandWidth / 2' },
249
- },
250
- },
251
- },
252
- {
253
- type: 'rect',
254
- from: { data: 'summary' },
255
- encode: {
256
- enter: {
257
- fill: { value: rectColor },
258
- height: { value: 2 },
259
- width: { value: 8 },
260
- },
261
- update: {
262
- y: { scale: 'yscale', field: 'median' },
263
- xc: { signal: 'bandWidth / 2' },
264
- },
265
- },
266
- },
267
- ],
268
- },
269
- ],
270
- };
113
+ useEffect(() => {
114
+ const domElement = svgRef.current;
115
+
116
+ const transformPrefix = (featureValueTransformName && featureValueTransformName !== 'None')
117
+ ? `${featureValueTransformName}-Transformed `
118
+ : '';
119
+ const unitSuffix = yUnits ? ` (${yUnits})` : '';
120
+ const yTitle = `${transformPrefix}${capitalize(featureValueType)}${unitSuffix}`;
121
+
122
+ const xTitle = `${capitalize(obsType)} Set`;
123
+
124
+ // Use a square-root term because the angle of the labels is 45 degrees (see below)
125
+ // so the perpendicular distance to the bottom of the labels is proportional to the
126
+ // square root of the length of the labels along the imaginary hypotenuse.
127
+ // 30 is an estimate of the pixel size of a given character and seems to work well.
128
+ const autoMarginBottom = marginBottom
129
+ || 30 + Math.sqrt(maxCharactersForLabel / 2) * 30;
130
+
131
+ const rectColor = (theme === 'dark' ? 'white' : 'black');
132
+
133
+ const svg = select(domElement);
134
+ svg.selectAll('g').remove();
135
+ svg
136
+ .attr('width', width)
137
+ .attr('height', height);
138
+
139
+ const g = svg
140
+ .append('g')
141
+ .attr('width', width)
142
+ .attr('height', height);
143
+
144
+ const groupNames = colors.map(d => d.name);
145
+
146
+ // Manually set the color scale so that Vega-Lite does
147
+ // not choose the colors automatically.
148
+ const colorScale = {
149
+ domain: colors.map(d => d.name),
150
+ range: colors.map(d => colorArrayToString(d.color)),
151
+ };
152
+
153
+ // Remove outliers on a per-group basis.
154
+ const groupedSummaries = Array.from(
155
+ d3_rollup(data, groupData => summarize(groupData, true), d => d[GROUP_KEY]),
156
+ ([key, value]) => ({ key, value }),
157
+ );
158
+ const groupedData = groupedSummaries
159
+ .map(({ key, value }) => ({ key, value: value.nonOutliers }));
160
+ const trimmedData = groupedData.map(kv => kv.value).flat();
161
+
162
+ const innerWidth = width - marginLeft;
163
+ const innerHeight = height - autoMarginBottom;
164
+
165
+ const xGroup = scaleBand()
166
+ .range([marginLeft, width - marginRight])
167
+ .domain(groupNames)
168
+ .padding(0.1);
169
+
170
+ const yMin = (yMinProp === null ? Math.min(0, min(trimmedData)) : yMinProp);
171
+
172
+ // For the y domain, use the yMin prop
173
+ // to support a use case such as 'Aspect Ratio',
174
+ // where the domain minimum should be 1 rather than 0.
175
+ const y = scaleLinear()
176
+ .domain([yMin, max(trimmedData)])
177
+ .range([innerHeight, marginTop]);
178
+
179
+ const histogram = bin()
180
+ .thresholds(y.ticks(16))
181
+ .domain(y.domain());
182
+
183
+ const groupBins = groupedData.map(kv => ({ key: kv.key, value: histogram(kv.value) }));
184
+
185
+ const groupBinsMax = max(groupBins.flatMap(d => d.value.map(v => v.length)));
186
+
187
+ const x = scaleLinear()
188
+ .domain([-groupBinsMax, groupBinsMax])
189
+ .range([0, xGroup.bandwidth()]);
190
+
191
+ const area = d3_area()
192
+ .x0(d => (jitter ? x(0) : x(-d.length)))
193
+ .x1(d => x(d.length))
194
+ .y(d => y(d.x0))
195
+ .curve(curveBasis);
196
+
197
+ // Violin areas
198
+ g
199
+ .selectAll('violin')
200
+ .data(groupBins)
201
+ .enter()
202
+ .append('g')
203
+ .attr('transform', d => `translate(${xGroup(d.key)},0)`)
204
+ .style('fill', d => colorScale.range[groupNames.indexOf(d.key)])
205
+ .append('path')
206
+ .datum(d => d.value)
207
+ .style('stroke', 'none')
208
+ .attr('d', d => area(d));
209
+
210
+ // Whiskers
211
+ const whiskerGroups = g.selectAll('whiskers')
212
+ .data(groupedSummaries)
213
+ .enter()
214
+ .append('g')
215
+ .attr('transform', d => `translate(${xGroup(d.key)},0)`);
216
+ whiskerGroups.append('line')
217
+ .datum(d => d.value)
218
+ .attr('stroke', rectColor)
219
+ .attr('x1', xGroup.bandwidth() / 2)
220
+ .attr('x2', xGroup.bandwidth() / 2)
221
+ .attr('y1', d => y(d.quartiles[0]))
222
+ .attr('y2', d => y(d.quartiles[2]))
223
+ .attr('stroke-width', 2);
224
+ whiskerGroups.append('line')
225
+ .datum(d => d.value)
226
+ .attr('stroke', rectColor)
227
+ .attr('x1', xGroup.bandwidth() / 2 - (jitter ? 0 : 4))
228
+ .attr('x2', xGroup.bandwidth() / 2 + 4)
229
+ .attr('y1', d => y(d.quartiles[1]))
230
+ .attr('y2', d => y(d.quartiles[1]))
231
+ .attr('stroke-width', 2);
232
+
233
+ // Jittered points
234
+ if (jitter) {
235
+ groupedData.forEach(({ key, value }) => {
236
+ const groupG = g.append('g');
237
+ groupG.selectAll('point')
238
+ .data(value)
239
+ .enter()
240
+ .append('circle')
241
+ .attr('transform', `translate(${xGroup(key)},0)`)
242
+ .style('stroke', 'none')
243
+ .style('fill', 'silver')
244
+ .style('opacity', '0.1')
245
+ .attr('cx', () => 5 + Math.random() * ((xGroup.bandwidth() / 2) - 10))
246
+ .attr('cy', d => y(d))
247
+ .attr('r', 2);
248
+ });
249
+ }
250
+
251
+ // Y-axis ticks
252
+ g
253
+ .append('g')
254
+ .attr('transform', `translate(${marginLeft},0)`)
255
+ .call(axisLeft(y))
256
+ .selectAll('text')
257
+ .style('font-size', '11px');
258
+
259
+ // X-axis ticks
260
+ g
261
+ .append('g')
262
+ .attr('transform', `translate(0,${innerHeight})`)
263
+ .style('font-size', '14px')
264
+ .call(axisBottom(xGroup))
265
+ .selectAll('text')
266
+ .style('font-size', '11px')
267
+ .attr('dx', '-6px')
268
+ .attr('dy', '6px')
269
+ .attr('transform', 'rotate(-45)')
270
+ .style('text-anchor', 'end');
271
+
272
+ // Y-axis title
273
+ g
274
+ .append('text')
275
+ .attr('text-anchor', 'middle')
276
+ .attr('x', -innerHeight / 2)
277
+ .attr('y', 15)
278
+ .attr('transform', 'rotate(-90)')
279
+ .text(yTitle)
280
+ .style('font-size', '12px')
281
+ .style('fill', 'white');
282
+
283
+ // X-axis title
284
+ g
285
+ .append('text')
286
+ .attr('text-anchor', 'middle')
287
+ .attr('x', marginLeft + innerWidth / 2)
288
+ .attr('y', height - 10)
289
+ .text(xTitle)
290
+ .style('font-size', '12px')
291
+ .style('fill', 'white');
292
+ }, [width, height, data, marginLeft, marginBottom, colors,
293
+ jitter, theme, yMinProp, marginTop, marginRight, featureType,
294
+ featureValueType, featureValueTransformName, yUnits, obsType,
295
+ maxCharactersForLabel,
296
+ ]);
271
297
 
272
298
  return (
273
- <VegaPlot
274
- data={data}
275
- spec={spec}
299
+ <svg
300
+ ref={svgRef}
301
+ style={{
302
+ top: 0,
303
+ left: 0,
304
+ width: `${width}px`,
305
+ height: `${height}px`,
306
+ position: 'relative',
307
+ }}
276
308
  />
277
309
  );
278
310
  }
@@ -112,6 +112,9 @@ export function CellSetExpressionPlotSubscriber(props) {
112
112
  downloadButtonVisible,
113
113
  removeGridComponent,
114
114
  theme,
115
+ jitter = false,
116
+ yMin = null,
117
+ yUnits = null,
115
118
  } = props;
116
119
 
117
120
  const classes = useStyles();
@@ -172,7 +175,7 @@ export function CellSetExpressionPlotSubscriber(props) {
172
175
  obsSetsUrls,
173
176
  ]);
174
177
 
175
- const [expressionArr, setArr, expressionMax] = useExpressionByCellSet(
178
+ const [expressionArr, setArr] = useExpressionByCellSet(
176
179
  expressionData, obsIndex, cellSets, additionalCellSets,
177
180
  geneSelection, cellSetSelection, cellSetColor,
178
181
  featureValueTransform, featureValueTransformCoefficient,
@@ -209,13 +212,16 @@ export function CellSetExpressionPlotSubscriber(props) {
209
212
  <div ref={containerRef} className={classes.vegaContainer}>
210
213
  {expressionArr ? (
211
214
  <CellSetExpressionPlot
212
- domainMax={expressionMax}
215
+ yMin={yMin}
216
+ yUnits={yUnits}
217
+ jitter={jitter}
213
218
  colors={setArr}
214
219
  data={expressionArr}
215
220
  theme={theme}
216
221
  width={width}
217
222
  height={height}
218
223
  obsType={obsType}
224
+ featureType={featureType}
219
225
  featureValueType={featureValueType}
220
226
  featureValueTransformName={selectedTransformName}
221
227
  />
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect, useCallback } from 'react';
2
2
  import { clamp, debounce } from 'lodash-es';
3
3
  import { VegaPlot, VEGA_THEMES } from '@vitessce/vega';
4
+ import { capitalize, pluralize } from '@vitessce/utils';
4
5
 
5
6
  /**
6
7
  * We use debounce, so that onSelect is called only after the user has finished the selection.
@@ -30,6 +31,9 @@ import { VegaPlot, VEGA_THEMES } from '@vitessce/vega';
30
31
  export default function ExpressionHistogram(props) {
31
32
  const {
32
33
  geneSelection,
34
+ obsType,
35
+ featureType,
36
+ featureValueType,
33
37
  data,
34
38
  theme,
35
39
  width,
@@ -41,9 +45,13 @@ export default function ExpressionHistogram(props) {
41
45
 
42
46
  const [selectedRanges, setSelectedRanges] = useState([]);
43
47
 
48
+ const isExpression = (
49
+ featureType === 'gene' && featureValueType === 'expression'
50
+ );
51
+ // eslint-disable-next-line no-nested-ternary
44
52
  const xTitle = geneSelection && geneSelection.length >= 1
45
- ? 'Normalized Expression Value'
46
- : 'Total Normalized Transcript Count';
53
+ ? (isExpression ? `Expression Value (${geneSelection[0]})` : `${geneSelection[0]}`)
54
+ : (isExpression ? 'Total Transcript Count' : 'Sum of Feature Values');
47
55
 
48
56
  const spec = {
49
57
  data: { values: data },
@@ -58,7 +66,7 @@ export default function ExpressionHistogram(props) {
58
66
  y: {
59
67
  type: 'quantitative',
60
68
  aggregate: 'count',
61
- title: 'Number of Cells',
69
+ title: `Number of ${capitalize(pluralize(obsType, 2))}`,
62
70
  },
63
71
  color: { value: 'gray' },
64
72
  opacity: {
@@ -86,8 +86,7 @@ export function ExpressionHistogramSubscriber(props) {
86
86
  return obsIndex.map((cellId, cellIndex) => {
87
87
  const value = expressionData[0][cellIndex];
88
88
  // Create new cellColors map based on the selected gene.
89
- const normValue = value * 100 / 255;
90
- const newItem = { value: normValue, gene: firstGeneSelected, cellId };
89
+ const newItem = { value, gene: firstGeneSelected, cellId };
91
90
  return newItem;
92
91
  });
93
92
  }
@@ -96,7 +95,7 @@ export function ExpressionHistogramSubscriber(props) {
96
95
  return obsIndex.map((cellId, cellIndex) => {
97
96
  const values = obsFeatureMatrix.data
98
97
  .subarray(cellIndex * numGenes, (cellIndex + 1) * numGenes);
99
- const sumValue = sum(values) * 100 / 255;
98
+ const sumValue = sum(values);
100
99
  const newItem = { value: sumValue, gene: null, cellId };
101
100
  return newItem;
102
101
  });
@@ -121,7 +120,7 @@ export function ExpressionHistogramSubscriber(props) {
121
120
 
122
121
  return (
123
122
  <TitleInfo
124
- title={`Expression Histogram${(firstGeneSelected ? ` (${firstGeneSelected})` : '')}`}
123
+ title={`Histogram${(firstGeneSelected ? ` (${firstGeneSelected})` : '')}`}
125
124
  closeButtonVisible={closeButtonVisible}
126
125
  downloadButtonVisible={downloadButtonVisible}
127
126
  removeGridComponent={removeGridComponent}
@@ -132,6 +131,9 @@ export function ExpressionHistogramSubscriber(props) {
132
131
  <div ref={containerRef} className={classes.vegaContainer}>
133
132
  <ExpressionHistogram
134
133
  geneSelection={geneSelection}
134
+ obsType={obsType}
135
+ featureType={featureType}
136
+ featureValueType={featureValueType}
135
137
  onSelect={onSelect}
136
138
  data={data}
137
139
  theme={theme}