vitessce
Version:
Vitessce app and React component library
257 lines (244 loc) • 8.16 kB
JavaScript
import React, { useCallback, useState, useEffect } from 'react';
import Grid from '@material-ui/core/Grid';
import Slider from '@material-ui/core/Slider';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import ChannelOptions from './ChannelOptions';
import { DOMAINS } from './constants';
import { getSourceFromLoader } from '../../utils';
import { getMultiSelectionStats } from './utils';
import {
ChannelSelectionDropdown,
ChannelVisibilityCheckbox,
} from './shared-channel-controls';
// Returns an rgb string for display, and changes the color (arr)
// to use a grey for light theme + white color or if the colormap is on.
export const toRgbUIString = (on, arr, theme) => {
const color = on || (theme === 'light' && arr.every(i => i === 255))
? [220, 220, 220]
: arr;
return `rgb(${color})`;
};
function abbreviateNumber(value) {
// Return an abbreviated representation of value, in 5 characters or less.
const maxLength = 5;
let maxNaiveDigits = maxLength;
/* eslint-disable no-plusplus */
if (!Number.isInteger(value)) {
--maxNaiveDigits;
} // Wasted on "."
if (value < 1) {
--maxNaiveDigits;
} // Wasted on "0."
/* eslint-disable no-plusplus */
const naive = Intl.NumberFormat('en-US', {
maximumSignificantDigits: maxNaiveDigits,
useGrouping: false,
}).format(value);
if (naive.length <= maxLength) return naive;
// "e+9" consumes 3 characters, so if we even had two significant digits,
// it would take take us to six characters, including the decimal point.
return value.toExponential(0);
}
/**
* Slider for controlling current colormap.
* @prop {string} color Current color for this channel.
* @prop {arry} slider Current value of the slider.
* @prop {function} handleChange Callback for each slider change.
* @prop {array} domain Current max/min allowable slider values.
*/
function ChannelSlider({
color,
slider = [0, 0],
handleChange,
domain = [0, 0],
dtype,
disabled,
}) {
const [min, max] = domain;
const sliderCopy = slider.slice();
if (slider[0] < min) {
sliderCopy[0] = min;
}
if (slider[1] > max) {
sliderCopy[1] = max;
}
const handleChangeDebounced = useCallback(
debounce(handleChange, 3, { trailing: true }),
[handleChange],
);
const step = max - min < 500 && dtype === 'Float32' ? (max - min) / 500 : 1;
return (
<Slider
value={slider}
valueLabelFormat={abbreviateNumber}
onChange={(e, v) => handleChangeDebounced(v)}
valueLabelDisplay="auto"
getAriaLabel={() => `${color}-${slider}`}
min={min}
max={max}
step={step}
orientation="horizontal"
style={{ color, marginTop: '7px' }}
disabled={disabled}
/>
);
}
/**
* Controller for the handling the colormapping sliders.
* @prop {boolean} visibility Whether or not this channel is "on"
* @prop {array} slider Current slider range.
* @prop {array} color Current color for this channel.
* @prop {array} domain Current max/min for this channel.
* @prop {string} dimName Name of the dimensions this slider controls (usually "channel").
* @prop {boolean} colormapOn Whether or not the colormap (viridis, magma etc.) is on.
* @prop {object} channelOptions All available options for this dimension (i.e channel names).
* @prop {function} handlePropertyChange Callback for when a property (color, slider etc.) changes.
* @prop {function} handleChannelRemove When a channel is removed, this is called.
* @prop {function} handleIQRUpdate When the IQR button is clicked, this is called.
* @prop {number} selectionIndex The current numeric index of the selection.
*/
function RasterChannelController({
visibility = false,
slider,
color,
channels,
channelId,
domainType: newDomainType,
dimName,
theme,
loader,
colormapOn,
channelOptions,
handlePropertyChange,
handleChannelRemove,
handleIQRUpdate,
selectionIndex,
isLoading,
use3d: newUse3d,
}) {
const { dtype } = getSourceFromLoader(loader);
const [domain, setDomain] = useState(null);
const [domainType, setDomainType] = useState(null);
const [use3d, setUse3d] = useState(null);
const [selection, setSelection] = useState([
{ ...channels[channelId].selection },
]);
const rgbColor = toRgbUIString(colormapOn, color, theme);
useEffect(() => {
// Use mounted to prevent state updates/re-renders after the component has been unmounted.
// All state updates should happen within the mounted check.
let mounted = true;
if (dtype && loader && channels) {
const selections = [{ ...channels[channelId].selection }];
let domains;
const hasDomainChanged = newDomainType !== domainType;
const has3dChanged = use3d !== newUse3d;
const hasSelectionChanged = !isEqual(selections, selection);
if (hasDomainChanged || hasSelectionChanged || has3dChanged) {
if (newDomainType === 'Full') {
domains = [DOMAINS[dtype]];
const [newDomain] = domains;
if (mounted) {
setDomain(newDomain);
setDomainType(newDomainType);
if (hasSelectionChanged) {
setSelection(selections);
}
if (has3dChanged) {
setUse3d(newUse3d);
}
}
} else {
getMultiSelectionStats({
loader: loader.data,
selections,
use3d: newUse3d,
}).then((stats) => {
// eslint-disable-next-line prefer-destructuring
domains = stats.domains;
const [newDomain] = domains;
if (mounted) {
setDomain(newDomain);
setDomainType(newDomainType);
if (hasSelectionChanged) {
setSelection(selections);
}
if (has3dChanged) {
setUse3d(newUse3d);
}
}
});
}
}
}
return () => {
mounted = false;
};
}, [
domainType,
channels,
channelId,
loader,
dtype,
newDomainType,
selection,
newUse3d,
use3d,
]);
/* A valid selection is defined by an object where the keys are
* the name of a dimension of the data, and the values are the
* index of the image along that particular dimension.
*
* Since we currently only support making a selection along one
* addtional dimension (i.e. the dropdown just has channels or mz)
* we have a helper function to create the selection.
*
* e.g { channel: 2 } // channel dimension, third channel
*/
const createSelection = index => ({ [dimName]: index });
return (
<Grid container direction="column" m={1} justifyContent="center">
<Grid container direction="row" justifyContent="space-between">
<Grid item xs={10}>
<ChannelSelectionDropdown
handleChange={v => handlePropertyChange('selection', createSelection(v))
}
selectionIndex={selectionIndex}
channelOptions={channelOptions}
disabled={isLoading}
/>
</Grid>
<Grid item xs={1} style={{ marginTop: '4px' }}>
<ChannelOptions
handlePropertyChange={handlePropertyChange}
handleChannelRemove={handleChannelRemove}
handleIQRUpdate={handleIQRUpdate}
disabled={isLoading}
/>
</Grid>
</Grid>
<Grid container direction="row" justifyContent="space-between">
<Grid item xs={2}>
<ChannelVisibilityCheckbox
color={rgbColor}
checked={visibility}
toggle={() => handlePropertyChange('visible', !visibility)}
disabled={isLoading}
/>
</Grid>
<Grid item xs={9}>
<ChannelSlider
color={rgbColor}
slider={slider}
domain={domain || DOMAINS[dtype]}
dtype={dtype}
handleChange={v => handlePropertyChange('slider', v)}
disabled={isLoading}
/>
</Grid>
</Grid>
</Grid>
);
}
export default RasterChannelController;