diff --git a/gui/src/components/plotting/PlotAxis.js b/gui/src/components/plotting/PlotAxis.js index fedb11293b1bef788ca5fe27fe166af3447a90c8..6a925ffcc5712023f0016c3dfbe7ee1a7f127827 100644 --- a/gui/src/components/plotting/PlotAxis.js +++ b/gui/src/components/plotting/PlotAxis.js @@ -22,9 +22,6 @@ import { makeStyles } from '@material-ui/core/styles' import { isArray, isNil } from 'lodash' import { useResizeDetector } from 'react-resize-detector' import { getScaler, getTicks } from './common' -import { Quantity } from '../units/Quantity' -import { Unit } from '../units/Unit' -import { useUnitContext } from '../units/UnitContext' import { formatNumber, DType } from '../../utils' import PlotLabel from './PlotLabel' import PlotTick from './PlotTick' @@ -76,7 +73,6 @@ const usePlotAxisStyles = makeStyles(theme => ({ const PlotAxis = React.memo(({ min, max, - unit, dtype, mode, decimals, @@ -95,8 +91,6 @@ const PlotAxis = React.memo(({ classes, 'data-testid': testID}) => { const styles = usePlotAxisStyles(classes) - const {units} = useUnitContext() - const unitObj = useMemo(() => new Unit(unit), [unit]) const {height, width, ref} = useResizeDetector() const orientation = { left: 'vertical', @@ -111,9 +105,8 @@ const PlotAxis = React.memo(({ // Determine the correct scaler const scaler = useMemo( - () => getScaler(scale, [min, max], [0, axisSize]), - [scale, min, max, axisSize] - ) + () => getScaler(scale, [min, max], [0, axisSize]) + , [scale, min, max, axisSize]) // Determine styles that depend on overflow values overflowBottom = isNil(overflowBottom) ? 8 : overflowBottom @@ -157,7 +150,7 @@ const PlotAxis = React.memo(({ const formatTick = (value) => { return dtype === DType.Timestamp ? format(value, 'MMM d') - : formatNumber(new Quantity(value, unitObj).toSystem(units).value(), dtype, mode, decimals) + : formatNumber(value, dtype, mode, decimals) } // Manual ticks @@ -172,11 +165,9 @@ const PlotAxis = React.memo(({ const nItems = Math.min(labels, Math.max(2, nItemsFit)) // If the scale length is zero, show only one tick - const minConverted = new Quantity(min, unitObj).toSystem(units).value() - const maxConverted = new Quantity(max, unitObj).toSystem(units).value() - if (minConverted === maxConverted) { + if (min === max) { return [{ - label: formatTick(minConverted), + label: formatTick(min), pos: 0 }] } @@ -184,15 +175,14 @@ const PlotAxis = React.memo(({ // Get reasonable, human-readable ticks. the .ticks function from d3-scale // does not guarantee an upper limit to the number of ticks, so it cannot be // directly used. - const unitConverted = unitObj.toSystem(units) - return getTicks(minConverted, maxConverted, nItems, dtype, mode, decimals) + return getTicks(min, max, nItems, dtype, mode, decimals) .map(({tick, value}) => { return { label: tick, - pos: scaler(new Quantity(value, unitConverted).toSI().value()) / axisSize + pos: scaler(value) / axisSize } }) - }, [axisSize, dtype, labelSize, labels, max, min, scaler, unitObj, units, mode, decimals]) + }, [axisSize, dtype, labelSize, labels, max, min, scaler, mode, decimals]) // Here we estimate the maximum label width. This is a relatively simple // approximattion calculated using the font size. A more reliable way would to @@ -249,7 +239,6 @@ const PlotAxis = React.memo(({ PlotAxis.propTypes = { min: PropTypes.number, max: PropTypes.number, - unit: PropTypes.any, scale: PropTypes.string, mode: PropTypes.oneOf(['scientific', 'SI', 'standard']), decimals: PropTypes.number, @@ -276,8 +265,7 @@ PlotAxis.defaultProps = { scale: 'linear', scientific: true, // Whether to use scientific notation, e.g. 1e+3 siPostfix: false, // Whether to use SI postfixes, e.g. K, M, B - decimals: 3, // How many decimals to show for custom labels - unit: 'dimensionless' + decimals: 3 // How many decimals to show for custom labels } export default PlotAxis diff --git a/gui/src/components/plotting/PlotHistogram.js b/gui/src/components/plotting/PlotHistogram.js index a52074a9a5294ba892477feb663f6805ddfe4e70..245c67e62a86c4ba4b47939a05297bfb190899f1 100644 --- a/gui/src/components/plotting/PlotHistogram.js +++ b/gui/src/components/plotting/PlotHistogram.js @@ -19,17 +19,21 @@ import React, { useRef, useMemo, useCallback } from 'react' import clsx from 'clsx' import { range as rangeLodash, isNil, clamp } from 'lodash' import { useRecoilValue } from 'recoil' -import { Slider } from '@material-ui/core' +import { Slider, InputAdornment } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import { pluralize, formatInteger } from '../../utils' -import { Unit } from '../units/Unit' +import { KeyboardDateTimePicker } from '@material-ui/pickers' +import { pluralize, formatInteger, DType } from '../../utils' +import { Quantity } from '../units/Quantity' import InputUnavailable from '../search/input/InputUnavailable' +import { InputTextField } from '../search/input/InputText' import Placeholder from '../visualization/Placeholder' import PlotAxis from './PlotAxis' import PlotBar from './PlotBar' +import FilterTitle from '../search/FilterTitle' import { guiState } from '../GUIMenu' import PropTypes from 'prop-types' import { getScaler } from './common' +import { dateFormat } from '../../config' /** * An interactive histogram for numeric values. @@ -40,7 +44,20 @@ const useStyles = makeStyles(theme => ({ height: '100%', boxSizing: 'border-box' }, + container: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center' + }, + histogram: { + width: '100%', + height: '100%' + }, overflow: { + flex: '1 1 auto', width: '100%', height: '100%' }, @@ -52,7 +69,7 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: 'auto 1fr', gridTemplateRows: '1fr auto', paddingRight: theme.spacing(0.75), - paddingBottom: theme.spacing(0.5) + paddingBottom: theme.spacing(0.25) }, plot: { gridColumn: 2, @@ -85,6 +102,16 @@ const useStyles = makeStyles(theme => ({ gridColumn: 2, gridRow: 2 }, + inputField: { + marginTop: 0, + marginBottom: 0, + flexGrow: 1, + minWidth: '6.1rem', + maxWidth: '8.5rem' + }, + inputFieldDate: { + maxWidth: '15rem' + }, thumb: { '&:active': { boxShadow: '0px 0px 0px 12px rgb(0, 141, 195, 16%)' @@ -92,19 +119,47 @@ const useStyles = makeStyles(theme => ({ '&:focusVisible': { boxShadow: '0px 0px 0px 6px rgb(0, 141, 195, 16%)' } + }, + title: { + flexGrow: 1, + marginLeft: theme.spacing(0.5), + marginRight: theme.spacing(0.5) + }, + // The following two styles are needed in order for the TextInput to + // transition from having a label to not having a label. The MUI component + // does not otherwise transition correctly. + inputMargin: { + paddingTop: 10, + paddingBottom: 11 + }, + adornment: { + marginTop: '0px !important' + }, + spacer: { + height: '3rem', + flex: '1 1 100%', + paddingLeft: '18px', + paddingRight: '18px', + display: 'flex', + alignItems: 'center' + }, + row: { + marginTop: theme.spacing(0.25), + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'flex-start' } })) const PlotHistogram = React.memo(({ + xAxis, bins, range, - minX, - maxX, - unitX, step, nBins, scale, discretization, - dtypeX, dtypeY, disabled, tooltipLabel, @@ -112,11 +167,27 @@ const PlotHistogram = React.memo(({ disableSlider, onRangeChange, onRangeCommit, + onMinChange, + onMaxChange, + onMinBlur, + onMaxBlur, + onMinSubmit, + onMaxSubmit, onClick, minXInclusive, maxXInclusive, className, classes, + showinput, + minError, + maxError, + minInput, + maxInput, + minLocal, + maxLocal, + stepSlider, + disableHistogram, + disableXTitle, 'data-testid': testID }) => { const styles = useStyles(classes) @@ -140,8 +211,17 @@ const PlotHistogram = React.memo(({ const oldRangeRef = useRef() const artificialRange = 1 const isArtificial = !step && bins?.length === 1 + const isTime = xAxis.dtype === DType.Timestamp step = isArtificial ? artificialRange / nBins : step - maxX = isArtificial ? minX + artificialRange : maxX + + // Determine the final maximum of x-axis. If the values are discrete, the + // maximum x value needs to be increased. + let maxX = isArtificial ? xAxis.min + artificialRange : xAxis.max + if (discretization) { + if (!isNil(maxX)) { + maxX += step + } + } // Determine the final bin data and the y axis limits. const [finalBins, minY, maxY] = useMemo(() => { @@ -176,15 +256,9 @@ const PlotHistogram = React.memo(({ : range }, [discretization, step]) - // If the values are discrete, the maximum x value needs to be increased. const rangeInternal = useMemo(() => { return toInternalRange([range?.gte, range?.lte]) }, [range, toInternalRange]) - if (discretization) { - if (!isNil(maxX)) { - maxX += step - } - } // Turns the internal range values back to the external counterparts before // sending the event. @@ -240,14 +314,14 @@ const PlotHistogram = React.memo(({ // Create the plot once items are ready const plot = useMemo(() => { - if (isNil(finalBins) || isNil(minX) || isNil(maxX)) { + if (isNil(finalBins) || isNil(xAxis.min) || isNil(maxX)) { return null } return <div className={styles.canvas}> {Object.values(finalBins).map((item, i) => { return <PlotBar - startX={(item.start - minX) / (maxX - minX)} - endX={(item.end - minX) / (maxX - minX)} + startX={(item.start - xAxis.min) / (maxX - xAxis.min)} + endX={(item.end - xAxis.min) / (maxX - xAxis.min)} startY={0} endY={item.scale} key={i} @@ -259,11 +333,11 @@ const PlotHistogram = React.memo(({ /> })} </div> - }, [finalBins, minX, maxX, styles.canvas, calculateSelection, rangeInternal, handleClick, tooltipLabel]) + }, [finalBins, xAxis.min, maxX, styles.canvas, calculateSelection, rangeInternal, handleClick, tooltipLabel]) // Create x-axis once axis range is ready const xaxis = useMemo(() => { - if (isNil(finalBins) || isNil(minX) || isNil(maxX)) { + if (isNil(finalBins) || isNil(xAxis.min) || isNil(maxX)) { return null } @@ -280,7 +354,7 @@ const PlotHistogram = React.memo(({ }] // Discrete values get label at the center of the bin. } else { - const start = step * Math.ceil(minX / step) + const start = step * Math.ceil(xAxis.min / step) const end = step * Math.floor(maxX / step) labels = rangeLodash(start, end).map(x => ({ label: x, @@ -288,19 +362,120 @@ const PlotHistogram = React.memo(({ })) } + const min = new Quantity(xAxis.min, xAxis.unitStorage).to(xAxis.unit).value() + const max = new Quantity(maxX, xAxis.unitStorage).to(xAxis.unit).value() + return <PlotAxis placement="bottom" - min={minX} - max={maxX} - unit={unitX} + min={min} + max={max} mode="scientific" labels={labels} labelWidth={45} overflowLeft={25} - dtype={dtypeX} + dtype={xAxis.dtype} className={styles.xaxis} /> - }, [finalBins, minX, maxX, discretization, isArtificial, unitX, dtypeX, styles.xaxis, step]) + }, [finalBins, xAxis.min, xAxis.unitStorage, maxX, discretization, isArtificial, xAxis.dtype, styles.xaxis, xAxis.unit, step]) + + const [minAdornment, maxAdornment] = useMemo(() => { + return disableHistogram + ? [undefined, undefined] + : [ + { + startAdornment: <InputAdornment + position="start" + classes={{positionStart: styles.adornment}} + >min:</InputAdornment>, + classes: {inputMarginDense: styles.inputMargin} + }, + { + startAdornment: <InputAdornment + position="start" + classes={{positionStart: styles.adornment}} + >max:</InputAdornment>, + classes: {inputMarginDense: styles.inputMargin} + } + ] + }, [disableHistogram, styles]) + + // Determine the min input component + let inputMinField + if (xAxis.dtype === DType.Timestamp) { + inputMinField = <KeyboardDateTimePicker + error={!!minError} + disabled={disabled} + helperText={minError} + ampm={false} + className={clsx(styles.inputField, styles.inputFieldDate)} + variant="inline" + inputVariant="filled" + label="Start time" + format={`${dateFormat} kk:mm`} + value={minInput} + invalidDateMessage="" + InputAdornmentProps={{ position: 'end' }} + onAccept={(date) => { + onMinChange(date) + onMinSubmit(date) + }} + onChange={onMinChange} + onBlur={onMinBlur} + onKeyDown={(event) => { if (event.key === 'Enter') { onMinSubmit(minInput) } }} + /> + } else { + inputMinField = <InputTextField + error={!!minError} + disabled={disabled} + helperText={minError} + className={styles.inputField} + InputProps={minAdornment} + label={disableHistogram ? 'min' : ' '} + value={minInput} + onChange={onMinChange} + onBlur={onMinBlur} + onKeyDown={(event) => { if (event.key === 'Enter') { onMinSubmit(minInput) } }} + /> + } + + // Determine the max input component + let inputMaxField + if (xAxis.dtype === DType.Timestamp) { + inputMaxField = <KeyboardDateTimePicker + error={!!maxError} + disabled={disabled} + helperText={maxError} + ampm={false} + className={clsx(styles.inputField, styles.inputFieldDate)} + variant="inline" + inputVariant="filled" + label="End time" + format={`${dateFormat} kk:mm`} + value={maxInput} + invalidDateMessage="" + InputAdornmentProps={{ position: 'end' }} + onAccept={(date) => { + onMaxChange(date) + onMaxSubmit(date) + }} + onChange={onMaxChange} + onBlur={onMaxBlur} + onKeyDown={(event) => { if (event.key === 'Enter') { onMaxSubmit(maxInput) } }} + /> + } else { + inputMaxField = <InputTextField + error={!!maxError} + disabled={disabled} + helperText={maxError} + className={styles.inputField} + InputProps={maxAdornment} + label={disableHistogram ? 'max' : ' '} + value={maxInput} + onChange={onMaxChange} + onBlur={onMaxBlur} + onKeyDown={(event) => { if (event.key === 'Enter') { onMaxSubmit(maxInput) } }} + /> + } // Create y-axis once axis range is ready. const yaxis = useMemo(() => { @@ -311,7 +486,6 @@ const PlotHistogram = React.memo(({ placement="left" min={minY} max={maxY} - unit={new Unit('dimensionless')} mode='SI' labels={5} scale={scale} @@ -338,9 +512,9 @@ const PlotHistogram = React.memo(({ {plot} {!disableSlider && <Slider - disabled={disabled || isArtificial || (maxX - minX) === discretization} + disabled={disabled || isArtificial || (maxX - xAxis.min) === discretization} color={highlight ? 'secondary' : 'primary'} - min={minX} + min={xAxis.min} max={maxX} step={step} value={rangeInternal} @@ -355,31 +529,67 @@ const PlotHistogram = React.memo(({ <div className={styles.square} /> {xaxis} </div> - </div> + </div> } + const titleComp = <div className={styles.title}> + <FilterTitle + quantity={xAxis.quantity} + label={xAxis.title} + unit={xAxis.unit} + variant="caption" + className={styles.titletext} + noWrap={false} + /> + </div> + return <div className={clsx(className, styles.root)} data-testid={testID}> - {histComp} + <div className={styles.container}> + {!disableHistogram && <div className={clsx(styles.histogram, classes?.histogram)}>{histComp}</div>} + {!disableXTitle && titleComp} + <div className={styles.row}> + {showinput + ? <> + {inputMinField} + {(disableHistogram && !isTime) + ? <div className={styles.spacer}> + <Slider + disabled={disabled || (minLocal === maxLocal)} + color={highlight ? 'secondary' : 'primary'} + min={minLocal} + max={maxLocal} + step={stepSlider} + value={[range.gte, range.lte]} + onChange={onRangeChange} + onChangeCommitted={onRangeCommit} + valueLabelDisplay="off" + /> + </div> + : <div className={styles.title} /> + } + {inputMaxField} + </> + : null + } + </div> + </div> </div> }) PlotHistogram.propTypes = { + xAxis: PropTypes.object, /* The bins data to show. */ bins: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.number, count: PropTypes.number })), range: PropTypes.object, - minX: PropTypes.number, - maxX: PropTypes.number, - unitX: PropTypes.any, step: PropTypes.number, /* The number of bins: required only when showing a dummy bin for histograms * that have no width. */ nBins: PropTypes.number, /* Discretization of the values. */ discretization: PropTypes.number, - dtypeX: PropTypes.string, dtypeY: PropTypes.string, scale: PropTypes.string, disabled: PropTypes.bool, @@ -395,8 +605,25 @@ PlotHistogram.propTypes = { minXInclusive: PropTypes.bool, /* Whether the max slider is inclusive (=max value is included) */ maxXInclusive: PropTypes.bool, + showInput: PropTypes.bool, onRangeChange: PropTypes.func, onRangeCommit: PropTypes.func, + onMinChange: PropTypes.func, + onMaxChange: PropTypes.func, + onMinSubmit: PropTypes.func, + onMaxSubmit: PropTypes.func, + showinput: PropTypes.bool, + maxError: PropTypes.any, + minError: PropTypes.any, + maxInput: PropTypes.any, + minInput: PropTypes.any, + maxLocal: PropTypes.any, + minLocal: PropTypes.any, + stepSlider: PropTypes.any, + disableHistogram: PropTypes.bool, + disableXTitle: PropTypes.bool, + onMinBlur: PropTypes.func, + onMaxBlur: PropTypes.func, className: PropTypes.string, classes: PropTypes.object, 'data-testid': PropTypes.string diff --git a/gui/src/components/plotting/common.js b/gui/src/components/plotting/common.js index 751850cc5ae513e8c12dad9377fc5bc683ea775a..50fb1a8a9476f7f9ddd09dbc8df954fc247233d7 100644 --- a/gui/src/components/plotting/common.js +++ b/gui/src/components/plotting/common.js @@ -131,6 +131,13 @@ export function getTicks(min, max, n, dtype, mode = 'scientific', decimals = 3) // Config for the available intervals const intervals = { + fiveyears: { + difference: (end, start) => differenceInYears(end, start) / 5, + split: (interval) => eachYearOfInterval(interval).filter(x => { + return !(x.getFullYear() % 5) + }), + format: 'yyyy' + }, years: { difference: differenceInYears, split: eachYearOfInterval, diff --git a/gui/src/components/search/Filter.js b/gui/src/components/search/Filter.js index fd7f917c5e243ac91c01606a067af7c9574b5a43..6460b1a8aa23521581d9b0a1806ebff72b68d0a7 100644 --- a/gui/src/components/search/Filter.js +++ b/gui/src/components/search/Filter.js @@ -165,7 +165,7 @@ export class Filter { this.serializerPretty = params?.serializerPretty || getSerializer(this.dtype, true) this.deserializer = params?.deserializer || getDeserializer(this.dtype, this.dimension) this.aggregatable = params?.aggregatable === undefined ? false : params?.aggregatable - this.widget = params?.widget || getWidgetConfig(this.dtype, params?.aggregatable) + this.widget = params?.widget || getWidgetConfig(this.quantity, this.dtype, params?.aggregatable, this.scale) if (this.default && !this.global) { throw Error(`Error constructing filter for ${this.name}: only filters that do not correspond to a metainfo value may have default values set.`) @@ -202,38 +202,36 @@ export function getEnumOptions(quantity, exclude = ['not processed']) { * @param {bool} aggregatable Whether the quantity is aggregatable * @returns A widget config object. */ -export const getWidgetConfig = (dtype, aggregatable) => { +export const getWidgetConfig = (quantity, dtype, aggregatable, scale) => { if (dtype === DType.Float || dtype === DType.Int || dtype === DType.Timestamp) { - return histogramWidgetConfig + return { + x: {quantity}, + type: 'histogram', + scale, + showinput: false, + autorange: false, + nbins: 30, + layout: { + sm: {w: 8, h: 3, minW: 3, minH: 3}, + md: {w: 8, h: 3, minW: 3, minH: 3}, + lg: {w: 8, h: 3, minW: 3, minH: 3}, + xl: {w: 8, h: 3, minW: 3, minH: 3}, + xxl: {w: 8, h: 3, minW: 3, minH: 3} + } + } } else if (aggregatable) { - return termsWidgetConfig - } -} - -export const histogramWidgetConfig = { - type: 'histogram', - scale: 'linear', - showinput: false, - autorange: false, - nbins: 30, - layout: { - sm: {w: 8, h: 3, minW: 8, minH: 3}, - md: {w: 8, h: 3, minW: 8, minH: 3}, - lg: {w: 8, h: 3, minW: 8, minH: 3}, - xl: {w: 8, h: 3, minW: 8, minH: 3}, - xxl: {w: 8, h: 3, minW: 8, minH: 3} - } -} - -export const termsWidgetConfig = { - type: 'terms', - scale: 'linear', - showinput: false, - layout: { - sm: {w: 6, h: 9, minW: 6, minH: 9}, - md: {w: 6, h: 9, minW: 6, minH: 9}, - lg: {w: 6, h: 9, minW: 6, minH: 9}, - xl: {w: 6, h: 9, minW: 6, minH: 9}, - xxl: {w: 6, h: 9, minW: 6, minH: 9} + return { + quantity, + type: 'terms', + scale: scale, + showinput: false, + layout: { + sm: {w: 6, h: 9, minW: 3, minH: 3}, + md: {w: 6, h: 9, minW: 3, minH: 3}, + lg: {w: 6, h: 9, minW: 3, minH: 3}, + xl: {w: 6, h: 9, minW: 3, minH: 3}, + xxl: {w: 6, h: 9, minW: 3, minH: 3} + } + } } } diff --git a/gui/src/components/search/FilterChip.js b/gui/src/components/search/FilterChip.js index 2336d81e4c23d4969de2097479e4164110f132f4..7c0382b4f5ae14a74eed9fa4eb7d609e7bec7d47 100644 --- a/gui/src/components/search/FilterChip.js +++ b/gui/src/components/search/FilterChip.js @@ -106,7 +106,7 @@ export const FilterChipGroup = React.memo(({ <FilterTitle quantity={quantity} variant="caption" - className={styles.title} + classes={{text: styles.title}} /> <div className={styles.paper}> {children} diff --git a/gui/src/components/search/FilterRegistry.js b/gui/src/components/search/FilterRegistry.js index efe62049845f8b531f7fac6615c5d593e1e13a51..4b358d9450a9175b792495f0ef7b2eee8d5b01ed 100644 --- a/gui/src/components/search/FilterRegistry.js +++ b/gui/src/components/search/FilterRegistry.js @@ -158,18 +158,6 @@ function registerFilterOptions(name, group, target, label, description, options) ) } -const ptWidgetConfig = { - type: 'periodictable', - scale: '1/2', - layout: { - sm: {w: 12, h: 8, minW: 12, minH: 8}, - md: {w: 12, h: 8, minW: 12, minH: 8}, - lg: {w: 12, h: 8, minW: 12, minH: 8}, - xl: {w: 12, h: 8, minW: 12, minH: 8}, - xxl: {w: 12, h: 8, minW: 12, minH: 8} - } -} - // Presets for different kind of quantities const termQuantity = {aggs: {terms: {size: 5}}} const termQuantityLarge = {aggs: {terms: {size: 10}}} @@ -642,7 +630,18 @@ registerFilter( 'results.material.elements', idElements, { - widget: ptWidgetConfig, + widget: { + quantity: 'results.material.elements', + type: 'periodictable', + scale: '1/2', + layout: { + sm: {w: 12, h: 8, minW: 12, minH: 8}, + md: {w: 12, h: 8, minW: 12, minH: 8}, + lg: {w: 12, h: 8, minW: 12, minH: 8}, + xl: {w: 12, h: 8, minW: 12, minH: 8}, + xxl: {w: 12, h: 8, minW: 12, minH: 8} + } + }, aggs: {terms: {size: elementData.elements.length}}, value: { set: (newQuery, oldQuery, value) => { diff --git a/gui/src/components/search/FilterTitle.js b/gui/src/components/search/FilterTitle.js index 0ca7ee1a902c19c497f57bbba3d28f2bee991e2a..2fb098425a479aba8af3ef16038a6c43f495cf02 100644 --- a/gui/src/components/search/FilterTitle.js +++ b/gui/src/components/search/FilterTitle.js @@ -33,6 +33,8 @@ import { useUnitContext } from '../units/UnitContext' const useStaticStyles = makeStyles(theme => ({ root: { }, + text: { + }, title: { fontWeight: 600, color: theme.palette.grey[800] @@ -64,9 +66,10 @@ const FilterTitle = React.memo(({ className, classes, rotation, - disableUnit + disableUnit, + noWrap }) => { - const styles = useStaticStyles({classes: classes}) + const styles = useStaticStyles({classes}) const { filterData } = useSearchContext() const sectionContext = useContext(inputSectionContext) const {units} = useUnitContext() @@ -93,14 +96,14 @@ const FilterTitle = React.memo(({ const finalDescription = description || filterData[quantity]?.description || '' return <Tooltip title={finalDescription} placement="bottom" {...(TooltipProps || {})}> - <div className={clsx( + <div className={clsx(className, styles.root, rotation === 'right' && styles.right, rotation === 'down' && styles.down, rotation === 'up' && styles.up )}> <Typography - noWrap - className={clsx(className, styles.root, (!section) && styles.title)} + noWrap={noWrap} + className={clsx(styles.text, (!section) && styles.title)} variant={variant} onMouseDown={onMouseDown} onMouseUp={onMouseUp} @@ -114,7 +117,7 @@ const FilterTitle = React.memo(({ FilterTitle.propTypes = { quantity: PropTypes.string, label: PropTypes.string, - unit: PropTypes.string, + unit: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), description: PropTypes.string, variant: PropTypes.string, className: PropTypes.string, @@ -123,12 +126,14 @@ FilterTitle.propTypes = { disableUnit: PropTypes.bool, TooltipProps: PropTypes.object, // Properties forwarded to the Tooltip onMouseDown: PropTypes.func, - onMouseUp: PropTypes.func + onMouseUp: PropTypes.func, + noWrap: PropTypes.bool } FilterTitle.defaultProps = { variant: 'body2', - rotation: 'right' + rotation: 'right', + noWrap: true } export default FilterTitle diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index f00f48615e2af21be9b5eb15eec64589949862dc..6610728cd6af6223e040f029c9f9fb78fd594f0e 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -54,7 +54,6 @@ import { formatTimestamp, getDeep, formatNumber, - setToArray, parseQuantityName, rsplit, parseOperator, @@ -1985,9 +1984,9 @@ function convertQueryGUIToAPI(query, resource, filtersData, queryModes) { // Create the API-compatible keys and values. const queryNormalized = {} for (const [k, v] of Object.entries(queryCustomized)) { - const newValue = convertQuerySingleGUIToAPI(v) const {quantity: filterName, op: queryMode} = parseOperator(k) let finalKey = filtersData[filterName]?.requestQuantity || filterName + const newValue = convertQuerySingleGUIToAPI(v, filterName, filtersData) finalKey = resource === 'materials' ? getFilterMaterialPath(finalKey) : finalKey let finalQueryMode = queryMode || queryModes?.[k] if (isNil(finalQueryMode) && isArray(newValue)) { @@ -2045,54 +2044,44 @@ function convertQueryGUIToAPI(query, resource, filtersData, queryModes) { /** * Cleans a single filter value into a form that is supported by the API. This includes: * - Sets are transformed into Arrays - * - Quantities are converted to SI values. + * - Quantities are converted to storage unit values * - Empty containers are set to undefined * - * @param {string} key Filter name * @param {any} value Filter value - * @param {object} filtersData All of the filters that are available - * @param {string} queryMode Determines the queryMode + * @param {Filter} filter Filter object * * @returns {any} The filter value in a format that is suitable for the API. */ -function convertQuerySingleGUIToAPI(value) { - // Determine the API-compatible value. - let newValue +function convertQuerySingleGUIToAPI(value, name, filterData) { + const unit = filterData[name]?.unit || 'dimensionless' + const convertItem = (item) => item instanceof Quantity ? item.to(unit).value() : item + if (value instanceof Set) { - newValue = setToArray(value) - if (newValue.length === 0) { - newValue = undefined - } else { - newValue = newValue.map((item) => item instanceof Quantity - ? item.toSI().value() - : item) - } - } else if (value instanceof Quantity) { - newValue = value.toSI().value() - } else if (isArray(value)) { - if (value.length === 0) { - newValue = undefined - } else { - newValue = value.map((item) => item instanceof Quantity - ? item.toSI().value() - : item) - } - } else if (isPlainObject(value)) { - newValue = {} - for (const [keyInner, valueInner] of Object.entries(value)) { - const apiValue = convertQuerySingleGUIToAPI(valueInner) - if (!isNil(apiValue)) { - newValue[keyInner] = apiValue - } - } - if (isEmpty(newValue)) { - newValue = undefined - } - } else { - newValue = value + const newValue = Array.from(value).map(convertItem) + return newValue.length ? newValue : undefined } - return newValue + if (value instanceof Quantity) { + return value.to(unit).value() + } + + if (isArray(value)) { + const newValue = value.map(convertItem) + return newValue.length ? newValue : undefined + } + + if (isPlainObject(value)) { + const newValue = Object.entries(value).reduce((acc, [key, val]) => { + const filterName = isPlainObject(val) ? `${name}.${key}` : name + const apiValue = convertQuerySingleGUIToAPI(val, filterName, filterData) + if (!isNil(apiValue)) acc[key] = apiValue + return acc + }, {}) + + return isEmpty(newValue) ? undefined : newValue + } + + return value } /** diff --git a/gui/src/components/search/conftest.spec.js b/gui/src/components/search/conftest.spec.js index a1495cc60d951ba5e17ce7afec27ac2eca425bcc..3dba928ee3c5fbc2f4f081ae5eecb07f34e4ef73 100644 --- a/gui/src/components/search/conftest.spec.js +++ b/gui/src/components/search/conftest.spec.js @@ -169,14 +169,14 @@ export async function expectWidgetScatterPlot(widget, loaded, colorTitle, legend /** * Tests that an InputRange is rendered with the given contents. - * @param {string} quantity The quantity name + * @param {object} widget The widget config * @param {bool} loaded Whether the data is already loaded * @param {bool} histogram Whether the histogram is shown * @param {bool} placeholder Whether the placeholder should be checked */ -export async function expectInputRange(quantity, loaded, histogram, anchored, min, max, root = screen) { +export async function expectInputRange(widget, loaded, histogram, anchored, min, max, root = screen) { // Test header - await expectInputHeader(quantity, true) + await expectInputHeader(widget.x.quantity, true) // Check histogram if (histogram) { @@ -188,14 +188,14 @@ export async function expectInputRange(quantity, loaded, histogram, anchored, mi // Test text elements if the component is not anchored if (!anchored) { - const data = defaultFilterData[quantity] + const data = defaultFilterData[widget.x.quantity] const dtype = data.dtype if (dtype === DType.Timestamp) { expect(root.getByText('Start time')).toBeInTheDocument() expect(root.getByText('End time')).toBeInTheDocument() } else { - expect(root.getByText('min')).toBeInTheDocument() - expect(root.getByText('max')).toBeInTheDocument() + expect(root.getByText(histogram ? 'min:' : 'min')).toBeInTheDocument() + expect(root.getByText(histogram ? 'max:' : 'max')).toBeInTheDocument() } // Get the formatted datetime in current timezone (timezones differ, so the diff --git a/gui/src/components/search/input/InputMetainfo.js b/gui/src/components/search/input/InputMetainfo.js index ae9304eacc7096603855f9c18b03e88ec9a6ada2..4263809e382c36a2f98a490e84e712fd23f3c2b1 100644 --- a/gui/src/components/search/input/InputMetainfo.js +++ b/gui/src/components/search/input/InputMetainfo.js @@ -82,12 +82,12 @@ export const InputMetainfoControlled = React.memo(({ if (optional && empty) { return {valid: true, error: undefined} } else if (empty) { - return {valid: false, error: 'Please specify a value'} + return {valid: false, error: 'Please specify a value.'} } if (validate) { return validate(value) } else if (!(keysSet.has(value))) { - return {valid: false, error: 'Invalid value for this field'} + return {valid: false, error: `The quantity "${value}" is not available.`} } return {valid: true, error: undefined} }, [validate, keysSet, optional, disableValidation]) @@ -425,9 +425,10 @@ function getMetainfoOptions(filterData, dtypes, dtypesRepeatable, disableNonAggr .filter(([key, data]) => { if (disableNonAggregatable && !data.aggregatable) return false const dtype = data?.dtype - return data?.repeats + const passed = data?.repeats ? dtypesRepeatable?.has(dtype) : dtypes?.has(dtype) + return passed }) .map(([key, data]) => [key, { key: key, diff --git a/gui/src/components/search/input/InputRange.js b/gui/src/components/search/input/InputRange.js index 433fca03f3f1f87ca02f095841ecd1c3ee92f4a7..01b6a37c04f868b20a8e028516ffb7399fe8507d 100644 --- a/gui/src/components/search/input/InputRange.js +++ b/gui/src/components/search/input/InputRange.js @@ -17,26 +17,19 @@ */ import React, { useState, useMemo, useCallback, useEffect, useRef, useContext } from 'react' import { makeStyles } from '@material-ui/core/styles' -import { Slider } from '@material-ui/core' -import { useRecoilValue } from 'recoil' import PropTypes from 'prop-types' import clsx from 'clsx' import { isNil } from 'lodash' -import { KeyboardDateTimePicker } from '@material-ui/pickers' import InputHeader from './InputHeader' -import InputTooltip from './InputTooltip' import { inputSectionContext } from './InputSection' -import { InputTextField } from './InputText' import { Quantity } from '../../units/Quantity' import { Unit } from '../../units/Unit' import { useUnitContext } from '../../units/UnitContext' import { DType, formatNumber } from '../../../utils' import { getInterval } from '../../plotting/common' -import { dateFormat } from '../../../config' import { useSearchContext } from '../SearchContext' import PlotHistogram from '../../plotting/PlotHistogram' import { isValid, getTime } from 'date-fns' -import { guiState } from '../../GUIMenu' import { ActionCheckbox } from '../../Actions' import { autorangeDescription } from '../widgets/WidgetHistogram' @@ -50,69 +43,16 @@ const useStyles = makeStyles(theme => ({ height: '100%' }, histogram: { - }, - inputFieldText: { - marginTop: 0, - marginBottom: 0, - flexGrow: 0, - flexShrink: 0, - flexBasis: '6.1rem' - }, - inputFieldDate: { - marginTop: 0, - marginBottom: 0, - flexGrow: 1 - }, - textInput: { - textOverflow: 'ellipsis' - }, - container: { - width: '100%' - }, - spacer: { - height: '3rem', - flex: '1 1 100%', - paddingLeft: '18px', - paddingRight: '18px', - display: 'flex', - alignItems: 'center' - }, - column: { - width: '100%', - height: '100%', - display: 'flex', - flexDirection: 'column' - }, - dash: { - height: '56px', - lineHeight: '56px', - textAlign: 'center', - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1) - }, - row: { - marginTop: theme.spacing(0.5), - width: '100%', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start' - }, - thumb: { - '&:active': { - boxShadow: '0px 0px 0px 12px rgb(0, 141, 195, 16%)' - }, - '&:focusVisible': { - boxShadow: '0px 0px 0px 6px rgb(0, 141, 195, 16%)' - } } })) export const Range = React.memo(({ - quantity, + xAxis, nSteps, visible, scale, nBins, disableHistogram, + disableXTitle, autorange, showinput, aggId, @@ -120,16 +60,15 @@ export const Range = React.memo(({ classes, 'data-testid': testID }) => { - const {units} = useUnitContext() const {filterData, useAgg, useFilterState, useIsStatisticsEnabled} = useSearchContext() const sectionContext = useContext(inputSectionContext) const repeats = sectionContext?.repeats const isStatisticsEnabled = useIsStatisticsEnabled() const styles = useStyles({classes}) - const [filter, setFilter] = useFilterState(quantity) + const [filter, setFilter] = useFilterState(xAxis.quantity) const [minLocal, setMinLocal] = useState() const [maxLocal, setMaxLocal] = useState() - const [plotData, setPlotData] = useState() + const [plotData, setPlotData] = useState({xAxis}) const loading = useRef(false) const firstRender = useRef(true) const validRange = useRef() @@ -142,20 +81,13 @@ export const Range = React.memo(({ const [minInclusive, setMinInclusive] = useState(true) const [maxInclusive, setMaxInclusive] = useState(true) const highlight = Boolean(filter) - const inputVariant = useRecoilValue(guiState('inputVariant')) disableHistogram = isNil(disableHistogram) ? !isStatisticsEnabled : disableHistogram // Determine the description and units - const def = filterData[quantity] - const unitSI = useMemo(() => { - return new Unit(def?.unit || 'dimensionless') - }, [def]) - const unitCurrent = useMemo(() => { - return unitSI.toSystem(units) - }, [unitSI, units]) - const dtype = filterData[quantity].dtype - const discretization = dtype === DType.Int ? 1 : undefined - const isTime = dtype === DType.Timestamp + const def = filterData[xAxis.quantity] + const unitStorage = useMemo(() => { return new Unit(def?.unit || 'dimensionless') }, [def]) + const discretization = xAxis.dtype === DType.Int ? 1 : undefined + const isTime = xAxis.dtype === DType.Timestamp const firstLoad = useRef(true) // We need to set a valid initial input state: otherwise the component thinks @@ -163,36 +95,40 @@ export const Range = React.memo(({ const [minInput, setMinInput] = useState(isTime ? new Date() : '') const [maxInput, setMaxInput] = useState(isTime ? new Date() : '') - // Function for converting filter values into SI values used by the API. - const fromFilter = useCallback(filter => { + // Function for converting filter values into storage units used by the API. + const fromDisplayUnit = useCallback(filter => { return filter instanceof Quantity - ? filter.toSI().value() + ? filter.to(unitStorage).value() : filter - }, []) + }, [unitStorage]) - // Function for converting filter values from SI. - const fromSI = useCallback(filter => { + // Function for converting filter values from storage unit to display unit + const fromStorageUnit = useCallback(filter => { return (isTime) ? filter - : filter instanceof Quantity ? filter.toSI() : new Quantity(filter, unitSI) - }, [unitSI, isTime]) + : filter instanceof Quantity + ? filter.to(unitStorage) + : new Quantity(filter, unitStorage) + }, [unitStorage, isTime]) - // Function for converting filter values from custom unit system. - const toSI = useCallback(filter => { + // Function for converting filter values from display unit to storage unit + const toStorageUnit = useCallback(filter => { return (isTime) ? filter - : filter instanceof Quantity ? filter.toSI() : new Quantity(filter, unitCurrent).toSI() - }, [isTime, unitCurrent]) + : filter instanceof Quantity + ? filter.to(unitStorage) + : new Quantity(filter, xAxis.unit).to(unitStorage) + }, [isTime, xAxis.unit, unitStorage]) // Aggregation when the statistics are enabled: a histogram aggregation with // extended bounds based on the currently set filter range. Note: the config // should be memoed in order to prevent re-renders. - const minRef = useRef(fromFilter(isNil(filter?.gte) ? filter?.gt : filter?.gte)) - const maxRef = useRef(fromFilter(isNil(filter?.lte) ? filter?.lt : filter?.lte)) + const minRef = useRef(fromDisplayUnit(isNil(filter?.gte) ? filter?.gt : filter?.gte)) + const maxRef = useRef(fromDisplayUnit(isNil(filter?.lte) ? filter?.lt : filter?.lte)) const aggHistogramConfig = useMemo(() => { const filterBounds = (filter) ? { - min: fromFilter(isNil(filter.gte) ? filter.gt : filter.gte), - max: fromFilter(isNil(filter.lte) ? filter.lt : filter.lte) + min: fromDisplayUnit(isNil(filter.gte) ? filter.gt : filter.gte), + max: fromDisplayUnit(isNil(filter.lte) ? filter.lt : filter.lte) } : undefined let exclude_from_search let extended_bounds @@ -227,8 +163,8 @@ export const Range = React.memo(({ return (isTime || !discretization) ? {type: 'histogram', buckets: nBins, exclude_from_search, extended_bounds} : {type: 'histogram', interval: discretization, exclude_from_search, extended_bounds} - }, [filter, fromFilter, isTime, discretization, nBins, autorange]) - const agg = useAgg(quantity, visible && !disableHistogram, `${aggId}_histogram`, aggHistogramConfig) + }, [filter, fromDisplayUnit, isTime, discretization, nBins, autorange]) + const agg = useAgg(xAxis.quantity, visible && !disableHistogram, `${aggId}_histogram`, aggHistogramConfig) useEffect(() => { if (!isNil(agg)) { firstLoad.current = false @@ -238,45 +174,46 @@ export const Range = React.memo(({ // Aggregation when the statistics are disabled: a simple min_max aggregation // is enough in order to get the slider range. const aggSliderConfig = useMemo(() => ({type: 'min_max', exclude_from_search: true}), []) - const aggSlider = useAgg(quantity, visible && disableHistogram, `${aggId}_slider`, aggSliderConfig) + const aggSlider = useAgg(xAxis.quantity, visible && disableHistogram, `${aggId}_slider`, aggSliderConfig) // Determine the global minimum and maximum values - const [minGlobalSI, maxGlobalSI] = useMemo(() => { - let minGlobalSI - let maxGlobalSI + const [minGlobal, maxGlobal] = useMemo(() => { + let minGlobal + let maxGlobal if (disableHistogram) { - minGlobalSI = aggSlider?.data?.[0] - maxGlobalSI = aggSlider?.data?.[1] + minGlobal = aggSlider?.data?.[0] + maxGlobal = aggSlider?.data?.[1] } else { const nBuckets = agg?.data?.length || 0 if (nBuckets === 1) { - minGlobalSI = agg.data[0].value - maxGlobalSI = minGlobalSI + minGlobal = agg.data[0].value + maxGlobal = minGlobal } else if (nBuckets > 1) { for (const bucket of agg.data) { - if (isNil(minGlobalSI)) { - minGlobalSI = bucket.value + if (isNil(minGlobal)) { + minGlobal = bucket.value } - maxGlobalSI = bucket.value + (discretization ? 0 : agg.interval) + maxGlobal = bucket.value + (discretization ? 0 : agg.interval) } - if (isNil(minGlobalSI)) { - minGlobalSI = agg.data[0].value + if (isNil(minGlobal)) { + minGlobal = agg.data[0].value } - if (isNil(maxGlobalSI)) { - maxGlobalSI = agg.data[agg.data.length - 1].value + (discretization ? 0 : agg.interval) + if (isNil(maxGlobal)) { + maxGlobal = agg.data[agg.data.length - 1].value + (discretization ? 0 : agg.interval) } } } firstRender.current = false - return [minGlobalSI, maxGlobalSI] + return [minGlobal, maxGlobal] }, [agg, aggSlider, disableHistogram, discretization]) const stepHistogram = agg?.interval - const unavailable = isNil(minGlobalSI) || isNil(maxGlobalSI) || isNil(range) + const unavailable = isNil(minGlobal) || isNil(maxGlobal) || isNil(range) const disabled = unavailable // Determine the step value for sliders. Notice that this does not have to - // match with the histogram binning. + // match with the histogram binning, and that we want to do call the + // getInterval function on the display unit range. const stepSlider = useMemo(() => { if (discretization) { return discretization @@ -285,15 +222,15 @@ export const Range = React.memo(({ return undefined } const rangeSI = maxLocal - minLocal - const range = new Quantity(rangeSI, unitSI).toSystem(units).value() - const intervalCustom = getInterval(range, nSteps, dtype) - return new Quantity(intervalCustom, unitCurrent).toSI().value() - }, [maxLocal, minLocal, discretization, nSteps, unitSI, unitCurrent, units, dtype]) + const range = new Quantity(rangeSI, unitStorage).to(xAxis.unit).value() + const intervalCustom = getInterval(range, nSteps, xAxis.dtype) + return new Quantity(intervalCustom, xAxis.unit).to(unitStorage).value() + }, [maxLocal, minLocal, discretization, nSteps, xAxis.dtype, xAxis.unit, unitStorage]) // When filter changes, the plot data should not be updated. useEffect(() => { - loading.current = true - }, [filter]) + if (autorange) loading.current = true + }, [filter, autorange]) // Once the aggregation data arrives, the plot data can be updated. useEffect(() => { @@ -309,16 +246,29 @@ export const Range = React.memo(({ if (loading.current || isNil(agg?.data)) { return } - setPlotData({step: stepHistogram, data: agg.data, minX: minLocal, maxX: maxLocal}) - }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram]) + + setPlotData({ + xAxis: { + quantity: xAxis.quantity, + unit: xAxis.unit, + unitStorage: unitStorage, + dtype: xAxis.dtype, + title: xAxis.title, + min: minLocal, + max: maxLocal + }, + step: stepHistogram, + data: agg.data + }) + }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram, unitStorage, xAxis.quantity, xAxis.unit, xAxis.dtype, xAxis.title]) // Function for converting search values into the currently selected unit // system. const toInternal = useCallback(filter => { - return (!isTime && unitSI) - ? formatNumber(new Quantity(filter, unitSI).toSystem(units).value()) + return (!isTime && unitStorage) + ? formatNumber(new Quantity(filter, unitStorage).to(xAxis.unit).value()) : filter - }, [unitSI, isTime, units]) + }, [unitStorage, isTime, xAxis.unit]) // If no filter has been specified by the user, the range is automatically // adjusted according to global min/max of the field. If filter is set, the @@ -343,19 +293,19 @@ export const Range = React.memo(({ const limitMin = (global, filter) => limit(global, filter, true) const limitMax = (global, filter) => limit(global, filter, false) - if (!isNil(minGlobalSI) && !isNil(maxGlobalSI)) { + if (!isNil(minGlobal) && !isNil(maxGlobal)) { // When no filter is set, use the whole available range if (isNil(filter)) { - gte = minGlobalSI - lte = maxGlobalSI - min = minGlobalSI - max = maxGlobalSI + gte = minGlobal + lte = maxGlobal + min = minGlobal + max = maxGlobal // A single specific value is given } else if (filter instanceof Quantity) { - gte = filter.toSI().value() - lte = filter.toSI().value() - min = limitMin(minGlobalSI, gte) - max = limitMax(maxGlobalSI, lte) + gte = filter.to(unitStorage).value() + lte = filter.to(unitStorage).value() + min = limitMin(minGlobal, gte) + max = limitMax(maxGlobal, lte) // A range is given. For visualization purposes open-ended queries are // displayed as well, although making such queries is currently not // supported. @@ -364,20 +314,20 @@ export const Range = React.memo(({ maxInc = isNil(filter.lt) gte = filter.gte || filter.gt lte = filter.lte || filter.lt - gte = gte instanceof Quantity ? gte.toSI().value() : gte - lte = lte instanceof Quantity ? lte.toSI().value() : lte + gte = gte instanceof Quantity ? gte.to(unitStorage).value() : gte + lte = lte instanceof Quantity ? lte.to(unitStorage).value() : lte if (isNil(gte)) { - min = limitMin(minGlobalSI, lte) + min = limitMin(minGlobal, lte) gte = min } else { - min = limitMin(minGlobalSI, gte) + min = limitMin(minGlobal, gte) } if (isNil(lte)) { - max = limitMax(maxGlobalSI, gte) + max = limitMax(maxGlobal, gte) lte = max } else { - max = limitMax(maxGlobalSI, lte) + max = limitMax(maxGlobal, lte) } } minRef.current = min @@ -392,7 +342,7 @@ export const Range = React.memo(({ setMaxError() setMinError() } - }, [minGlobalSI, maxGlobalSI, filter, unitSI, toInternal, units, def, isTime, autorange]) + }, [minGlobal, maxGlobal, filter, unitStorage, toInternal, def, isTime, autorange]) // Returns whether the given range is an acceptable value to be queried and // displayed. @@ -405,7 +355,7 @@ export const Range = React.memo(({ // Handles changes in the min input const handleMinChange = useCallback((value) => { - loading.current = true + if (autorange) loading.current = true let val if (isTime) { value.setSeconds(0, 0) @@ -417,10 +367,11 @@ export const Range = React.memo(({ setMinInput(val) setMaxError() setMinError() - }, [isTime]) + }, [isTime, autorange]) // Handles changes in the max input const handleMaxChange = useCallback((value) => { + if (autorange) loading.current = true loading.current = true let val if (isTime) { @@ -433,7 +384,7 @@ export const Range = React.memo(({ setMaxInput(val) setMaxError() setMinError() - }, [isTime]) + }, [isTime, autorange]) // Called when min values are submitted through the input field const handleMinSubmit = useCallback((value) => { @@ -449,7 +400,7 @@ export const Range = React.memo(({ if (isNaN(number)) { error = 'Invalid minimum value.' } else { - value = toSI(number) + value = toStorageUnit(number) } } if ((isTime ? value : value.value) > range.lte) { @@ -462,11 +413,11 @@ export const Range = React.memo(({ setFilter(old => { return { gte: value, - lte: fromSI(isNil(old?.lte) ? maxLocal : old.lte) + lte: fromStorageUnit(isNil(old?.lte) ? maxLocal : old.lte) } }) } - }, [isTime, setFilter, fromSI, toSI, maxLocal, range]) + }, [isTime, setFilter, fromStorageUnit, toStorageUnit, maxLocal, range]) // Called when max values are submitted through the input field const handleMaxSubmit = useCallback((value) => { @@ -482,7 +433,7 @@ export const Range = React.memo(({ if (isNaN(number)) { error = 'Invalid maximum value.' } else { - value = toSI(number) + value = toStorageUnit(number) } } if ((isTime ? value : value.value) < range.gte) { @@ -494,12 +445,12 @@ export const Range = React.memo(({ maxInputChanged.current = false setFilter(old => { return { - gte: fromSI(isNil(old?.gte) ? minLocal : old.gte), + gte: fromStorageUnit(isNil(old?.gte) ? minLocal : old.gte), lte: value } }) } - }, [isTime, setFilter, fromSI, toSI, minLocal, range]) + }, [isTime, setFilter, fromStorageUnit, toStorageUnit, minLocal, range]) // Handle range commit: Set the filter when mouse is released on a slider. // Notice that we cannot rely on the value given by the slider event: it may @@ -509,17 +460,17 @@ export const Range = React.memo(({ const value = validRange.current if (!isNil(value) && rangeChanged.current) { setFilter({ - gte: fromSI(value[0]), - lte: fromSI(value[1]) + gte: fromStorageUnit(value[0]), + lte: fromStorageUnit(value[1]) }) rangeChanged.current = false } - }, [setFilter, fromSI]) + }, [setFilter, fromStorageUnit]) // Handle range change: only change the rendered values, filter is send with // handleRangeCommit. const handleRangeChange = useCallback((event, value, validate = true) => { - loading.current = true + if (autorange) loading.current = true const valid = !validate || validateRange(value) if (valid) { rangeChanged.current = true @@ -532,7 +483,7 @@ export const Range = React.memo(({ setMaxError() setMinError() } - }, [validateRange, validRange, toInternal]) + }, [validateRange, validRange, toInternal, autorange]) // Handle min input field blur: only if the input has changed, the filter will // be submitted. @@ -546,143 +497,51 @@ export const Range = React.memo(({ maxInputChanged.current && handleMaxSubmit(maxInput) }, [maxInput, handleMaxSubmit, maxInputChanged]) - // Determine the min input component - let inputMinField - if (dtype === DType.Timestamp) { - inputMinField = <KeyboardDateTimePicker - error={!!minError} - disabled={disabled} - helperText={minError} - ampm={false} - className={styles.inputFieldDate} - variant="inline" - inputVariant={inputVariant} - label="Start time" - format={`${dateFormat} kk:mm`} - value={minInput} - invalidDateMessage="" - InputAdornmentProps={{ position: 'end' }} - onAccept={(date) => { - handleMinChange(date) - handleMinSubmit(date) - }} - onChange={handleMinChange} - onBlur={handleMinBlur} - onKeyDown={(event) => { if (event.key === 'Enter') { handleMinSubmit(minInput) } }} - /> - } else { - inputMinField = <InputTextField - error={!!minError} - disabled={disabled} - helperText={minError} - label="min" - className={styles.inputFieldText} - inputProps={{className: styles.textInput}} - value={minInput} - margin="normal" - onChange={handleMinChange} - onBlur={handleMinBlur} - onKeyDown={(event) => { if (event.key === 'Enter') { handleMinSubmit(minInput) } }} - /> - } - - // Determine the max input component - let inputMaxField - if (dtype === DType.Timestamp) { - inputMaxField = <KeyboardDateTimePicker - error={!!maxError} + return <div className={clsx(className, styles.root)} data-testid={testID}> + <PlotHistogram + bins={plotData?.data} + xAxis={plotData?.xAxis} + step={plotData?.step} + minXInclusive={minInclusive} + maxXInclusive={maxInclusive} disabled={disabled} - helperText={maxError} - ampm={false} - className={styles.inputFieldDate} - variant="inline" - inputVariant={inputVariant} - label="End time" - format={`${dateFormat} kk:mm`} - value={maxInput} - invalidDateMessage="" - InputAdornmentProps={{ position: 'end' }} - onAccept={(date) => { - handleMaxChange(date) - handleMaxSubmit(date) + scale={scale} + nBins={nBins} + range={range} + highlight={highlight} + discretization={discretization} + tooltipLabel={repeats ? 'value' : 'entry'} + dtypeY={DType.Int} + onRangeChange={handleRangeChange} + onRangeCommit={handleRangeCommit} + onMinBlur={handleMinBlur} + onMaxBlur={handleMaxBlur} + onMinChange={handleMinChange} + onMaxChange={handleMaxChange} + onMinSubmit={handleMinSubmit} + onMaxSubmit={handleMaxSubmit} + classes={{histogram: classes?.histogram}} + onClick={(event, value) => { + handleRangeChange(event, value, false) + handleRangeCommit(event, value) }} - onChange={handleMaxChange} - onBlur={handleMaxBlur} - onKeyDown={(event) => { if (event.key === 'Enter') { handleMaxSubmit(maxInput) } }} - /> - } else { - inputMaxField = <InputTextField - error={!!maxError} - disabled={disabled} - helperText={maxError} - label="max" - className={styles.inputFieldText} - value={maxInput} - margin="normal" - onChange={handleMaxChange} - onBlur={handleMaxBlur} - onKeyDown={(event) => { if (event.key === 'Enter') { handleMaxSubmit(maxInput) } }} + minError={minError} + maxError={maxError} + minInput={minInput} + maxInput={maxInput} + minLocal={minLocal} + maxLocal={maxLocal} + showinput={showinput} + stepSlider={stepSlider} + disableHistogram={disableHistogram} + disableXTitle={disableXTitle} + data-testid={`${testID}-histogram`} /> - } - - return <div className={clsx(className, styles.root)} data-testid={testID}> - <InputTooltip unavailable={unavailable}> - <div className={styles.column}> - {!disableHistogram && - <PlotHistogram - bins={plotData?.data} - disabled={disabled} - scale={scale} - minX={plotData?.minX} - maxX={plotData?.maxX} - unitX={unitSI} - step={plotData?.step} - nBins={nBins} - range={range} - highlight={highlight} - discretization={discretization} - tooltipLabel={repeats ? 'value' : 'entry'} - dtypeX={dtype} - dtypeY={DType.Int} - onRangeChange={handleRangeChange} - onRangeCommit={handleRangeCommit} - className={clsx(styles.histogram)} - onClick={(event, value) => { - handleRangeChange(event, value, false) - handleRangeCommit(event, value) - }} - minXInclusive={minInclusive} - maxXInclusive={maxInclusive} - data-testid={`${testID}-histogram`} - /> - } - {showinput && <div className={styles.row}> - {inputMinField} - {disableHistogram && !isTime - ? <div className={styles.spacer}> - <Slider - disabled={disabled || (minLocal === maxLocal)} - color={highlight ? 'primary' : 'secondary'} - min={minLocal} - max={maxLocal} - step={stepSlider} - value={[range.gte, range.lte]} - onChange={handleRangeChange} - onChangeCommitted={handleRangeCommit} - valueLabelDisplay="off" - /> - </div> - : <div className={styles.dash} /> - } - {inputMaxField} - </div>} - </div> - </InputTooltip> </div> }) Range.propTypes = { - quantity: PropTypes.string.isRequired, + xAxis: PropTypes.object, /* Target number of steps for the slider that is shown when statistics are * disabled. The actual number may vary, as the step is chosen to be a * human-readable value that depends on the range and the unit. */ @@ -695,6 +554,8 @@ Range.propTypes = { scale: PropTypes.string, /* Whether the histogram is disabled */ disableHistogram: PropTypes.bool, + /* Whether the x title is disabled */ + disableXTitle: PropTypes.bool, /* Set the range automatically according to data. */ autorange: PropTypes.bool, /* Show the input fields for min and max value */ @@ -739,11 +600,19 @@ const InputRange = React.memo(({ 'data-testid': testID }) => { const {filterData} = useSearchContext() + const {units} = useUnitContext() const styles = useInputRangeStyles() const [scale, setScale] = useState(initialScale || filterData[quantity].scale) const dtype = filterData[quantity].dtype const isTime = dtype === DType.Timestamp const [autorange, setAutorange] = useState(isNil(initialAutorange) ? isTime : initialAutorange) + const x = useMemo(() => ( + { + quantity, + dtype, + unit: new Unit(filterData[quantity]?.unit || 'dimensionless').toSystem(units) + } + ), [quantity, filterData, dtype, units]) // Determine the description and title const def = filterData[quantity] @@ -769,12 +638,13 @@ const InputRange = React.memo(({ actions={actions} /> <Range - quantity={quantity} + xAxis={x} nSteps={nSteps} visible={visible} scale={scale} nBins={nBins} disableHistogram={disableHistogram} + disableXTitle autorange={autorange} showinput aggId={aggId} diff --git a/gui/src/components/search/input/InputRange.spec.js b/gui/src/components/search/input/InputRange.spec.js index cb32bfaca26a1e80c3ac0c08ba87ffce91b0d293..b5da2751d8b0db79c308ad39740a5fae9ddb8826 100644 --- a/gui/src/components/search/input/InputRange.spec.js +++ b/gui/src/components/search/input/InputRange.spec.js @@ -47,7 +47,7 @@ describe('test initial state', () => { time_histogram ])('quantity: %s, histogram: %s', async (quantity, histogram, min, max) => { renderSearchEntry(<InputRange visible quantity={quantity} disableHistogram={!histogram}/>) - await expectInputRange(quantity, false, histogram, false, min, max) + await expectInputRange({x: {quantity}}, false, histogram, false, min, max) }) }) diff --git a/gui/src/components/search/widgets/Dashboard.js b/gui/src/components/search/widgets/Dashboard.js index a33c766b7041af5fa902f6683c95f29bc0d2c476..105ed46fd557134defaeb74ccf1bb5c6a213a027 100644 --- a/gui/src/components/search/widgets/Dashboard.js +++ b/gui/src/components/search/widgets/Dashboard.js @@ -39,7 +39,7 @@ import WidgetGrid from './WidgetGrid' import { Actions, Action } from '../../Actions' import { useSearchContext } from '../SearchContext' import { WidgetScatterPlotEdit, schemaWidgetScatterPlot } from './WidgetScatterPlotEdit' -import { WidgetHistogramEdit, schemaWidgetHistogram } from './WidgetHistogram' +import { WidgetHistogramEdit, schemaWidgetHistogram } from './WidgetHistogramEdit' import { WidgetTermsEdit, schemaWidgetTerms } from './WidgetTerms' import { WidgetPeriodicTableEdit, schemaWidgetPeriodicTable } from './WidgetPeriodicTable' import InputConfig from '../input/InputConfig' @@ -255,7 +255,7 @@ const Dashboard = React.memo(() => { const comp = { scatterplot: <WidgetScatterPlotEdit key={id} widget={value}/>, periodictable: <WidgetPeriodicTableEdit key={id} {...value}/>, - histogram: <WidgetHistogramEdit key={id} {...value}/>, + histogram: <WidgetHistogramEdit key={id} widget={value}/>, terms: <WidgetTermsEdit key={id} {...value}/> }[value.type] return comp || null diff --git a/gui/src/components/search/widgets/Dashboard.spec.js b/gui/src/components/search/widgets/Dashboard.spec.js index a273e725141904b015fc2459de03748419c20f29..efe82a3d82a632d89651534549d1d57f12fe8662 100644 --- a/gui/src/components/search/widgets/Dashboard.spec.js +++ b/gui/src/components/search/widgets/Dashboard.spec.js @@ -69,7 +69,8 @@ describe('displaying an initial widget and removing it', () => { 'histogram', { type: 'histogram', - quantity: 'results.material.n_elements', + title: 'Test title', + x: {quantity: 'results.material.n_elements'}, scale: 'linear', editing: false, visible: true, @@ -81,7 +82,7 @@ describe('displaying an initial widget and removing it', () => { xxl: {x: Infinity, y: 0, w: 12, h: 9} } }, - async (widget, loaded) => await expectInputRange(widget.quantity, loaded, true, true) + async (widget, loaded) => await expectInputRange(widget, loaded, true, true) ], [ 'scatterplot', diff --git a/gui/src/components/search/widgets/WidgetGrid.js b/gui/src/components/search/widgets/WidgetGrid.js index f3b2dcd5831b3613b64e44d6ddd7d312f5f3f9c1..0966315a39b7e7c9d5c690d396097d194a0c3a25 100644 --- a/gui/src/components/search/widgets/WidgetGrid.js +++ b/gui/src/components/search/widgets/WidgetGrid.js @@ -178,13 +178,13 @@ const useStyles = makeStyles(theme => { return { root: { position: 'relative', - marginLeft: theme.spacing(-1), - marginRight: theme.spacing(-1) + marginLeft: theme.spacing(-0.75), + marginRight: theme.spacing(-0.75) }, component: { position: 'absolute', - top: theme.spacing(1), - bottom: theme.spacing(1.5), + top: theme.spacing(0.5), + bottom: theme.spacing(1.25), left: theme.spacing(1.5), right: theme.spacing(1.5), height: 'unset', @@ -195,10 +195,10 @@ const useStyles = makeStyles(theme => { }, containerInner: { position: 'absolute', - top: theme.spacing(1), - bottom: theme.spacing(1), - left: theme.spacing(1), - right: theme.spacing(1), + top: theme.spacing(0.75), + bottom: theme.spacing(0.75), + left: theme.spacing(0.75), + right: theme.spacing(0.75), height: 'unset', width: 'unset' } @@ -220,14 +220,44 @@ const WidgetGrid = React.memo(({ const layout = useMemo(() => { if (!nCols) return {} + // If layouts are not provided for all different breakpoints, duplicate the + // closest layout that is found. We need to work on copies as the widgets + // information is immutable. + const widgetCopies = {} + for (const [id, widget] of Object.entries(widgets)) { + if (!widget.visible) continue + if (!widget.layout) { + widgetCopies[id] = widget + continue + } + const widgetCopy = cloneDeep(widget) + const breakpointNames = Object.keys(breakpoints) + for (const i of range(0, breakpointNames.length)) { + const breakpoint = breakpointNames[i] + if (!widgetCopy.layout[breakpoint]) { + for (const j of range(1, breakpointNames.length - 1)) { + const breakpointSmaller = widgetCopy.layout[breakpointNames[i - j]] + const breakpointBigger = widgetCopy.layout[breakpointNames[i + j]] + if (breakpointSmaller) { + widgetCopy.layout[breakpoint] = breakpointSmaller + break + } else if (breakpointBigger) { + widgetCopy.layout[breakpoint] = breakpointBigger + break + } + } + } + } + widgetCopies[id] = widgetCopy + } + const layouts = {} for (const breakpoint of Object.keys(breakpoints)) { // This is the layout in the format as react-grid-layout would read it. x: // Infinity means that we want to place the item at the very end. // Add widgets let i = 0 - let layout = Object.entries(widgets) - .filter(([id, value]) => value?.visible) + let layout = Object.entries(widgetCopies) .map(([id, value]) => { const layout = value.layout?.[breakpoint] const config = { @@ -345,7 +375,9 @@ export default WidgetGrid const useHandleStyles = makeStyles(theme => { return { root: { - margin: theme.spacing(1.25) + margin: theme.spacing(1), + width: '25px', + height: '25px' } } }) @@ -354,8 +386,11 @@ const ResizeHandle = React.forwardRef((props, ref) => { const styles = useHandleStyles() return <div ref={ref} - className={clsx('react-resizable-handle', `react-resizable-handle-${handleAxis}`, styles.root)} - style={{width: '30px', height: '30px'}} + className={clsx( + 'react-resizable-handle', + `react-resizable-handle-${handleAxis}`, + styles.root + )} {...restProps} > </div> diff --git a/gui/src/components/search/widgets/WidgetHistogram.js b/gui/src/components/search/widgets/WidgetHistogram.js index 9e76e883972cd12031e83082b26558660db265f7..e0fbdf1f3f1a72285f9deb731cb1e19fb6f46484 100644 --- a/gui/src/components/search/widgets/WidgetHistogram.js +++ b/gui/src/components/search/widgets/WidgetHistogram.js @@ -15,27 +15,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useCallback, useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import PropTypes from 'prop-types' -import { string, number, bool } from 'yup' -import { - TextField, - MenuItem, - Checkbox, - FormControlLabel -} from '@material-ui/core' import { useSearchContext } from '../SearchContext' -import { InputMetainfo } from '../input/InputMetainfo' -import { InputTextField } from '../input/InputText' -import { Widget, schemaWidget } from './Widget' +import { Widget } from './Widget' import { ActionCheckbox, ActionSelect } from '../../Actions' -import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption } from './WidgetEdit' import { Range } from '../input/InputRange' -import { DType } from '../../../utils' import { scales } from '../../plotting/common' - -// Predefined in order to not break memoization -const dtypes = new Set([DType.Float, DType.Int, DType.Timestamp]) +import {getDisplayLabel} from '../../../utils' +import { Unit } from '../../units/Unit' +import { useUnitContext } from '../../units/UnitContext' /** * Displays a histogram widget. @@ -46,16 +35,34 @@ export const WidgetHistogram = React.memo(( id, title, description, - quantity, + x, nbins, scale, autorange, showinput, className }) => { - const { useSetWidget } = useSearchContext() + const { filterData, useSetWidget } = useSearchContext() + const {units} = useUnitContext() const setWidget = useSetWidget(id) + // Create final axis config for the plot + const xAxis = useMemo(() => { + const xFilter = filterData[x.quantity] + const xTitle = x.title || getDisplayLabel(xFilter) + const xType = xFilter?.dtype + const xUnit = x.unit + ? new Unit(x.unit) + : new Unit(xFilter.unit || 'dimensionless').toSystem(units) + + return { + ...x, + title: xTitle, + unit: xUnit, + dtype: xType + } + }, [filterData, x, units]) + const handleEdit = useCallback(() => { setWidget(old => { return {...old, editing: true } }) }, [setWidget]) @@ -66,8 +73,7 @@ export const WidgetHistogram = React.memo(( return <Widget id={id} - quantity={quantity} - title={title} + title={title || 'Histogram'} description={description} onEdit={handleEdit} className={className} @@ -87,14 +93,13 @@ export const WidgetHistogram = React.memo(( </>} > <Range - quantity={quantity} + xAxis={xAxis} visible={true} nBins={nbins} scale={scale} anchored={true} autorange={autorange} showinput={showinput} - disableHistogram={false} aggId={id} /> </Widget> @@ -104,152 +109,10 @@ WidgetHistogram.propTypes = { id: PropTypes.string.isRequired, title: PropTypes.string, description: PropTypes.string, - quantity: PropTypes.string, + x: PropTypes.object, nbins: PropTypes.number, scale: PropTypes.string, autorange: PropTypes.bool, showinput: PropTypes.bool, className: PropTypes.string } - -/** - * A dialog that is used to configure a scatter plot widget. - */ -export const WidgetHistogramEdit = React.memo((props) => { - const {id, editing, visible} = props - const { useSetWidget } = useSearchContext() - const [settings, setSettings] = useState(props) - const [errors, setErrors] = useState({}) - const setWidget = useSetWidget(id) - const hasError = useMemo(() => { - return Object.values(errors).some((d) => !!d) || !schemaWidgetHistogram.isValidSync(settings) - }, [errors, settings]) - - const handleSubmit = useCallback((settings) => { - setWidget(old => ({...old, ...settings})) - }, [setWidget]) - - const handleChange = useCallback((key, value) => { - setSettings(old => ({...old, [key]: value})) - }, [setSettings]) - - const handleError = useCallback((key, value) => { - setErrors(old => ({...old, [key]: value})) - }, [setErrors]) - - const handleAccept = useCallback((key, value) => { - try { - schemaWidgetHistogram.validateSyncAt(key, {[key]: value}) - } catch (e) { - handleError(key, e.message) - return - } - setErrors(old => ({...old, [key]: undefined})) - setSettings(old => ({...old, [key]: value})) - }, [handleError, setSettings]) - - const handleClose = useCallback(() => { - setWidget(old => ({...old, editing: false})) - }, [setWidget]) - - const handleEditAccept = useCallback(() => { - handleSubmit({...settings, editing: false, visible: true}) - }, [handleSubmit, settings]) - - return <WidgetEditDialog - id={id} - open={editing} - visible={visible} - title="Edit histogram widget" - onClose={handleClose} - onAccept={handleEditAccept} - error={hasError} - > - <WidgetEditGroup title="x axis"> - <WidgetEditOption> - <InputMetainfo - label="quantity" - value={settings.quantity} - error={errors.quantity} - onChange={(value) => handleChange('quantity', value)} - onSelect={(value) => handleAccept('quantity', value)} - onError={(value) => handleError('quantity', value)} - dtypes={dtypes} - dtypesRepeatable={dtypes} - /> - </WidgetEditOption> - <WidgetEditOption> - <TextField - select - fullWidth - label="Statistics scaling" - variant="filled" - value={settings.scale} - onChange={(event) => { handleChange('scale', event.target.value) }} - > - {Object.keys(scales).map((key) => - <MenuItem value={key} key={key}>{key}</MenuItem> - )} - </TextField> - </WidgetEditOption> - <WidgetEditOption> - <TextField - select - fullWidth - label="Maximum number of bins" - variant="filled" - value={settings.nbins} - onChange={(event) => { handleChange('nbins', event.target.value) }} - > - <MenuItem value={10}>10</MenuItem> - <MenuItem value={20}>20</MenuItem> - <MenuItem value={30}>30</MenuItem> - <MenuItem value={40}>40</MenuItem> - <MenuItem value={50}>50</MenuItem> - </TextField> - </WidgetEditOption> - </WidgetEditGroup> - <WidgetEditGroup title="general"> - <WidgetEditOption> - <InputTextField - label="title" - fullWidth - value={settings?.title} - onChange={(event) => handleChange('title', event.target.value)} - /> - </WidgetEditOption> - <WidgetEditOption> - <FormControlLabel - control={<Checkbox checked={settings.autorange} onChange={(event, value) => handleChange('autorange', value)}/>} - label={autorangeDescription} - /> - </WidgetEditOption> - <WidgetEditOption> - <FormControlLabel - control={<Checkbox checked={settings.showinput} onChange={(event, value) => handleChange('showinput', value)}/>} - label='Show input fields' - /> - </WidgetEditOption> - </WidgetEditGroup> - </WidgetEditDialog> -}) - -WidgetHistogramEdit.propTypes = { - id: PropTypes.string.isRequired, - editing: PropTypes.bool, - visible: PropTypes.bool, - quantity: PropTypes.string, - scale: PropTypes.string, - nbins: PropTypes.number, - autorange: PropTypes.bool, - showinput: PropTypes.bool, - onClose: PropTypes.func -} - -export const schemaWidgetHistogram = schemaWidget.shape({ - quantity: string().required('Quantity is required.'), - scale: string().required('Scale is required.'), - nbins: number().integer().required(), - autorange: bool(), - showinput: bool() -}) diff --git a/gui/src/components/search/widgets/WidgetHistogramEdit.js b/gui/src/components/search/widgets/WidgetHistogramEdit.js new file mode 100644 index 0000000000000000000000000000000000000000..9e557923b9a967d438a72e3fdfa4587e8252bb01 --- /dev/null +++ b/gui/src/components/search/widgets/WidgetHistogramEdit.js @@ -0,0 +1,220 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useCallback } from 'react' +import PropTypes from 'prop-types' +import { string, number, bool, reach } from 'yup' +import { cloneDeep } from 'lodash' +import { + TextField, + MenuItem, + Checkbox, + FormControlLabel +} from '@material-ui/core' +import { useSearchContext } from '../SearchContext' +import { InputMetainfo } from '../input/InputMetainfo' +import { InputTextField } from '../input/InputText' +import UnitInput from '../../units/UnitInput' +import { schemaWidget, schemaAxis } from './Widget' +import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption } from './WidgetEdit' +import { DType, parseJMESPath, setDeep, isEmptyString } from '../../../utils' +import { scales } from '../../plotting/common' +import { autorangeDescription } from './WidgetHistogram' + +// Predefined in order to not break memoization +const dtypes = new Set([DType.Float, DType.Int, DType.Timestamp]) + +/** + * A dialog that is used to configure a scatter plot widget. + */ +export const WidgetHistogramEdit = React.memo(({widget}) => { + const { filterData, useSetWidget } = useSearchContext() + const [settings, setSettings] = useState(cloneDeep(widget)) + const [errors, setErrors] = useState({}) + const [dimensions, setDimensions] = useState({}) + const setWidget = useSetWidget(widget.id) + + const handleChange = useCallback((key, value) => { + setSettings(old => { + const newValue = {...old} + setDeep(newValue, key, value) + return newValue + }) + }, [setSettings]) + + const handleError = useCallback((key, value) => { + setErrors(old => ({...old, [key]: value})) + }, [setErrors]) + + const handleErrorQuantity = useCallback((key, value) => { + handleError(key, value) + setDimensions((old) => ({...old, [key]: null})) + }, [handleError]) + + const handleAccept = useCallback((key, value) => { + try { + reach(schemaWidgetHistogram, key).validateSync(value) + } catch (e) { + handleError(key, e.message) + return + } + setErrors(old => ({...old, [key]: undefined})) + handleChange(key, value) + }, [handleError, handleChange]) + + const handleAcceptQuantity = useCallback((key, value) => { + handleAccept(key, value) + const { quantity } = parseJMESPath(value) + const dimension = filterData[quantity]?.dimension + setDimensions((old) => ({...old, [key]: dimension})) + }, [handleAccept, filterData]) + + const handleClose = useCallback(() => { + setWidget(old => ({...old, editing: false})) + }, [setWidget]) + + // Upon accepting the entire form, we perform final validation. + const handleEditAccept = useCallback(() => { + const independentErrors = Object.values(errors).some(x => !!x) + if (independentErrors) return + + // Check for missing values. This check is required because there is no + // value set when a new widget is created, and pressing the done button + // without filling a value should raise an error. + const xEmpty = isEmptyString(settings?.x?.quantity) + if (xEmpty) { + handleErrorQuantity('x.quantity', 'Please specify a value.') + } + + if (!independentErrors && !xEmpty) { + setWidget(old => ({...old, ...{...settings, editing: false, visible: true}})) + } + }, [settings, setWidget, errors, handleErrorQuantity]) + + return <WidgetEditDialog + id={widget.id} + open={widget.editing} + visible={widget.visible} + title="Edit histogram widget" + onClose={handleClose} + onAccept={handleEditAccept} + > + <WidgetEditGroup title="x axis"> + <WidgetEditOption> + <InputMetainfo + label="quantity" + value={settings.x?.quantity} + error={errors['x.quantity']} + onChange={(value) => handleChange('x.quantity', value)} + onAccept={(value) => handleAcceptQuantity('x.quantity', value)} + onSelect={(value) => handleAcceptQuantity('x.quantity', value)} + onError={(value) => handleErrorQuantity('x.quantity', value)} + dtypes={dtypes} + dtypesRepeatable={dtypes} + /> + </WidgetEditOption> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings.x?.title} + onChange={(event) => handleChange('x.title', event.target.value)} + /> + </WidgetEditOption> + <WidgetEditOption> + <UnitInput + label='unit' + value={settings.x?.unit} + onChange={(value) => handleChange('x.unit', value)} + onSelect={(value) => handleAccept('x.unit', value)} + onAccept={(value) => handleAccept('x.unit', value)} + error={errors['x.unit']} + onError={(value) => handleError('x.unit', value)} + dimension={dimensions['x.quantity'] || null} + optional + disableGroup + /> + </WidgetEditOption> + </WidgetEditGroup> + <WidgetEditGroup title="general"> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings?.title} + onChange={(event) => handleChange('title', event.target.value)} + /> + </WidgetEditOption> + <WidgetEditOption> + <TextField + select + fullWidth + label="Statistics scaling" + variant="filled" + value={settings.scale} + onChange={(event) => { handleChange('scale', event.target.value) }} + > + {Object.keys(scales).map((key) => + <MenuItem value={key} key={key}>{key}</MenuItem> + )} + </TextField> + </WidgetEditOption> + <WidgetEditOption> + <TextField + select + fullWidth + label="Maximum number of bins" + variant="filled" + value={settings.nbins} + onChange={(event) => { handleChange('nbins', event.target.value) }} + > + <MenuItem value={10}>10</MenuItem> + <MenuItem value={20}>20</MenuItem> + <MenuItem value={30}>30</MenuItem> + <MenuItem value={40}>40</MenuItem> + <MenuItem value={50}>50</MenuItem> + </TextField> + </WidgetEditOption> + + <WidgetEditOption> + <FormControlLabel + control={<Checkbox checked={settings.autorange} onChange={(event, value) => handleChange('autorange', value)}/>} + label={autorangeDescription} + /> + </WidgetEditOption> + <WidgetEditOption> + <FormControlLabel + control={<Checkbox checked={settings.showinput} onChange={(event, value) => handleChange('showinput', value)}/>} + label='Show input fields' + /> + </WidgetEditOption> + </WidgetEditGroup> + </WidgetEditDialog> +}) + +WidgetHistogramEdit.propTypes = { + widget: PropTypes.object, + onClose: PropTypes.func +} + +export const schemaWidgetHistogram = schemaWidget.shape({ + x: schemaAxis.required('Quantity for the x axis is required.'), + scale: string().required('Scale is required.'), + nbins: number().integer().required(), + autorange: bool(), + showinput: bool() +}) diff --git a/gui/src/components/search/widgets/WidgetHistogramEdit.spec.js b/gui/src/components/search/widgets/WidgetHistogramEdit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c449b450099ae759c9062ac1adc5b3a41900a667 --- /dev/null +++ b/gui/src/components/search/widgets/WidgetHistogramEdit.spec.js @@ -0,0 +1,41 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react' +import userEvent from '@testing-library/user-event' +import { screen } from '../../conftest.spec' +import { renderSearchEntry } from '../conftest.spec' +import { WidgetHistogramEdit } from './WidgetHistogramEdit' + +describe('test edit dialog error messages', () => { + test.each([ + ['missing x', {x: {}}, 'Please specify a value.'], + ['unavailable x', {x: {quantity: 'results.material.not_a_quantity'}}, 'The quantity "results.material.not_a_quantity" is not available.'], + ['invalid x unit', {x: {quantity: 'results.material.topology.cell.a', unit: 'nounit'}}, 'Unit "nounit" not found.'], + ['incompatible x unit', {x: {quantity: 'results.material.topology.cell.a', unit: 'joule'}}, 'Unit "joule" is incompatible with dimension "length".'] + ])('%s', async (name, config, error) => { + const finalConfig = { + id: '0', + editing: true, + ...config + } + renderSearchEntry(<WidgetHistogramEdit widget={finalConfig} />) + const button = screen.getByText('Done') + await userEvent.click(button) + screen.getByText(error) + }) +}) diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js index 8c43207d265055bdcc0ba3257067bb80122d9ec9..909a3506467b5d28a684e8d9572a7f6eea57ab95 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js +++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js @@ -28,7 +28,7 @@ import { schemaWidget, schemaAxis, schemaMarkers } from './Widget' import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption, WidgetEditSelect } from './WidgetEdit' import { useSearchContext } from '../SearchContext' import { autorangeDescription } from './WidgetHistogram' -import { DType, setDeep, parseJMESPath } from '../../../utils' +import { DType, setDeep, parseJMESPath, isEmptyString } from '../../../utils' import { InputTextField } from '../input/InputText' import UnitInput from '../../units/UnitInput' @@ -40,9 +40,6 @@ const nPointsOptions = { 1000: 1000, 10000: 10000 } -function isEmptyString(value) { - return value === undefined || value === null || !value?.trim?.()?.length -} /** * A dialog that is used to configure a scatter plot widget. */ @@ -99,16 +96,16 @@ export const WidgetScatterPlotEdit = React.memo(({widget}) => { const independentErrors = Object.values(errors).some(x => !!x) if (independentErrors) return - // Check for missing values: TODO: This kind of check should be replaced - // by a dedicated form context. Inputs could automatically register into - // this form to enable imperative validation etc. + // Check for missing values. This check is required because there is no + // value set when a new widget is created, and pressing the done button + // without filling a value should raise an error. const xEmpty = isEmptyString(settings?.x?.quantity) if (xEmpty) { - handleErrorQuantity('x.quantity', 'Please specify a value') + handleErrorQuantity('x.quantity', 'Please specify a value.') } const yEmpty = isEmptyString(settings?.y?.quantity) if (yEmpty) { - handleErrorQuantity('y.quantity', 'Please specify a value') + handleErrorQuantity('y.quantity', 'Please specify a value.') } if (!independentErrors && !xEmpty && !yEmpty) { diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js index 04382e408e777565031aabbde5fce54232c96a2f..7f8d458f4a66f0c8583d276634dc8e4b1c8a6f82 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js +++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js @@ -23,8 +23,8 @@ import { WidgetScatterPlotEdit } from './WidgetScatterPlotEdit' describe('test edit dialog error messages', () => { test.each([ - ['missing x', {x: {quantity: 'results.material.n_elements'}}, 'Please specify a value'], - ['missing y', {y: {quantity: 'results.material.n_elements'}}, 'Please specify a value'], + ['missing x', {x: {quantity: 'results.material.n_elements'}}, 'Please specify a value.'], + ['missing y', {y: {quantity: 'results.material.n_elements'}}, 'Please specify a value.'], ['unavailable x', {x: {quantity: 'results.material.not_a_quantity'}}, 'The quantity "results.material.not_a_quantity" is not available.'], ['unavailable y', {y: {quantity: 'results.material.not_a_quantity'}}, 'The quantity "results.material.not_a_quantity" is not available.'], ['unavailable color', {markers: {color: {quantity: 'results.material.not_a_quantity'}}}, 'The quantity "results.material.not_a_quantity" is not available.'], @@ -37,9 +37,9 @@ describe('test edit dialog error messages', () => { ['invalid x unit', {x: {quantity: 'results.material.topology[0].cell.a', unit: 'nounit'}}, 'Unit "nounit" not found.'], ['invalid y unit', {y: {quantity: 'results.material.topology[0].cell.a', unit: 'nounit'}}, 'Unit "nounit" not found.'], ['invalid color unit', {markers: {color: {quantity: 'results.material.topology[0].cell.a', unit: 'nounit'}}}, 'Unit "nounit" not found.'], - ['incompatible x unit', {x: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}, 'Unit "joule" is incompatible with dimension "length"'], - ['incompatible y unit', {y: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}, 'Unit "joule" is incompatible with dimension "length"'], - ['incompatible color unit', {markers: {color: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}}, 'Unit "joule" is incompatible with dimension "length"'] + ['incompatible x unit', {x: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}, 'Unit "joule" is incompatible with dimension "length".'], + ['incompatible y unit', {y: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}, 'Unit "joule" is incompatible with dimension "length".'], + ['incompatible color unit', {markers: {color: {quantity: 'results.material.topology[0].cell.a', unit: 'joule'}}}, 'Unit "joule" is incompatible with dimension "length".'] ])('%s', async (name, config, error) => { const finalConfig = { id: '0', diff --git a/gui/src/components/search/widgets/WidgetToggle.js b/gui/src/components/search/widgets/WidgetToggle.js index 8f9666ca88d9ce596e64a2beb96a04c1e472744b..b017ce9465d7783006b1958907d0089c68557347 100644 --- a/gui/src/components/search/widgets/WidgetToggle.js +++ b/gui/src/components/search/widgets/WidgetToggle.js @@ -49,9 +49,7 @@ const WidgetToggle = React.memo(({quantity, disabled, 'data-testid': testID}) => id: quantity, editing: false, visible: true, - quantity: quantity, - ...cloneDeep(widgetDefault), - scale: filterData[quantity].scale + ...cloneDeep(widgetDefault) } if (hasWidget) { removeWidget(quantity) diff --git a/gui/src/components/units/Quantity.js b/gui/src/components/units/Quantity.js index 5b0e44996f29dbdeb85c0ab5d7b6d4a9777c6766..4926ffac61d2db5651f833bf38f5d3cb7fabb6ed 100644 --- a/gui/src/components/units/Quantity.js +++ b/gui/src/components/units/Quantity.js @@ -137,7 +137,7 @@ export function parseQuantity(input, dimension = 'dimensionless', requireValue = valueString = undefined value = undefined if (requireValue) { - error = 'Enter a valid numerical value' + error = 'Enter a valid numerical value.' } } else { value = Number(valueString) @@ -146,7 +146,7 @@ export function parseQuantity(input, dimension = 'dimensionless', requireValue = // Check unit if required if (requireUnit) { if (unitString === '') { - return {valueString, value, error: 'Unit is required'} + return {valueString, value, error: 'Unit is required.'} } } @@ -180,7 +180,7 @@ export function parseQuantity(input, dimension = 'dimensionless', requireValue = // units are compared. if (dimension !== null) { if (!(unit.dimension(true) === dimension || unit.dimension(false) === dimension)) { - error = `Unit "${unit.label(false)}" is incompatible with dimension "${dimension}"` + error = `Unit "${unit.label(false)}" is incompatible with dimension "${dimension}".` } } diff --git a/gui/src/components/units/Quantity.spec.js b/gui/src/components/units/Quantity.spec.js index 205326066f8ed5d12e9952d05eb89b776b0470ee..8179c40a9f212118887ede8c054a7e6c384b3b1a 100644 --- a/gui/src/components/units/Quantity.spec.js +++ b/gui/src/components/units/Quantity.spec.js @@ -128,9 +128,9 @@ test.each([ ['unit only', 'joule', null, false, true, {valueString: undefined, value: undefined, unit: new Unit('joule')}], ['number and unit with dimension', '100 joule', 'energy', true, true, {valueString: '100', value: 100, unit: new Unit('joule')}], ['number and unit without dimension', '100 joule', null, true, true, {valueString: '100', value: 100, unit: new Unit('joule')}], - ['incorrect dimension', '100 joule', 'length', true, true, {valueString: '100', value: 100, unit: new Unit('joule'), error: 'Unit "joule" is incompatible with dimension "length"'}], - ['missing unit', '100', 'length', true, true, {valueString: '100', value: 100, unit: undefined, error: 'Unit is required'}], - ['missing value', 'joule', 'energy', true, true, {valueString: undefined, value: undefined, unit: new Unit('joule'), error: 'Enter a valid numerical value'}], + ['incorrect dimension', '100 joule', 'length', true, true, {valueString: '100', value: 100, unit: new Unit('joule'), error: 'Unit "joule" is incompatible with dimension "length".'}], + ['missing unit', '100', 'length', true, true, {valueString: '100', value: 100, unit: undefined, error: 'Unit is required.'}], + ['missing value', 'joule', 'energy', true, true, {valueString: undefined, value: undefined, unit: new Unit('joule'), error: 'Enter a valid numerical value.'}], ['mixing number and quantity #1', '1 / joule', 'energy^-1', false, false, {valueString: '1', value: 1, unit: new Unit('1 / joule')}], ['mixing number and quantity #2', '100 / joule', 'energy^-1', false, false, {valueString: '100', value: 100, unit: new Unit('1 / joule')}] diff --git a/gui/src/utils.js b/gui/src/utils.js index c1633cbb6a8e3998adb31d21cdb3fba4f9f0214d..7b9ec7ae672c1715539c81c0966d3be415c815ef 100644 --- a/gui/src/utils.js +++ b/gui/src/utils.js @@ -1766,3 +1766,13 @@ export function getDisplayLabel(def, isArchive = false, technicalView = false) { : def.name.replace(/_/g, ' ') return eln?.[0].label || def?.more?.label || def?.label || (isArchive ? name : capitalize(name)) } + +/** + * Checks if the given string is empty: undefined, null or contains only + * whitespace. + * @param {*} value The value to check + * @returns Whether the string is empty + */ +export function isEmptyString(value) { + return value === undefined || value === null || !value?.trim?.()?.length +} diff --git a/gui/tests/env.js b/gui/tests/env.js index 51abd35805964ab2c973aba1dec8ce0ceca1418f..bb7c2061a3ca797aab88405bd9c5ed32966b7257 100644 --- a/gui/tests/env.js +++ b/gui/tests/env.js @@ -2065,7 +2065,9 @@ window.nomadEnv = { "minW": 3 } }, - "quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity", + "x": { + "quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity" + }, "scale": "1/4", "autorange": true, "showinput": true, @@ -2164,7 +2166,9 @@ window.nomadEnv = { "minW": 8 } }, - "quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", + "x": { + "quantity": "results.properties.electronic.band_structure_electronic.band_gap.value" + }, "scale": "1/4", "autorange": false, "showinput": false, @@ -2521,7 +2525,9 @@ window.nomadEnv = { "minW": 3 } }, - "quantity": "results.material.topology.pore_limiting_diameter", + "x": { + "quantity": "results.material.topology.pore_limiting_diameter" + }, "scale": "linear", "autorange": false, "showinput": true, @@ -2571,7 +2577,9 @@ window.nomadEnv = { "minW": 3 } }, - "quantity": "results.material.topology.largest_cavity_diameter", + "x": { + "quantity": "results.material.topology.largest_cavity_diameter" + }, "scale": "linear", "autorange": false, "showinput": true, @@ -2621,7 +2629,9 @@ window.nomadEnv = { "minW": 3 } }, - "quantity": "results.material.topology.accessible_surface_area", + "x": { + "quantity": "results.material.topology.accessible_surface_area" + }, "scale": "linear", "autorange": false, "showinput": true, @@ -2671,7 +2681,9 @@ window.nomadEnv = { "minW": 3 } }, - "quantity": "results.material.topology.void_fraction", + "x": { + "quantity": "results.material.topology.void_fraction" + }, "scale": "linear", "autorange": false, "showinput": true, @@ -3200,7 +3212,9 @@ window.nomadEnv = { "minW": 8 } }, - "quantity": "results.properties.catalytic.reaction.weight_hourly_space_velocity", + "x": { + "quantity": "results.properties.catalytic.reaction.weight_hourly_space_velocity" + }, "scale": "linear", "autorange": false, "showinput": false, @@ -3309,7 +3323,9 @@ window.nomadEnv = { "minW": 8 } }, - "quantity": "results.properties.catalytic.reaction.pressure", + "x": { + "quantity": "results.properties.catalytic.reaction.pressure" + }, "scale": "linear", "autorange": false, "showinput": false, @@ -3597,7 +3613,9 @@ window.nomadEnv = { "minW": 8 } }, - "quantity": "results.properties.catalytic.catalyst_characterization.surface_area", + "x": { + "quantity": "results.properties.catalytic.catalyst_characterization.surface_area" + }, "scale": "1/4", "autorange": false, "showinput": false, diff --git a/nomad/config/models/ui.py b/nomad/config/models/ui.py index 0c9d98f86cd90cf6940629c4ba93aaba78a47ab3..7e6bc75ad52e91e0330d1bd4188369a4ede0d76a 100644 --- a/nomad/config/models/ui.py +++ b/nomad/config/models/ui.py @@ -471,7 +471,12 @@ class WidgetHistogram(Widget): type: Literal['histogram'] = Field( 'histogram', description='Set as `histogram` to get this widget type.' ) - quantity: str = Field(description='Targeted quantity.') + quantity: Optional[str] = Field( + description='Targeted quantity. Note that this field is deprecated and `x` should be used instead.' + ) + x: Union[Axis, str] = Field( + description='Configures the information source and display options for the x-axis.' + ) scale: ScaleEnum = Field(description='Statistics scaling.') autorange: bool = Field( True, @@ -488,6 +493,19 @@ class WidgetHistogram(Widget): """ ) + @root_validator(pre=True) + def __validate(cls, values): + """Ensures backwards compatibility for quantity.""" + quantity = values.get('quantity') + x = values.get('x') + if quantity and not x: + values['x'] = {'quantity': quantity} + del values['quantity'] + elif isinstance(x, str): + values['x'] = {'quantity': x} + + return values + class WidgetPeriodicTable(Widget): """Periodic table widget configuration."""