diff --git a/gui/src/components/Actions.js b/gui/src/components/Actions.js index 52e56248fdcadb4b9bec51df3f2fb751895bd036..7edf874b5b149305de99d0d5df7afe98e62d6c19 100644 --- a/gui/src/components/Actions.js +++ b/gui/src/components/Actions.js @@ -195,7 +195,7 @@ export const ActionSelect = React.memo(({value, options, tooltip, onChange}) => onChange={(event) => onChange && onChange(event.target.value)} > {Object.entries(items).map(([key, value]) => - <MenuItem key={key} value={value}>{key}</MenuItem> + <MenuItem key={key} value={key}>{value}</MenuItem> )} </Select> </Action> diff --git a/gui/src/components/plotting/Plot.js b/gui/src/components/plotting/Plot.js index edbfcf661d1131e302a2d5f725dba0d22511642a..9b9d027fec4e8eb493eeee6b1a24ec94d60bbc22 100644 --- a/gui/src/components/plotting/Plot.js +++ b/gui/src/components/plotting/Plot.js @@ -685,7 +685,7 @@ Plot.propTypes = { } Plot.defaultProps = { floatTitle: '', - fixedMargins: true, + fixedMargins: true, // If set to true, the margins will be fixed after first render. throttleResize: false } diff --git a/gui/src/components/plotting/PlotAxis.js b/gui/src/components/plotting/PlotAxis.js index 6a925ffcc5712023f0016c3dfbe7ee1a7f127827..9713f427d31ba9c8023b7b9ec4482fc2c2d1dc3d 100644 --- a/gui/src/components/plotting/PlotAxis.js +++ b/gui/src/components/plotting/PlotAxis.js @@ -158,11 +158,18 @@ const PlotAxis = React.memo(({ return labels.map((tick) => ({...tick, label: formatTick(tick.label), pos: scaler(tick.pos) / axisSize})) } - // Determine the number of ticks that fits. Calculated with formula: - // axisSize - scaler(max - max/nLabels) = labelSize - // -> nLabels = max / (max - scaler.invert(axisHeight - labelSize) - const nItemsFit = Math.floor((max - min) / (max - scaler.invert(axisSize - labelSize))) - const nItems = Math.min(labels, Math.max(2, nItemsFit)) + // On linear and log axes, the labels are spaced evenly, and the number of + // labels is calculated from the available space. On non-linearly spaced + // axes, we calculate the number of labels with formula: + // axisSize - scaler(max - (max-min) / nLabels) = labelSize + // -> nLabels = (max-min) / (max - scaler.invert(axisSize - labelSize) + const padding = 10 + let nItems = (scale === 'linear' || scale === 'log') + ? Math.floor(axisSize / (labelSize + padding)) + : Math.floor((max - min) / (max - scaler.invert(axisSize - (labelSize + padding)))) + + // At least two labels should be attempted to be shown + nItems = Math.max(2, nItems) // If the scale length is zero, show only one tick if (min === max) { @@ -175,14 +182,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. - return getTicks(min, max, nItems, dtype, mode, decimals) + return getTicks(min, max, nItems, scale, dtype, mode, decimals) .map(({tick, value}) => { return { label: tick, pos: scaler(value) / axisSize } }) - }, [axisSize, dtype, labelSize, labels, max, min, scaler, mode, decimals]) + }, [axisSize, dtype, labelSize, labels, max, min, scaler, mode, decimals, scale]) // Here we estimate the maximum label width. This is a relatively simple // approximattion calculated using the font size. A more reliable way would to @@ -242,7 +249,7 @@ PlotAxis.propTypes = { scale: PropTypes.string, mode: PropTypes.oneOf(['scientific', 'SI', 'standard']), decimals: PropTypes.number, - labels: PropTypes.oneOfType([PropTypes.number, PropTypes.array]), + labels: PropTypes.array, placement: PropTypes.oneOf(['left', 'bottom']), labelHeight: PropTypes.number, labelWidth: PropTypes.number, diff --git a/gui/src/components/plotting/PlotAxis.spec.js b/gui/src/components/plotting/PlotAxis.spec.js index b019b9c75c6d6096c4f4afc82a3304e65ecebe3a..558d3eac7f220bd607944db3d16eb24730c0931e 100644 --- a/gui/src/components/plotting/PlotAxis.spec.js +++ b/gui/src/components/plotting/PlotAxis.spec.js @@ -17,12 +17,12 @@ */ import React from 'react' import { format, getTime } from 'date-fns' -import { render, screen } from '../conftest.spec' +import { renderNoAPI, screen } from '../conftest.spec' import PlotAxis from './PlotAxis' import { DType } from '../../utils' -const mockHeight = 50 -const mockWidth = 300 +const mockHeight = 70 +const mockWidth = 200 const labelHeight = 12 const labelWidth = 50 const yearFormat = 'yyyy' @@ -44,25 +44,25 @@ function getTs(...args) { } test.each([ - ['only two ticks', 'left', 'linear', 0, 1, DType.Int, 'SI', 2, ['0', '1']], - ['do not show fractional ticks for integers', 'left', 'linear', 0, 1, DType.Int, 'SI', 6, ['0', '1']], - ['show fractional ticks for floats', 'left', 'linear', 0, 1, DType.Float, 'scientific', 6, ['0', '0.5', '1']], - ['do not not show more ticks than fit vertically', 'left', 'linear', 0, 100, DType.Int, 'SI', 20, ['0', '50', '100']], - ['do not not show more ticks than fit horizontally', 'bottom', 'linear', 0, 100, DType.Int, 'SI', 20, ['0', '20', '40', '60', '80', '100']], - ['thousands SI format', 'left', 'linear', 0, 5000, DType.Int, 'SI', 3, ['0', '2.5k', '5k']], - ['thousands scientific format', 'left', 'linear', 0, 5000, DType.Int, 'scientific', 3, ['0', '2.5e+3', '5e+3']], - ['millions SI format', 'left', 'linear', 0, 5000000, DType.Int, 'SI', 3, ['0', '2.5M', '5M']], - ['millions scientific format', 'left', 'linear', 0, 5000000, DType.Int, 'scientific', 3, ['0', '2.5e+6', '5e+6']], - ['negative numbers', 'left', 'linear', -5000, 0, DType.Int, 'SI', 3, ['-5k', '-2.5k', '0']], - ['timestamp seconds', 'bottom', 'linear', 0, 2000, DType.Timestamp, undefined, 3, [0, 1000, 2000].map(x => format(x, secondFormat))], - ['timestamp fifteen seconds', 'bottom', 'linear', 0, 30000, DType.Timestamp, undefined, 3, [0, 15000, 30000].map(x => format(x, secondFormat))], - ['timestamp thirty seconds', 'bottom', 'linear', 0, 60000, DType.Timestamp, undefined, 3, [0, 30000, 60000].map(x => format(x, secondFormat))], - ['timestamp minutes', 'bottom', 'linear', 0, 120000, DType.Timestamp, undefined, 3, [0, 60000, 120000].map(x => format(x, minuteFormat))], - ['timestamp fifteen minutes', 'bottom', 'linear', 0, 1800000, DType.Timestamp, undefined, 3, [0, 900000, 1800000].map(x => format(x, minuteFormat))], - ['timestamp thirty minutes', 'bottom', 'linear', 0, 3600000, DType.Timestamp, undefined, 3, [0, 1800000, 3600000].map(x => format(x, minuteFormat))], - ['timestamp hours', 'bottom', 'linear', 0, 7200000, DType.Timestamp, undefined, 3, [0, 3600000, 7200000].map(x => format(x, hourFormat))], - ['timestamp six hours', 'bottom', 'linear', getTs(1970, 0, 0, 3), getTs(1970, 0, 0, 15), DType.Timestamp, undefined, 2, ['6:00', '12:00']], - ['timestamp twelve hours', 'bottom', 'linear', getTs(1970, 0, 0, 6), getTs(1970, 0, 1, 6), DType.Timestamp, undefined, 2, ['12:00', '24:00']], + ['log axis full', 'left', 'log', 0, 1000, DType.Int, 'SI', ['10', '100', '1k']], + ['log axis reduced', 'left', 'log', 0, 1000000, DType.Int, 'SI', ['10', '1k', '100k']], + ['log axis unsupported range', 'left', 'log', 0, 1, DType.Int, 'SI', []], + ['do not show fractional ticks for integers', 'left', 'linear', 0, 1, DType.Int, 'SI', ['0', '1']], + ['show fractional ticks for floats', 'left', 'linear', 0, 1, DType.Float, 'scientific', ['0', '0.5', '1']], + ['thousands SI format', 'left', 'linear', 0, 5000, DType.Int, 'SI', ['0', '2.5k', '5k']], + ['thousands scientific format', 'left', 'linear', 0, 5000, DType.Int, 'scientific', ['0', '2.5e+3', '5e+3']], + ['millions SI format', 'left', 'linear', 0, 5000000, DType.Int, 'SI', ['0', '2.5M', '5M']], + ['millions scientific format', 'left', 'linear', 0, 5000000, DType.Int, 'scientific', ['0', '2.5e+6', '5e+6']], + ['negative numbers', 'left', 'linear', -5000, 0, DType.Int, 'SI', ['-5k', '-2.5k', '0']], + ['timestamp seconds', 'bottom', 'linear', 0, 2000, DType.Timestamp, undefined, [0, 1000, 2000].map(x => format(x, secondFormat))], + ['timestamp fifteen seconds', 'bottom', 'linear', 0, 30000, DType.Timestamp, undefined, [0, 15000, 30000].map(x => format(x, secondFormat))], + ['timestamp thirty seconds', 'bottom', 'linear', 0, 60000, DType.Timestamp, undefined, [0, 30000, 60000].map(x => format(x, secondFormat))], + ['timestamp minutes', 'bottom', 'linear', 0, 120000, DType.Timestamp, undefined, [0, 60000, 120000].map(x => format(x, minuteFormat))], + ['timestamp fifteen minutes', 'bottom', 'linear', 0, 1800000, DType.Timestamp, undefined, [0, 900000, 1800000].map(x => format(x, minuteFormat))], + ['timestamp thirty minutes', 'bottom', 'linear', 0, 3600000, DType.Timestamp, undefined, [0, 1800000, 3600000].map(x => format(x, minuteFormat))], + ['timestamp hours', 'bottom', 'linear', 0, 7200000, DType.Timestamp, undefined, [0, 3600000, 7200000].map(x => format(x, hourFormat))], + ['timestamp six hours', 'bottom', 'linear', getTs(1970, 0, 0, 3), getTs(1970, 0, 0, 15), DType.Timestamp, undefined, ['6:00', '12:00']], + ['timestamp twelve hours', 'bottom', 'linear', getTs(1970, 0, 0, 6), getTs(1970, 0, 1, 6), DType.Timestamp, undefined, ['12:00', '24:00']], [ 'timestamp days', 'bottom', @@ -71,7 +71,6 @@ test.each([ getTs(1970, 0, 3, 12), DType.Timestamp, undefined, - 3, [getTs(1970, 0, 1), getTs(1970, 0, 2), getTs(1970, 0, 3)].map(x => format(x, dayFormat))], [ 'timestamp weeks', @@ -81,7 +80,6 @@ test.each([ getTs(1970, 0, 22, 12), DType.Timestamp, undefined, - 3, [getTs(1970, 0, 4, 12), getTs(1970, 0, 11, 12), getTs(1970, 0, 18, 12)].map(x => format(x, dayFormat)) ], [ @@ -92,7 +90,6 @@ test.each([ getTs(1970, 3, 15, 12), DType.Timestamp, undefined, - 3, [getTs(1970, 1, 1, 12), getTs(1970, 2, 1, 12), getTs(1970, 3, 1, 12)].map(x => format(x, monthFormat)) ], [ @@ -103,7 +100,6 @@ test.each([ getTs(1973, 10, 1), DType.Timestamp, undefined, - 3, [getTs(1971, 4, 1), getTs(1971, 7, 1), getTs(1972, 10, 1)].map(x => format(x, yearFormat)) ], [ @@ -114,14 +110,32 @@ test.each([ getTs(1973, 6, 1), DType.Timestamp, undefined, - 3, - [getTs(1971, 0, 1, 12), getTs(1971, 0, 1, 12), getTs(1972, 0, 1, 12)].map(x => format(x, yearFormat)) + [getTs(1971, 0, 1, 12), getTs(1972, 0, 1, 12), getTs(1973, 0, 1, 12)].map(x => format(x, yearFormat)) + ], + [ + 'timestamp two years', + 'bottom', + 'linear', + getTs(1969, 6, 1), + getTs(1974, 6, 1), + DType.Timestamp, + undefined, + [getTs(1970, 0, 1, 12), getTs(1972, 0, 1, 12), getTs(1974, 0, 1, 12)].map(x => format(x, yearFormat)) + ], + [ + 'timestamp five years', + 'bottom', + 'linear', + getTs(1969, 6, 1), + getTs(1981, 6, 1), + DType.Timestamp, + undefined, + [getTs(1970, 0, 1, 12), getTs(1975, 0, 1, 12), getTs(1980, 0, 1, 12)].map(x => format(x, yearFormat)) ] -])('%s', async (msg, placement, scale, min, max, dtype, mode, nLabels, labels) => { - render(<PlotAxis +])('%s', async (msg, placement, scale, min, max, dtype, mode, labels) => { + renderNoAPI(<PlotAxis min={min} max={max} - labels={nLabels} labelWidth={placement === 'left' ? undefined : labelWidth} labelHeight={labelHeight} mode={mode} diff --git a/gui/src/components/plotting/PlotHistogram.js b/gui/src/components/plotting/PlotHistogram.js index 245c67e62a86c4ba4b47939a05297bfb190899f1..1414a77e6d699e8ce976143b2058f5c28e7f6ed9 100644 --- a/gui/src/components/plotting/PlotHistogram.js +++ b/gui/src/components/plotting/PlotHistogram.js @@ -154,11 +154,11 @@ const useStyles = makeStyles(theme => ({ })) const PlotHistogram = React.memo(({ xAxis, + yAxis, bins, range, step, nBins, - scale, discretization, dtypeY, disabled, @@ -206,7 +206,7 @@ const PlotHistogram = React.memo(({ } }) const dynamicStyles = useDynamicStyles() - const scaler = useMemo(() => getScaler(scale), [scale]) + const aggIndicator = useRecoilValue(guiState('aggIndicator')) const oldRangeRef = useRef() const artificialRange = 1 @@ -230,16 +230,17 @@ const PlotHistogram = React.memo(({ } const minY = 0 const maxY = Math.max(...bins.map(item => item.count)) + const scaler = getScaler(yAxis.scale, [minY, maxY]) const finalBins = bins.map((bucket) => { return { ...bucket, start: bucket.value, end: bucket.value + step, - scale: scaler(bucket.count / maxY) || 0 + scale: scaler(bucket.count) } }) return [finalBins, minY, maxY] - }, [bins, scaler, step]) + }, [bins, step, yAxis.scale]) // Transforms the original range into an internal range used for // visualization. @@ -340,20 +341,17 @@ const PlotHistogram = React.memo(({ if (isNil(finalBins) || isNil(xAxis.min) || isNil(maxX)) { return null } - let labels - // Automatic labelling is used for continuous values - if (!discretization && !isArtificial) { - labels = 10 + // One bin is shown for the artificial values - } else if (isArtificial) { + if (isArtificial) { const offset = discretization ? 0.5 * step : 0 labels = [{ label: finalBins[0].start, pos: finalBins[0].start + offset }] // Discrete values get label at the center of the bin. - } else { + } else if (discretization) { const start = step * Math.ceil(xAxis.min / step) const end = step * Math.floor(maxX / step) labels = rangeLodash(start, end).map(x => ({ @@ -487,12 +485,11 @@ const PlotHistogram = React.memo(({ min={minY} max={maxY} mode='SI' - labels={5} - scale={scale} + scale={yAxis.scale} dtype={dtypeY} className={styles.yaxis} /> - }, [dtypeY, maxY, minY, scale, styles.yaxis]) + }, [dtypeY, maxY, minY, yAxis.scale, styles.yaxis]) // Determine the final component to show. let histComp @@ -578,6 +575,7 @@ const PlotHistogram = React.memo(({ PlotHistogram.propTypes = { xAxis: PropTypes.object, + yAxis: PropTypes.object, /* The bins data to show. */ bins: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.number, @@ -591,7 +589,6 @@ PlotHistogram.propTypes = { /* Discretization of the values. */ discretization: PropTypes.number, dtypeY: PropTypes.string, - scale: PropTypes.string, disabled: PropTypes.bool, /* The label to show for the tooltips */ tooltipLabel: PropTypes.string, diff --git a/gui/src/components/plotting/PlotScatter.js b/gui/src/components/plotting/PlotScatter.js index 085e878296416489b228adcfa2c0feae50f566c0..a74666ef20090ad0f3304c6f695a1ca8cd2ec25b 100644 --- a/gui/src/components/plotting/PlotScatter.js +++ b/gui/src/components/plotting/PlotScatter.js @@ -126,6 +126,7 @@ const PlotScatter = React.memo(forwardRef(( template = template + `<extra></extra>` return template } + const scatterType = hasWebGL ? 'scattergl' : 'scatter' // If dealing with a quantized color, each group is separated into it's own // trace which has a legend as well. @@ -155,7 +156,7 @@ const PlotScatter = React.memo(forwardRef(( name: option, text: colorArray, mode: 'markers', - type: hasWebGL ? 'scattergl' : 'scatter', + type: scatterType, textposition: 'top center', showlegend: true, hovertemplate: hoverTemplate( @@ -185,7 +186,7 @@ const PlotScatter = React.memo(forwardRef(( text: data.color, entry_id: data.id, mode: 'markers', - type: 'scattergl', + type: scatterType, textposition: 'top center', showlegend: false, hoverinfo: "text", @@ -223,7 +224,7 @@ const PlotScatter = React.memo(forwardRef(( y: data.y, entry_id: data.id, mode: 'markers', - type: 'scattergl', + type: scatterType, textposition: 'top center', showlegend: false, hoverinfo: "text", @@ -267,10 +268,12 @@ const PlotScatter = React.memo(forwardRef(( y: 1 }, xaxis: { + type: xAxis.scale, fixedrange: false, autorange: autorange }, yaxis: { + type: yAxis.scale, fixedrange: false, autorange: autorange }, @@ -288,7 +291,7 @@ const PlotScatter = React.memo(forwardRef(( // both. This is a general problem in trying to 'reactify' a non-react library // like Plotly. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autorange]) + }, [autorange, xAxis.scale, yAxis.scale]) // Change dragmode useEffect(() => { diff --git a/gui/src/components/plotting/common.js b/gui/src/components/plotting/common.js index 50fb1a8a9476f7f9ddd09dbc8df954fc247233d7..e9c3086e5faea720faf86c6019a23660fa75ee9d 100644 --- a/gui/src/components/plotting/common.js +++ b/gui/src/components/plotting/common.js @@ -15,8 +15,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { range, size, isNil } from 'lodash' -import { scalePow } from 'd3-scale' +import { range, size } from 'lodash' +import { scalePow, scaleLog } from 'd3-scale' import { format, getTime, @@ -39,36 +39,64 @@ import { eachQuarterOfInterval } from 'date-fns' import { scale as chromaScale } from 'chroma-js' -import { scale, add, DType, formatNumber } from '../../utils.js' +import { scale as scaleUtils, add, DType, formatNumber } from '../../utils.js' -// The available scaling options export const scales = { - 'linear': 1, - '1/2': 0.5, - '1/4': 0.25, - '1/8': 0.125 + 'linear': 'linear', + 'log': 'log', + '1/2': '1/2', + '1/4': '1/4', + '1/8': '1/8' +} + +export const scalesLimited = { + 'linear': 'linear', + 'log': 'log' } /** - * Returns a d3-scale object for the given scaling type, domain and range. + * Creates a scaling function based on the provided type. * - * @param {str} type Type of scaling. - * @param {array} domain The input domain. - * @param {array} range The output range. - * @returns A function that when given a number between [0, 1] will transform - * it to the given output range with the given type of scaling. + * @param {string} type - The type of scaling function ('linear', '1/2', '1/4', '1/8', 'log'). + * @param {number[]} [domain=[0, 1]] - The input domain for the scale. + * @param {number[]} [range=[0, 1]] - The output range for the scale. + * @returns {function} - The scaling function with added properties and methods. + * @throws {Error} - Throws an error if the scaling type is invalid. */ export function getScaler(type, domain = [0, 1], range = [0, 1]) { - const scale = scales[type] - if (isNil(scale)) { - throw Error('Invalid scaling type.') + let scaler + + const powScales = { + 'linear': 1, + '1/2': 0.5, + '1/4': 0.25, + '1/8': 0.125 + } + + const powscale = powScales[type] + + if (powscale !== undefined) { + scaler = scalePow() + .exponent(powscale) + .domain(domain) + .range(range) + } else if (type === 'log') { + const adjustedDomain = [Math.max(domain[0], 1), domain[1]] + scaler = scaleLog() + .base(10) + .domain(adjustedDomain) + .range(range) + } else { + throw new Error('Invalid scaling type.') } - const scaler = scalePow() - .exponent(scale) - .domain(domain) - .range(range) - return scaler + // Invalid values are mapped to zero. Important especially for log scales. + const func = (value) => scaler(value) || 0 + + // Copy all properties and methods from scaler to func + Object.assign(func, scaler) + + return func } /** @@ -87,7 +115,7 @@ export function getInterval(value, steps, dtype, cap = true) { const interval = value / steps const degree = Math.pow(10, (Math.round(Math.log10(interval)))) const multipliers = [0.1, 0.2, 0.25, 0.5, 1, 2, 2.5, 5, 10] - const intervals = scale(multipliers, degree).filter(x => { + const intervals = scaleUtils(multipliers, degree).filter(x => { const capped = cap ? (x >= interval) : true const valid = dtype === DType.Int ? x % 1 === 0 : true return capped && valid @@ -116,7 +144,7 @@ export const argMin = (iMin, x, i, arr) => { * * @returns Array of tick objects containing value and tick. */ -export function getTicks(min, max, n, dtype, mode = 'scientific', decimals = 3) { +export function getTicks(min, max, n, scale = 'linear', dtype, mode = 'scientific', decimals = 3) { if (dtype === DType.Timestamp) { const start = fromUnixTime(millisecondsToSeconds(min)) const end = fromUnixTime(millisecondsToSeconds(max)) @@ -138,6 +166,13 @@ export function getTicks(min, max, n, dtype, mode = 'scientific', decimals = 3) }), format: 'yyyy' }, + twoyears: { + difference: (end, start) => differenceInYears(end, start) / 2, + split: (interval) => eachYearOfInterval(interval).filter(x => { + return !(x.getFullYear() % 2) + }), + format: 'yyyy' + }, years: { difference: differenceInYears, split: eachYearOfInterval, @@ -247,27 +282,39 @@ export function getTicks(min, max, n, dtype, mode = 'scientific', decimals = 3) // value. return ticks.map(x => ({value: getTime(x), tick: format(x, setup.format)})) } else { - // Calculate minimum number of ticks for each option - const tickRange = max - min - const multipliers = [0.1, 0.2, 0.25, 0.5, 1, 2, 2.5, 5, 10] - const degree = Math.pow(10, (Math.round(Math.log10(tickRange)))) - const closestDuration = scale(multipliers, degree) - .filter(x => dtype === DType.Int ? x % 1 === 0 : true) // Filter out invalid intervals - .map(x => [x, Math.floor(tickRange / x)]) // Calculate minimum number of ticks - .filter(([interval, nMin]) => nMin <= n) // Filter out values where the minimum number of ticks is beyond the target. - .map(([interval, nMin]) => { // Calculate actual ticks - const startRound = Math.ceil(min / interval) - const endRound = Math.floor(max / interval) - const ticks = range(startRound, endRound + 1).map(x => { - const value = (x * interval) - return {value, tick: formatNumber(value, dtype, mode, decimals)} - }) - return ticks + // Log scale ticks are divided into evenly spaced intervals in the log scale + if (scale === 'log') { + const tickRange = max - min + const degree = Math.floor(Math.log10(tickRange)) + const reduction = Math.ceil(degree / n) + const degrees = range(1, degree + 1, degree > n ? reduction : 1) + return degrees.map(x => { + const value = Math.pow(10, x) + return {value, tick: formatNumber(value, dtype, mode, decimals)} }) - .filter((ticks) => ticks.length <= n) // Filter out options with more ticks than requested - .map((ticks) => [ticks, Math.abs(ticks.length - n)]) // Calculate abs difference to n - .reduce((prev, curr) => prev[1] < curr[1] ? prev : curr) // Select best option - return closestDuration[0] + // Calculate reasonable ticks for other scales + } else { + const tickRange = max - min + const multipliers = [0.1, 0.2, 0.25, 0.5, 1, 2, 2.5, 5, 10] + const degree = Math.pow(10, (Math.round(Math.log10(tickRange)))) + const closestDuration = scaleUtils(multipliers, degree) + .filter(x => dtype === DType.Int ? x % 1 === 0 : true) // Filter out invalid intervals + .map(x => [x, Math.floor(tickRange / x)]) // Calculate minimum number of ticks + .filter(([interval, nMin]) => nMin <= n) // Filter out values where the minimum number of ticks is beyond the target. + .map(([interval, nMin]) => { // Calculate actual ticks + const startRound = Math.ceil(min / interval) + const endRound = Math.floor(max / interval) + const ticks = range(startRound, endRound + 1).map(x => { + const value = (x * interval) + return {value, tick: formatNumber(value, dtype, mode, decimals)} + }) + return ticks + }) + .filter((ticks) => ticks.length <= n) // Filter out options with more ticks than requested + .map((ticks) => [ticks, Math.abs(ticks.length - n)]) // Calculate abs difference to n + .reduce((prev, curr) => prev[1] < curr[1] ? prev : curr) // Select best option + return closestDuration[0] + } } } diff --git a/gui/src/components/plotting/common.spec.js b/gui/src/components/plotting/common.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e269f3b7902fea0397a403261a7f2da9eeb814da --- /dev/null +++ b/gui/src/components/plotting/common.spec.js @@ -0,0 +1,32 @@ +/* + * 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 { getScaler } from './common' + +describe('test getScaler', () => { + test.each([ + ['linear scale', 'linear', [0, 1], [0, 1], [[0, 0], [1, 1]]], + ['log scale', 'log', [0, 1000], [0, 1], [[0, 0], [1, 0], [10, 1 / 3], [100, 2 / 3], [1000, 1]]] + ] + )('%s', async (name, type, domain, range, values) => { + const scaler = getScaler(type, domain, range) + for (const [a, b] of values) { + expect(scaler(a)).toBeCloseTo(b, 10) + } + }) +}) diff --git a/gui/src/components/search/FilterRegistry.js b/gui/src/components/search/FilterRegistry.js index 92795100d516040d62df82b9e2c86c901de8bea7..ecab46a84f81547d0e62e3ecf22b29e1b32f9fbc 100644 --- a/gui/src/components/search/FilterRegistry.js +++ b/gui/src/components/search/FilterRegistry.js @@ -182,7 +182,7 @@ registerFilter( idStructure, { ...termQuantity, - scale: '1/4', + scale: 'log', label: "Dimensionality", options: getEnumOptions('results.material.structural_type', ['not processed', 'unavailable']) } @@ -285,21 +285,21 @@ registerFilter( {name: 'degeneracy', ...termQuantity} ] ) -registerFilter('results.method.method_name', idMethod, {...termQuantity, scale: '1/4'}) -registerFilter('results.method.workflow_name', idMethod, {...termQuantity, scale: '1/4'}) -registerFilter('results.method.simulation.program_name', idMethod, {...termQuantity, scale: '1/4'}) +registerFilter('results.method.method_name', idMethod, {...termQuantity, scale: 'log'}) +registerFilter('results.method.workflow_name', idMethod, {...termQuantity, scale: 'log'}) +registerFilter('results.method.simulation.program_name', idMethod, {...termQuantity, scale: 'log'}) registerFilter('results.method.simulation.program_version', idMethod, termQuantity) registerFilter('results.method.simulation.program_version_internal', idMethod, termQuantity) registerFilter('results.method.simulation.precision.native_tier', idPrecision, {...termQuantity, placeholder: "E.g. VASP - accurate", label: 'Code-specific tier'}) -registerFilter('results.method.simulation.precision.k_line_density', idPrecision, {...numberHistogramQuantity, scale: '1/2', label: 'k-line density'}) -registerFilter('results.method.simulation.precision.basis_set', idPrecision, {...termQuantity, scale: '1/4'}) -registerFilter('results.method.simulation.precision.planewave_cutoff', idPrecision, {...numberHistogramQuantity, label: 'Plane-wave cutoff', scale: '1/2'}) -registerFilter('results.method.simulation.precision.apw_cutoff', idPrecision, {...numberHistogramQuantity, label: 'APW cutoff', scale: '1/2'}) +registerFilter('results.method.simulation.precision.k_line_density', idPrecision, {...numberHistogramQuantity, scale: 'log', label: 'k-line density'}) +registerFilter('results.method.simulation.precision.basis_set', idPrecision, {...termQuantity, scale: 'log'}) +registerFilter('results.method.simulation.precision.planewave_cutoff', idPrecision, {...numberHistogramQuantity, label: 'Plane-wave cutoff', scale: 'log'}) +registerFilter('results.method.simulation.precision.apw_cutoff', idPrecision, {...numberHistogramQuantity, label: 'APW cutoff', scale: 'log'}) registerFilter('results.method.simulation.dft.core_electron_treatment', idDFT, termQuantity) -registerFilter('results.method.simulation.dft.jacobs_ladder', idDFT, {...termQuantity, scale: '1/2', label: 'Jacob\'s ladder'}) +registerFilter('results.method.simulation.dft.jacobs_ladder', idDFT, {...termQuantity, scale: 'log', label: 'Jacob\'s ladder'}) registerFilter('results.method.simulation.dft.xc_functional_type', idDFT, { ...termQuantity, - scale: '1/2', + scale: 'log', label: 'Jacob\'s ladder', options: { 'LDA': {label: 'LDA'}, @@ -309,16 +309,16 @@ registerFilter('results.method.simulation.dft.xc_functional_type', idDFT, { 'hybrid': {label: 'Hybrid'} } }) -registerFilter('results.method.simulation.dft.xc_functional_names', idDFT, {...termQuantityNonExclusive, scale: '1/2', label: 'XC functional names'}) -registerFilter('results.method.simulation.dft.exact_exchange_mixing_factor', idDFT, {...numberHistogramQuantity, scale: '1/2'}) -registerFilter('results.method.simulation.dft.hubbard_kanamori_model.u_effective', idDFT, {...numberHistogramQuantity, scale: '1/2'}) +registerFilter('results.method.simulation.dft.xc_functional_names', idDFT, {...termQuantityNonExclusive, scale: 'log', label: 'XC functional names'}) +registerFilter('results.method.simulation.dft.exact_exchange_mixing_factor', idDFT, {...numberHistogramQuantity, scale: 'log'}) +registerFilter('results.method.simulation.dft.hubbard_kanamori_model.u_effective', idDFT, {...numberHistogramQuantity, scale: 'log'}) registerFilter('results.method.simulation.dft.relativity_method', idDFT, termQuantity) -registerFilter('results.method.simulation.tb.type', idTB, {...termQuantity, scale: '1/2'}) -registerFilter('results.method.simulation.tb.localization_type', idTB, {...termQuantity, scale: '1/2'}) +registerFilter('results.method.simulation.tb.type', idTB, {...termQuantity, scale: 'log'}) +registerFilter('results.method.simulation.tb.localization_type', idTB, {...termQuantity, scale: 'log'}) registerFilter('results.method.simulation.gw.type', idGW, {...termQuantity, label: 'GW type'}) registerFilter('results.method.simulation.gw.starting_point_type', idGW, { ...termQuantity, - scale: '1/2', + scale: 'log', options: { 'LDA': {label: 'LDA'}, 'GGA': {label: 'GGA'}, @@ -328,12 +328,12 @@ registerFilter('results.method.simulation.gw.starting_point_type', idGW, { 'HF': {label: 'HF'} } }) -registerFilter('results.method.simulation.gw.basis_set_type', idGW, {...termQuantity, scale: '1/4'}) +registerFilter('results.method.simulation.gw.basis_set_type', idGW, {...termQuantity, scale: 'log'}) registerFilter('results.method.simulation.bse.type', idBSE, termQuantity) registerFilter('results.method.simulation.bse.solver', idBSE, termQuantity) registerFilter('results.method.simulation.bse.starting_point_type', idBSE, { ...termQuantity, - scale: '1/2', + scale: 'log', options: { 'LDA': {label: 'LDA'}, 'GGA': {label: 'GGA'}, @@ -343,13 +343,13 @@ registerFilter('results.method.simulation.bse.starting_point_type', idBSE, { 'HF': {label: 'HF'} } }) -registerFilter('results.method.simulation.bse.basis_set_type', idBSE, {...termQuantity, scale: '1/4'}) -registerFilter('results.method.simulation.bse.gw_type', idBSE, {...termQuantity, scale: '1/4', label: `GW type`}) +registerFilter('results.method.simulation.bse.basis_set_type', idBSE, {...termQuantity, scale: 'log'}) +registerFilter('results.method.simulation.bse.gw_type', idBSE, {...termQuantity, scale: 'log', label: `GW type`}) registerFilter('results.method.simulation.dmft.impurity_solver_type', idDMFT, {...termQuantity}) registerFilter('results.method.simulation.dmft.magnetic_state', idDMFT, {...termQuantity}) -registerFilter('results.method.simulation.dmft.inverse_temperature', idDMFT, {...numberHistogramQuantity, scale: '1/2'}) -registerFilter('results.method.simulation.dmft.u', idDMFT, {...numberHistogramQuantity, scale: '1/2'}) -registerFilter('results.method.simulation.dmft.jh', idDMFT, {...numberHistogramQuantity, label: `JH`, scale: '1/2'}) +registerFilter('results.method.simulation.dmft.inverse_temperature', idDMFT, {...numberHistogramQuantity, scale: 'log'}) +registerFilter('results.method.simulation.dmft.u', idDMFT, {...numberHistogramQuantity, scale: 'log'}) +registerFilter('results.method.simulation.dmft.jh', idDMFT, {...numberHistogramQuantity, label: `JH`, scale: 'log'}) registerFilter('results.method.simulation.dmft.analytical_continuation', idDMFT, {...termQuantity}) registerFilter('results.eln.sections', idELN, termQuantity) registerFilter('results.eln.tags', idELN, termQuantity) @@ -358,10 +358,10 @@ registerFilter('results.eln.instruments', idELN, termQuantity) registerFilter('results.eln.lab_ids', idELN, {...termQuantity, label: 'Lab IDs'}) registerFilter('results.eln.names', idELN, noAggQuantity) registerFilter('results.eln.descriptions', idELN, noAggQuantity) -registerFilter('external_db', idAuthor, {...termQuantity, label: 'External database', scale: '1/4'}) +registerFilter('external_db', idAuthor, {...termQuantity, label: 'External database', scale: 'log'}) registerFilter('authors.name', idAuthor, {...termQuantityNonExclusive, label: 'Author name'}) -registerFilter('upload_create_time', idAuthor, {...numberHistogramQuantity, scale: '1/2'}) -registerFilter('entry_create_time', idAuthor, {...numberHistogramQuantity, scale: '1/2'}) +registerFilter('upload_create_time', idAuthor, {...numberHistogramQuantity, scale: 'log'}) +registerFilter('entry_create_time', idAuthor, {...numberHistogramQuantity, scale: 'log'}) registerFilter('datasets.dataset_name', idAuthor, {...termQuantityLarge, label: 'Dataset name'}) registerFilter('datasets.doi', idAuthor, {...termQuantity, label: 'Dataset DOI'}) registerFilter('datasets.dataset_id', idAuthor, termQuantity) @@ -442,7 +442,7 @@ registerFilter( nestedQuantity, [ {name: 'type', ...termQuantity}, - {name: 'value', ...numberHistogramQuantity, scale: '1/4'} + {name: 'value', ...numberHistogramQuantity, scale: 'log'} ] ) registerFilter( @@ -451,7 +451,7 @@ registerFilter( nestedQuantity, [ {name: 'type', ...termQuantity}, - {name: 'value', ...numberHistogramQuantity, scale: '1/4'} + {name: 'value', ...numberHistogramQuantity, scale: 'log'} ] ) registerFilter('results.properties.electronic.band_gap.provenance.label', idElectronic, termQuantity) @@ -460,12 +460,12 @@ registerFilter( idSolarCell, nestedQuantity, [ - {name: 'efficiency', ...numberHistogramQuantity, scale: '1/4'}, - {name: 'fill_factor', ...numberHistogramQuantity, scale: '1/4'}, - {name: 'open_circuit_voltage', ...numberHistogramQuantity, scale: '1/4'}, - {name: 'short_circuit_current_density', ...numberHistogramQuantity, scale: '1/4'}, - {name: 'illumination_intensity', ...numberHistogramQuantity, scale: '1/4'}, - {name: 'device_area', ...numberHistogramQuantity, scale: '1/4'}, + {name: 'efficiency', ...numberHistogramQuantity, scale: 'log'}, + {name: 'fill_factor', ...numberHistogramQuantity, scale: 'log'}, + {name: 'open_circuit_voltage', ...numberHistogramQuantity, scale: 'log'}, + {name: 'short_circuit_current_density', ...numberHistogramQuantity, scale: 'log'}, + {name: 'illumination_intensity', ...numberHistogramQuantity, scale: 'log'}, + {name: 'device_area', ...numberHistogramQuantity, scale: 'log'}, {name: 'device_architecture', ...termQuantity}, {name: 'absorber_fabrication', ...termQuantity}, {name: 'device_stack', ...termQuantityAllNonExclusive}, @@ -482,7 +482,7 @@ registerFilter( nestedQuantity, [ {name: 'characterization_methods', ...termQuantity}, - {name: 'surface_area', ...numberHistogramQuantity, scale: '1/4'}, + {name: 'surface_area', ...numberHistogramQuantity, scale: 'log'}, {name: 'catalyst_name', ...termQuantity}, {name: 'catalyst_type', ...termQuantity}, {name: 'preparation_method', ...termQuantity} @@ -502,9 +502,9 @@ registerFilter( idCatalyst, nestedQuantity, [ - {name: 'temperature', ...numberHistogramQuantity, scale: '1/4'}, + {name: 'temperature', ...numberHistogramQuantity, scale: 'log'}, {name: 'pressure', ...numberHistogramQuantity, scale: 'linear'}, - {name: 'weight_hourly_space_velocity', ...numberHistogramQuantity, scale: '1/4'} + {name: 'weight_hourly_space_velocity', ...numberHistogramQuantity, scale: 'log'} ] ) registerFilter( @@ -514,7 +514,7 @@ registerFilter( [ {name: 'name', ...termQuantityAllNonExclusive}, {name: 'gas_concentration_out', ...numberHistogramQuantity, scale: 'linear'}, - {name: 'selectivity', ...numberHistogramQuantity, scale: '1/4'} + {name: 'selectivity', ...numberHistogramQuantity, scale: 'log'} ] ) registerFilter( @@ -572,9 +572,9 @@ registerFilter( idGeometryOptimization, nestedQuantity, [ - {name: 'final_energy_difference', ...numberHistogramQuantity, scale: '1/8'}, - {name: 'final_displacement_maximum', ...numberHistogramQuantity, scale: '1/8'}, - {name: 'final_force_maximum', ...numberHistogramQuantity, scale: '1/8'} + {name: 'final_energy_difference', ...numberHistogramQuantity, scale: 'log'}, + {name: 'final_displacement_maximum', ...numberHistogramQuantity, scale: 'log'}, + {name: 'final_force_maximum', ...numberHistogramQuantity, scale: 'log'} ] ) registerFilter( @@ -632,7 +632,7 @@ registerFilter( widget: { quantity: 'results.material.elements', type: 'periodictable', - scale: '1/2', + scale: 'log', layout: { sm: {w: 12, h: 8, minW: 12, minH: 8}, md: {w: 12, h: 8, minW: 12, minH: 8}, diff --git a/gui/src/components/search/input/InputHeader.js b/gui/src/components/search/input/InputHeader.js index c05ad831b2e7c9eccfd60accbf1716dcfef4d723..a6c0cd1e10fd229f6aa16890f98c9f76e5794eff 100644 --- a/gui/src/components/search/input/InputHeader.js +++ b/gui/src/components/search/input/InputHeader.js @@ -131,7 +131,7 @@ const InputHeader = React.memo(({ onChange={onChangeScale ? (event, value) => onChangeScale(value) : undefined} > {Object.entries(scales).map(([key, value]) => - <FormControlLabel key={key} value={key} label={key} control={<Radio/>} /> + <FormControlLabel key={key} value={key} label={value} control={<Radio/>} /> )} </RadioGroup> </FormControl> @@ -139,7 +139,7 @@ const InputHeader = React.memo(({ </> : <ActionSelect value={scale} - options={Object.keys(scales)} + options={scales} tooltip="Statistics scaling" onChange={onChangeScale} /> diff --git a/gui/src/components/search/input/InputPeriodicTable.js b/gui/src/components/search/input/InputPeriodicTable.js index 0115b720ee734da3971d730ada7df83bdebe87cf..7d74f53b67c96f7cd9099ff07c9a589baac02d4a 100644 --- a/gui/src/components/search/input/InputPeriodicTable.js +++ b/gui/src/components/search/input/InputPeriodicTable.js @@ -80,9 +80,9 @@ const Element = React.memo(({ }) => { const styles = useElementStyles() const theme = useTheme() - const scaler = useMemo(() => getScaler(scale, undefined, [0.2, 1]), [scale]) + const scaler = useMemo(() => getScaler(scale, [0, max], [0.2, 1]), [scale, max]) const finalCount = useMemo(() => approxInteger(count || 0), [count]) - const finalScale = useMemo(() => scaler(count / max) || 0, [count, max, scaler]) + const finalScale = useMemo(() => scaler(count), [count, scaler]) const disabledFinal = disabled && !selected const color = selected ? theme.palette.secondary.main diff --git a/gui/src/components/search/input/InputRange.js b/gui/src/components/search/input/InputRange.js index 17c04950b0fafe6717ccadd86ec643c6f50ca87b..16bce845d5147caea222c7055d2350ed29476f42 100644 --- a/gui/src/components/search/input/InputRange.js +++ b/gui/src/components/search/input/InputRange.js @@ -47,9 +47,9 @@ const useStyles = makeStyles(theme => ({ })) export const Range = React.memo(({ xAxis, + yAxis, nSteps, visible, - scale, nBins, disableHistogram, disableXTitle, @@ -68,7 +68,7 @@ export const Range = React.memo(({ const [filter, setFilter] = useFilterState(xAxis.quantity) const [minLocal, setMinLocal] = useState() const [maxLocal, setMaxLocal] = useState() - const [plotData, setPlotData] = useState({xAxis}) + const [plotData, setPlotData] = useState({xAxis, yAxis}) const loading = useRef(false) const firstRender = useRef(true) const validRange = useRef() @@ -257,10 +257,11 @@ export const Range = React.memo(({ min: minLocal, max: maxLocal }, + yAxis, step: stepHistogram, data: agg.data }) - }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram, unitStorage, xAxis.quantity, xAxis.unit, xAxis.dtype, xAxis.title]) + }, [loading, nBins, agg, minLocal, maxLocal, stepHistogram, unitStorage, xAxis.quantity, xAxis.unit, xAxis.dtype, xAxis.title, xAxis.scale, yAxis]) // Function for converting search values into the currently selected unit // system. @@ -501,11 +502,11 @@ export const Range = React.memo(({ <PlotHistogram bins={plotData?.data} xAxis={plotData?.xAxis} + yAxis={plotData?.yAxis} step={plotData?.step} minXInclusive={minInclusive} maxXInclusive={maxInclusive} disabled={disabled} - scale={scale} nBins={nBins} range={range} highlight={highlight} @@ -542,6 +543,7 @@ export const Range = React.memo(({ Range.propTypes = { xAxis: PropTypes.object, + yAxis: 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. */ @@ -550,8 +552,6 @@ Range.propTypes = { * enabled. */ nBins: PropTypes.number, visible: PropTypes.bool, - /* The statistics scaling */ - scale: PropTypes.string, /* Whether the histogram is disabled */ disableHistogram: PropTypes.bool, /* Whether the x title is disabled */ @@ -613,6 +613,7 @@ const InputRange = React.memo(({ unit: new Unit(filterData[quantity]?.unit || 'dimensionless').toSystem(units) } ), [quantity, filterData, dtype, units]) + const y = useMemo(() => ({scale: scale}), [scale]) // Determine the description and title const def = filterData[quantity] @@ -639,9 +640,9 @@ const InputRange = React.memo(({ /> <Range xAxis={x} + yAxis={y} nSteps={nSteps} visible={visible} - scale={scale} nBins={nBins} disableHistogram={disableHistogram} disableXTitle diff --git a/gui/src/components/search/widgets/Dashboard.js b/gui/src/components/search/widgets/Dashboard.js index 105ed46fd557134defaeb74ccf1bb5c6a213a027..7a6f526e67652a1875d519e71a5a955cf6b468ca 100644 --- a/gui/src/components/search/widgets/Dashboard.js +++ b/gui/src/components/search/widgets/Dashboard.js @@ -107,6 +107,8 @@ const Dashboard = React.memo(() => { xl: {...layout}, xxl: {...layout} }, + // x: {scale: 'linear'}, + // y: {scale: 'linear'}, size: 1000, autorange: true, type: 'scatterplot' @@ -155,7 +157,7 @@ const Dashboard = React.memo(() => { }, autorange: false, nbins: 30, - scale: 'linear', + y: {scale: 'linear'}, type: 'histogram' } addWidget(id, value) diff --git a/gui/src/components/search/widgets/Dashboard.spec.js b/gui/src/components/search/widgets/Dashboard.spec.js index efe82a3d82a632d89651534549d1d57f12fe8662..80078fb1d8f266b70aa74669faf4bd7e007ac734 100644 --- a/gui/src/components/search/widgets/Dashboard.spec.js +++ b/gui/src/components/search/widgets/Dashboard.spec.js @@ -71,7 +71,7 @@ describe('displaying an initial widget and removing it', () => { type: 'histogram', title: 'Test title', x: {quantity: 'results.material.n_elements'}, - scale: 'linear', + y: {scale: 'linear'}, editing: false, visible: true, layout: { diff --git a/gui/src/components/search/widgets/StatisticsBar.js b/gui/src/components/search/widgets/StatisticsBar.js index 9495cf0e2bd1f50b5afdc90c3e9babaefe0a60b6..78ad58daa7dd19579fb77e99f0d634c930f1fe68 100644 --- a/gui/src/components/search/widgets/StatisticsBar.js +++ b/gui/src/components/search/widgets/StatisticsBar.js @@ -71,9 +71,9 @@ const StatisticsBar = React.memo(({ const theme = useTheme() // Calculate the approximated count and the final scaled value - const scaler = useMemo(() => scale ? getScaler(scale) : (value) => value, [scale]) + const scaler = useMemo(() => scale ? getScaler(scale, [0, max]) : (value) => value, [scale, max]) const finalCount = useMemo(() => approxInteger(value || 0), [value]) - const finalScale = useMemo(() => scaler(value / max) || 0, [value, max, scaler]) + const finalScale = useMemo(() => scaler(value), [value, scaler]) return <div onClick={onClick} className={clsx(className, styles.root)} data-testid={testID}> <Tooltip placement="bottom" enterDelay={0} title={tooltip || ''}> diff --git a/gui/src/components/search/widgets/Widget.js b/gui/src/components/search/widgets/Widget.js index 37ccc97705f999555e89bb557f08afdf1931dc3e..a6be7bb7050e61a385dea382367c4c96d9831c5d 100644 --- a/gui/src/components/search/widgets/Widget.js +++ b/gui/src/components/search/widgets/Widget.js @@ -110,15 +110,20 @@ export const schemaWidget = object({ editing: string().strip(), visible: string().strip() }) +export const schemaAxisBase = object({ + scale: string().nullable() +}) export const schemaAxis = object({ quantity: string().required(), unit: string().nullable(), - title: string().nullable() + title: string().nullable(), + scale: string().nullable() }) export const schemaAxisOptional = object({ quantity: string().nullable(), unit: string().nullable(), - title: string().nullable() + title: string().nullable(), + scale: string().nullable() }) export const schemaMarkers = object({ color: schemaAxisOptional diff --git a/gui/src/components/search/widgets/WidgetHistogram.js b/gui/src/components/search/widgets/WidgetHistogram.js index e0fbdf1f3f1a72285f9deb731cb1e19fb6f46484..1f54766891b8247b4e74873815ad203135394059 100644 --- a/gui/src/components/search/widgets/WidgetHistogram.js +++ b/gui/src/components/search/widgets/WidgetHistogram.js @@ -36,8 +36,8 @@ export const WidgetHistogram = React.memo(( title, description, x, + y, nbins, - scale, autorange, showinput, className @@ -68,7 +68,7 @@ export const WidgetHistogram = React.memo(( }, [setWidget]) const handleChangeScale = useCallback((value) => { - setWidget(old => { return {...old, scale: value} }) + setWidget(old => { return {...old, y: {...old.y, scale: value}} }) }, [setWidget]) return <Widget @@ -85,8 +85,8 @@ export const WidgetHistogram = React.memo(( onChange={(value) => setWidget(old => ({...old, autorange: value}))} /> <ActionSelect - value={scale} - options={Object.keys(scales)} + value={y.scale} + options={scales} tooltip="Statistics scaling" onChange={handleChangeScale} /> @@ -94,9 +94,9 @@ export const WidgetHistogram = React.memo(( > <Range xAxis={xAxis} + yAxis={y} visible={true} nBins={nbins} - scale={scale} anchored={true} autorange={autorange} showinput={showinput} @@ -110,8 +110,8 @@ WidgetHistogram.propTypes = { title: PropTypes.string, description: PropTypes.string, x: PropTypes.object, + y: PropTypes.object, nbins: PropTypes.number, - scale: PropTypes.string, autorange: PropTypes.bool, showinput: PropTypes.bool, className: PropTypes.string diff --git a/gui/src/components/search/widgets/WidgetHistogramEdit.js b/gui/src/components/search/widgets/WidgetHistogramEdit.js index 9e557923b9a967d438a72e3fdfa4587e8252bb01..5b1bbe1f5d47f5eb46d2fcd1c4e6f5e581e804da 100644 --- a/gui/src/components/search/widgets/WidgetHistogramEdit.js +++ b/gui/src/components/search/widgets/WidgetHistogramEdit.js @@ -17,7 +17,7 @@ */ import React, { useState, useCallback } from 'react' import PropTypes from 'prop-types' -import { string, number, bool, reach } from 'yup' +import { number, bool, reach } from 'yup' import { cloneDeep } from 'lodash' import { TextField, @@ -29,7 +29,7 @@ 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 { schemaWidget, schemaAxis, schemaAxisBase } from './Widget' import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption } from './WidgetEdit' import { DType, parseJMESPath, setDeep, isEmptyString } from '../../../utils' import { scales } from '../../plotting/common' @@ -150,29 +150,31 @@ export const WidgetHistogramEdit = React.memo(({widget}) => { /> </WidgetEditOption> </WidgetEditGroup> - <WidgetEditGroup title="general"> - <WidgetEditOption> - <InputTextField - label="title" - fullWidth - value={settings?.title} - onChange={(event) => handleChange('title', event.target.value)} - /> - </WidgetEditOption> + <WidgetEditGroup title="y axis"> <WidgetEditOption> <TextField select fullWidth - label="Statistics scaling" + label="scale" variant="filled" - value={settings.scale} - onChange={(event) => { handleChange('scale', event.target.value) }} + value={settings.y?.scale} + onChange={(event) => { handleChange('y.scale', event.target.value) }} > {Object.keys(scales).map((key) => <MenuItem value={key} key={key}>{key}</MenuItem> )} </TextField> </WidgetEditOption> + </WidgetEditGroup> + <WidgetEditGroup title="general"> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings?.title} + onChange={(event) => handleChange('title', event.target.value)} + /> + </WidgetEditOption> <WidgetEditOption> <TextField select @@ -212,8 +214,8 @@ WidgetHistogramEdit.propTypes = { } export const schemaWidgetHistogram = schemaWidget.shape({ - x: schemaAxis.required('Quantity for the x axis is required.'), - scale: string().required('Scale is required.'), + x: schemaAxis.required('X-axis configuration is required.'), + y: schemaAxisBase.required('Y-axis configuration is required.'), nbins: number().integer().required(), autorange: bool(), showinput: bool() diff --git a/gui/src/components/search/widgets/WidgetPeriodicTable.js b/gui/src/components/search/widgets/WidgetPeriodicTable.js index a27aedfea4b3567c6f94e8f62638c43761b28f36..5055fc0fe45a6d546e72f4a9fef25a69fa88ee31 100644 --- a/gui/src/components/search/widgets/WidgetPeriodicTable.js +++ b/gui/src/components/search/widgets/WidgetPeriodicTable.js @@ -60,7 +60,7 @@ export const WidgetPeriodicTable = React.memo(( actions={ <ActionSelect value={scale} - options={Object.keys(scales)} + options={scales} tooltip="Statistics scaling" onChange={handleChangeScale} /> diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js index 909a3506467b5d28a684e8d9572a7f6eea57ab95..04ecdf417545640011d5bfd0d093b6df70c54bf5 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js +++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js @@ -21,7 +21,9 @@ import { number, bool, reach } from 'yup' import { cloneDeep } from 'lodash' import { Checkbox, - FormControlLabel + FormControlLabel, + MenuItem, + TextField } from '@material-ui/core' import { InputJMESPath } from '../input/InputMetainfo' import { schemaWidget, schemaAxis, schemaMarkers } from './Widget' @@ -30,6 +32,7 @@ import { useSearchContext } from '../SearchContext' import { autorangeDescription } from './WidgetHistogram' import { DType, setDeep, parseJMESPath, isEmptyString } from '../../../utils' import { InputTextField } from '../input/InputText' +import { scalesLimited } from '../../plotting/common' import UnitInput from '../../units/UnitInput' // Predefined in order to not break memoization @@ -157,6 +160,20 @@ export const WidgetScatterPlotEdit = React.memo(({widget}) => { disableGroup /> </WidgetEditOption> + <WidgetEditOption> + <TextField + select + fullWidth + label="scale" + variant="filled" + value={settings.x?.scale} + onChange={(event) => { handleChange('x.scale', event.target.value) }} + > + {Object.keys(scalesLimited).map((key) => + <MenuItem value={key} key={key}>{key}</MenuItem> + )} + </TextField> + </WidgetEditOption> </WidgetEditGroup> <WidgetEditGroup title="y axis"> <WidgetEditOption> @@ -194,6 +211,20 @@ export const WidgetScatterPlotEdit = React.memo(({widget}) => { disableGroup /> </WidgetEditOption> + <WidgetEditOption> + <TextField + select + fullWidth + label="scale" + variant="filled" + value={settings.y?.scale} + onChange={(event) => { handleChange('y.scale', event.target.value) }} + > + {Object.keys(scalesLimited).map((key) => + <MenuItem value={key} key={key}>{key}</MenuItem> + )} + </TextField> + </WidgetEditOption> </WidgetEditGroup> <WidgetEditGroup title="marker color"> <WidgetEditOption> diff --git a/gui/src/components/search/widgets/WidgetTerms.js b/gui/src/components/search/widgets/WidgetTerms.js index a4289f5ec9c653c43789ab1fc3187539b51b59da..889587224392deb5c3cc875f8fe18aff5eb5347e 100644 --- a/gui/src/components/search/widgets/WidgetTerms.js +++ b/gui/src/components/search/widgets/WidgetTerms.js @@ -196,7 +196,7 @@ export const WidgetTerms = React.memo(( actions={ <ActionSelect value={scale} - options={Object.keys(scales)} + options={scales} tooltip="Statistics scaling" onChange={handleChangeScale} /> diff --git a/gui/tests/env.js b/gui/tests/env.js index 2d4351f800bb579cd6c8d93c450be4a7b269e8df..e917a871b6c849a8187fb47ab0f0b675a5584e7d 100644 --- a/gui/tests/env.js +++ b/gui/tests/env.js @@ -1049,7 +1049,7 @@ window.nomadEnv = { } }, "quantity": "results.material.structural_type", - "scale": "1/8", + "scale": "log", "showinput": false }, { @@ -1097,7 +1097,7 @@ window.nomadEnv = { } }, "quantity": "results.method.simulation.program_name", - "scale": "1/4", + "scale": "log", "showinput": true }, { @@ -2128,7 +2128,11 @@ window.nomadEnv = { }, "x": { "unit": "ml/(g*s)", - "quantity": "results.properties.catalytic.reaction.reaction_conditions.weight_hourly_space_velocity" + "quantity": "results.properties.catalytic.reaction.reaction_conditions.weight_hourly_space_velocity", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": false, @@ -2182,14 +2186,17 @@ window.nomadEnv = { }, "x": { "title": "gas concentration (%)", - "quantity": "results.properties.catalytic.reaction.reactants[*].gas_concentration_in" + "quantity": "results.properties.catalytic.reaction.reactants[*].gas_concentration_in", + "scale": "linear" }, "y": { - "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature" + "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.catalytic.reaction.reactants[*].name" + "quantity": "results.properties.catalytic.reaction.reactants[*].name", + "scale": "linear" } }, "size": 1000, @@ -2242,7 +2249,11 @@ window.nomadEnv = { }, "x": { "unit": "bar", - "quantity": "results.properties.catalytic.reaction.reaction_conditions.pressure" + "quantity": "results.properties.catalytic.reaction.reaction_conditions.pressure", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": false, @@ -2295,15 +2306,18 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature" + "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature", + "scale": "linear" }, "y": { "title": "Conversion (%)", - "quantity": "results.properties.catalytic.reaction.reactants[*].conversion" + "quantity": "results.properties.catalytic.reaction.reactants[*].conversion", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.catalytic.reaction.reactants[*].name" + "quantity": "results.properties.catalytic.reaction.reactants[*].name", + "scale": "linear" } }, "size": 1000, @@ -2355,15 +2369,18 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature" + "quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature", + "scale": "linear" }, "y": { "title": "Selectivity (%)", - "quantity": "results.properties.catalytic.reaction.products[*].selectivity" + "quantity": "results.properties.catalytic.reaction.products[*].selectivity", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.catalytic.reaction.products[*].name" + "quantity": "results.properties.catalytic.reaction.products[*].name", + "scale": "linear" } }, "size": 1000, @@ -2415,15 +2432,18 @@ window.nomadEnv = { }, "x": { "title": "Oxygen Conversion (%)", - "quantity": "results.properties.catalytic.reaction.reactants[? name=='molecular oxygen'].conversion" + "quantity": "results.properties.catalytic.reaction.reactants[? name=='molecular oxygen'].conversion", + "scale": "linear" }, "y": { "title": "Acetic Acid Selectivity (%)", - "quantity": "results.properties.catalytic.reaction.products[? name=='acetic acid'].selectivity" + "quantity": "results.properties.catalytic.reaction.products[? name=='acetic acid'].selectivity", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.catalytic.reaction.name" + "quantity": "results.properties.catalytic.reaction.name", + "scale": "linear" } }, "size": 1000, @@ -2475,15 +2495,18 @@ window.nomadEnv = { }, "x": { "title": "Carbon Monoxide Conversion (%)", - "quantity": "results.properties.catalytic.reaction.reactants[? name=='carbon monoxide'].conversion" + "quantity": "results.properties.catalytic.reaction.reactants[? name=='carbon monoxide'].conversion", + "scale": "linear" }, "y": { "title": "Ethanol Selectivity (%)", - "quantity": "results.properties.catalytic.reaction.products[? name=='ethanol'].selectivity" + "quantity": "results.properties.catalytic.reaction.products[? name=='ethanol'].selectivity", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.catalytic.catalyst.preparation_method" + "quantity": "results.properties.catalytic.catalyst.preparation_method", + "scale": "linear" } }, "size": 1000, @@ -2536,9 +2559,13 @@ window.nomadEnv = { }, "x": { "unit": "m^2/g", - "quantity": "results.properties.catalytic.catalyst.surface_area" + "quantity": "results.properties.catalytic.catalyst.surface_area", + "scale": "linear" + }, + "y": { + "scale": "log" }, - "scale": "1/4", + "scale": "linear", "autorange": false, "showinput": false, "nbins": 30 @@ -3144,7 +3171,11 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.material.topology.pore_limiting_diameter" + "quantity": "results.material.topology.pore_limiting_diameter", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": true, @@ -3196,7 +3227,11 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.material.topology.largest_cavity_diameter" + "quantity": "results.material.topology.largest_cavity_diameter", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": true, @@ -3248,7 +3283,11 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.material.topology.accessible_surface_area" + "quantity": "results.material.topology.accessible_surface_area", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": true, @@ -3300,7 +3339,11 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.material.topology.void_fraction" + "quantity": "results.material.topology.void_fraction", + "scale": "linear" + }, + "y": { + "scale": "linear" }, "scale": "linear", "autorange": true, @@ -4081,16 +4124,19 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage" + "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", + "scale": "linear" }, "y": { "title": "Efficiency (%)", - "quantity": "results.properties.optoelectronic.solar_cell.efficiency" + "quantity": "results.properties.optoelectronic.solar_cell.efficiency", + "scale": "linear" }, "markers": { "color": { "unit": "mA/cm^2", - "quantity": "results.properties.optoelectronic.solar_cell.short_circuit_current_density" + "quantity": "results.properties.optoelectronic.solar_cell.short_circuit_current_density", + "scale": "linear" } }, "size": 1000, @@ -4141,15 +4187,18 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage" + "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", + "scale": "linear" }, "y": { "title": "Efficiency (%)", - "quantity": "results.properties.optoelectronic.solar_cell.efficiency" + "quantity": "results.properties.optoelectronic.solar_cell.efficiency", + "scale": "linear" }, "markers": { "color": { - "quantity": "results.properties.optoelectronic.solar_cell.device_architecture" + "quantity": "results.properties.optoelectronic.solar_cell.device_architecture", + "scale": "linear" } }, "size": 1000, @@ -4248,9 +4297,13 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity" + "quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity", + "scale": "linear" + }, + "y": { + "scale": "1/4" }, - "scale": "1/4", + "scale": "linear", "autorange": true, "showinput": true, "nbins": 30 @@ -4349,9 +4402,13 @@ window.nomadEnv = { } }, "x": { - "quantity": "results.properties.electronic.band_structure_electronic.band_gap.value" + "quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", + "scale": "linear" + }, + "y": { + "scale": "1/4" }, - "scale": "1/4", + "scale": "linear", "autorange": false, "showinput": false, "nbins": 30 diff --git a/nomad/config/defaults.yaml b/nomad/config/defaults.yaml index 9aadfb889a5ddf8641f0f7e58400ecde9301c8d8..4629ce57deadc2998efc0d35311312fdd08e70b2 100644 --- a/nomad/config/defaults.yaml +++ b/nomad/config/defaults.yaml @@ -973,7 +973,7 @@ ui: xl: {h: 11, minH: 3, minW: 3, w: 5, x: 19, y: 0} xxl: {h: 9, minH: 3, minW: 3, w: 6, x: 19, y: 0} quantity: results.material.structural_type - scale: 1/8 + scale: log showinput: false type: terms - layout: @@ -983,7 +983,7 @@ ui: xl: {h: 11, minH: 3, minW: 3, w: 5, x: 14, y: 0} xxl: {h: 9, minH: 3, minW: 3, w: 6, x: 13, y: 0} quantity: results.method.simulation.program_name - scale: 1/4 + scale: log showinput: true type: terms - layout: @@ -1518,7 +1518,7 @@ ui: quantity: results.properties.catalytic.catalyst.surface_area unit: 'm^2/g' title: 'Catalyst Surface Area' - scale: 1/4 + scale: log showinput: false type: histogram - layout: diff --git a/nomad/config/models/ui.py b/nomad/config/models/ui.py index 388209989e303a85e28e89d3fdc185b210e42fae..705ebd18af024ffec6181aaed52aad25a8063605 100644 --- a/nomad/config/models/ui.py +++ b/nomad/config/models/ui.py @@ -427,12 +427,20 @@ class Layout(ConfigBaseModel): class ScaleEnum(str, Enum): + LINEAR = 'linear' + LOG = 'log' + # TODO: The following should possibly be deprecated. POW1 = 'linear' POW2 = '1/2' POW4 = '1/4' POW8 = '1/8' +class ScaleEnumPlot(str, Enum): + LINEAR = 'linear' + LOG = 'log' + + class BreakpointEnum(str, Enum): SM = 'sm' MD = 'md' @@ -441,7 +449,18 @@ class BreakpointEnum(str, Enum): XXL = 'xxl' -class Axis(ConfigBaseModel): +# NOTE: Once the old power scaling options (1/2, 1/4, 1/8) are deprecated, the +# axis models here can be simplified. +class AxisScale(ConfigBaseModel): + """Basic configuration for a plot axis.""" + + scale: Optional[ScaleEnum] = Field( + ScaleEnum.LINEAR, + description="""Defines the axis scaling. Defaults to linear scaling.""", + ) + + +class AxisQuantity(ConfigBaseModel): """Configuration for a plot axis.""" title: Optional[str] = Field(description="""Custom title to show for the axis.""") @@ -458,6 +477,19 @@ class Axis(ConfigBaseModel): ) +class Axis(AxisScale, AxisQuantity): + """Configuration for a plot axis with limited scaling options.""" + + +class AxisLimitedScale(AxisQuantity): + """Configuration for a plot axis with limited scaling options.""" + + scale: Optional[ScaleEnumPlot] = Field( + ScaleEnumPlot.LINEAR, + description="""Defines the axis scaling. Defaults to linear scaling.""", + ) + + class Markers(ConfigBaseModel): """Configuration for plot markers.""" @@ -504,7 +536,12 @@ class WidgetHistogram(Widget): x: Union[Axis, str] = Field( description='Configures the information source and display options for the x-axis.' ) - scale: ScaleEnum = Field(description='Statistics scaling.') + y: Union[AxisScale, str] = Field( + description='Configures the information source and display options for the y-axis.' + ) + scale: Optional[ScaleEnum] = Field( + ScaleEnum.LINEAR, description='Statistics scaling.' + ) autorange: bool = Field( True, description='Whether to automatically set the range according to the data limits.', @@ -522,14 +559,26 @@ 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} + """Ensures backwards compatibility for quantity and scale.""" + # X-axis + x = values.get('x', {}) + if isinstance(x, str): + x = {'quantity': x} + if isinstance(x, dict): + quantity = values.get('quantity') + if quantity and not x.get('quantity'): + x['quantity'] = quantity + del values['quantity'] + values['x'] = x + + # Y-axis + y = values.get('y', {}) + if isinstance(y, dict): + scale = values.get('scale') + if scale: + y['scale'] = scale + del values['scale'] + values['y'] = y return values @@ -550,10 +599,10 @@ class WidgetScatterPlot(Widget): type: Literal['scatterplot'] = Field( 'scatterplot', description='Set as `scatterplot` to get this widget type.' ) - x: Union[Axis, str] = Field( + x: Union[AxisLimitedScale, str] = Field( description='Configures the information source and display options for the x-axis.' ) - y: Union[Axis, str] = Field( + y: Union[AxisLimitedScale, str] = Field( description='Configures the information source and display options for the y-axis.' ) markers: Optional[Markers] = Field(