diff --git a/gui/package.json b/gui/package.json index 897f36112cf0a908a33cbf8fb25f2992556d9740..a40518630d67b01df6d1571326746a3c2622266a 100644 --- a/gui/package.json +++ b/gui/package.json @@ -28,6 +28,7 @@ "dompurify": "^3.0.1", "fetch": "^1.1.0", "html-to-react": "^1.3.3", + "jmespath": "^0.16.0", "keycloak-js": "^18.0.1", "lodash": "^4.17.15", "material-ui-chip-input": "^1.1.0", @@ -95,13 +96,13 @@ "eject": "craco eject" }, "devDependencies": { + "@babel/eslint-parser": "^7.22.15", "@craco/craco": "^6.4.5", "@material-ui/codemod": "^4.5.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.2.0", "@welldone-software/why-did-you-render": "^7.0.1", - "@babel/eslint-parser": "^7.22.15", "craco-workbox": "^0.2.0", "eslint": "^7.11.0", "eslint-config-standard": "^17.0.0", diff --git a/gui/src/components/archive/ArchiveSearchBar.js b/gui/src/components/archive/ArchiveSearchBar.js index b8b13ff4a33df3746ea400155c972d3b066210f7..675fbc5bc950275f80f25e3e015825a6b4ab87aa 100644 --- a/gui/src/components/archive/ArchiveSearchBar.js +++ b/gui/src/components/archive/ArchiveSearchBar.js @@ -22,7 +22,7 @@ import clsx from 'clsx' import { Paper, Tooltip, IconButton } from '@material-ui/core' import SearchIcon from '@material-ui/icons/Search' import { useStyles } from '../search/SearchBar' -import { InputMetainfo } from '../search/input/InputMetainfo' +import { InputMetainfoControlled } from '../search/input/InputMetainfo' /** * This component shows a search bar with autocomplete functionality. @@ -43,7 +43,7 @@ const ArchiveSearchBar = React.memo(({options, group, onChange, className}) => { }, [options, onChange]) return <Paper className={clsx(className, styles.root)}> - <InputMetainfo + <InputMetainfoControlled value={value} onChange={setValue} onSelect={handleSelect} diff --git a/gui/src/components/editQuantity/NumberEditQuantity.js b/gui/src/components/editQuantity/NumberEditQuantity.js index 7b51b4084c0da8711589d0eb17f5bafc258297f9..63aee68de621864e1e9df0a0c407195ca22e3e8c 100644 --- a/gui/src/components/editQuantity/NumberEditQuantity.js +++ b/gui/src/components/editQuantity/NumberEditQuantity.js @@ -89,7 +89,7 @@ export const NumberField = React.memo((props) => { } // Try to parse the quantity. Value is required, unit is optional. - const {unit: parsedUnit, value, valueString, error} = parseQuantity(input, true, false, dimension) + const {unit: parsedUnit, value, valueString, error} = parseQuantity(input, dimension, true, false) previousNumberPart.current = valueString if (parsedUnit) { previousUnitLabel.current = parsedUnit.label() @@ -207,7 +207,7 @@ export const NumberEditQuantity = React.memo((props) => { const {quantityDef, value, onChange, ...otherProps} = props const {units, isReset} = useUnitContext() const defaultUnit = useMemo(() => quantityDef.unit && new Unit(quantityDef.unit), [quantityDef]) - const dimension = defaultUnit && defaultUnit.dimension(false) + const dimension = defaultUnit && defaultUnit.dimension() const [checked, setChecked] = useState(true) const [displayedValue, setDisplayedValue] = useState(true) const {defaultDisplayUnit: deprecatedDefaultDisplayUnit, ...fieldProps} = getFieldProps(quantityDef) @@ -300,7 +300,7 @@ export const UnitSelect = React.memo(({options, unit, onChange, dimension, disab // Validate input and submit unit if valid const submit = useCallback((val) => { - const {unit, error} = parseQuantity(val, false, true, dimension) + const {unit, error} = parseQuantity(val, dimension, false, true) if (error) { setError(error) } else { diff --git a/gui/src/components/plotting/Plot.js b/gui/src/components/plotting/Plot.js index 0cd785215c60d455ab8f93c92431b66c996d99ad..75f8eee4dc31faa4523533a55aa64f7791120032 100644 --- a/gui/src/components/plotting/Plot.js +++ b/gui/src/components/plotting/Plot.js @@ -481,6 +481,14 @@ const Plot = React.memo(forwardRef(({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutSubject, fixedMargins, canvasNode, firstRender, data, finalConfig, finalLayout, sizeReady, canvasRef, onRelayout, onRelayouting, fixMargins, getLayout]) + // If the callbacks change, we need to update them in the canvas as well. + // TODO: Do this for other callbacks as well. + useEffect(() => { + if (onSelected && canvasRef?.current?.on) { + canvasRef.current.on('plotly_selected', onSelected) + } + }, [onSelected, canvasRef]) + // For resetting the view. const handleReset = useCallback(() => { if (canvasRef?.current) { diff --git a/gui/src/components/plotting/PlotScatter.js b/gui/src/components/plotting/PlotScatter.js index 33b950ad4c4c15e8db1a9c50bca3d61d77f13114..085e878296416489b228adcfa2c0feae50f566c0 100644 --- a/gui/src/components/plotting/PlotScatter.js +++ b/gui/src/components/plotting/PlotScatter.js @@ -18,15 +18,10 @@ import React, {useState, useEffect, useMemo, useCallback, forwardRef} from 'react' import PropTypes from 'prop-types' import { makeStyles, useTheme } from '@material-ui/core' -import { getDeep, hasWebGLSupport, parseQuantityName } from '../../utils' -import { Quantity } from '../units/Quantity' -import { Unit } from '../units/Unit' -import { useUnitContext } from '../units/UnitContext' +import { hasWebGLSupport } from '../../utils' import * as d3 from 'd3' -import { isArray, isNil } from 'lodash' import FilterTitle from '../search/FilterTitle' import Plot from './Plot' -import { useSearchContext } from '../search/SearchContext' import { useHistory } from 'react-router-dom' import { getUrl } from '../nav/Routes' @@ -93,12 +88,9 @@ const PlotScatter = React.memo(forwardRef(( { data, title, - x, - y, - color, - unitX, - unitY, - unitColor, + xAxis, + yAxis, + colorAxis, discrete, autorange, dragmode, @@ -110,8 +102,6 @@ const PlotScatter = React.memo(forwardRef(( const styles = useStyles() const theme = useTheme() const [finalData, setFinalData] = useState(!data ? data : undefined) - const {units} = useUnitContext() - const { filterData } = useSearchContext() const history = useHistory() // Side effect that runs when the data that is displayed should change. By @@ -124,12 +114,7 @@ const PlotScatter = React.memo(forwardRef(( return } - // TODO: The API should support an "exists" query that could be used to - // return hits that actually have the requested values. This way we would - // get a better match for the query size and we would not need to manually - // validate and check the results. - - const hoverTemplate = (xLabel, yLabel, colorLabel, xUnit, yUnit, colorUnit, discrete) => { + const hoverTemplate = (xLabel, yLabel, colorLabel, xUnit, yUnit, colorUnit) => { let template = `<b>Click to go to entry page</b>` + `<br>` + `${xLabel || ''}: %{x} ${xUnit === 'dimensionless' ? '' : xUnit}<br>` + @@ -142,44 +127,27 @@ const PlotScatter = React.memo(forwardRef(( return template } - const values = data - .map((d) => ({ - x: getDeep(d, parseQuantityName(x).path), - y: getDeep(d, parseQuantityName(y).path), - color: color && getDeep(d, parseQuantityName(color).path), - entry_id: d.entry_id - })) - // We filter out points that don't have x, y values. Also for continuous - // coloring the color property needs to be defined. - .filter((d) => !isNil(d.x) && !isNil(d.y) && (discrete || !color || !isNil(d.color))) - // If dealing with a quantized color, each group is separated into it's own // trace which has a legend as well. - const unitXObj = new Unit(unitX) - const unitYObj = new Unit(unitY) - const unitColorObj = new Unit(unitColor) const traces = [] - if (color && discrete) { - const colorArray = [] - values.forEach((d) => { - if (isNil(d.color)) { - d.color = 'undefined' - } else if (isArray(d.color)) { - d.color = d.color.sort().join(", ") - } - colorArray.push(d.color) - }) - const options = [...new Set(colorArray)] + if (colorAxis?.quantity && discrete) { + const options = [...new Set(data.color)] const nOptions = options.length const scale = d3.scaleSequential([0, 1], d3.interpolateTurbo) const offset = 0.1 for (const option of options) { - const optionValues = values.filter((d) => d.color === option) - const xArray = optionValues.map((d) => new Quantity(d.x, unitXObj).toSystem(units).value()) - const yArray = optionValues.map((d) => new Quantity(d.y, unitYObj).toSystem(units).value()) - const unitLabelX = optionValues[0] ? new Quantity(optionValues[0].x, unitXObj).toSystem(units).label() : '' - const unitLabelY = optionValues[0] ? new Quantity(optionValues[0].y, unitYObj).toSystem(units).label() : '' - const entryIdArray = optionValues.map((d) => d.entry_id) + const xArray = [] + const yArray = [] + const colorArray = [] + const entryIdArray = [] + for (let i = 0; i < data.color.length; ++i) { + if (data.color[i] === option) { + xArray.push(data.x[i]) + yArray.push(data.y[i]) + colorArray.push(data.color[i]) + entryIdArray.push(data.id[i]) + } + } traces.push({ x: xArray, y: yArray, @@ -191,13 +159,12 @@ const PlotScatter = React.memo(forwardRef(( textposition: 'top center', showlegend: true, hovertemplate: hoverTemplate( - filterData[x]?.label, - filterData[y]?.label, - filterData[color]?.label, - unitLabelX, - unitLabelY, - '', - true + xAxis.title, + yAxis.title, + colorAxis.title, + xAxis.unit, + yAxis.unit, + '' ), marker: { size: 8, @@ -210,37 +177,29 @@ const PlotScatter = React.memo(forwardRef(( }) } // When dealing with a continuous color, display a colormap - } else if (color && !discrete) { - const xArray = values.map((d) => new Quantity(d.x, unitXObj).toSystem(units).value()) - const yArray = values.map((d) => new Quantity(d.y, unitYObj).toSystem(units).value()) - const colors = values.map((d) => new Quantity(d.color, unitColorObj).toSystem(units).value()) - const entryIdArray = values.map((d) => d.entry_id) - const unitLabelX = values[0] ? new Quantity(values[0].x, unitXObj).toSystem(units).label() : '' - const unitLabelY = values[0] ? new Quantity(values[0].y, unitYObj).toSystem(units).label() : '' - const unitLabelColors = values[0] ? new Quantity(values[0].color, unitColorObj).toSystem(units).label() : '' + } else if (colorAxis?.quantity && !discrete) { traces.push({ - x: xArray, - y: yArray, - color: colors, - text: colors, - entry_id: entryIdArray, - name: "Test", + x: data.x, + y: data.y, + color: data.color, + text: data.color, + entry_id: data.id, mode: 'markers', type: 'scattergl', textposition: 'top center', showlegend: false, hoverinfo: "text", hovertemplate: hoverTemplate( - filterData[x]?.label, - filterData[y]?.label, - filterData[color]?.label, - unitLabelX, - unitLabelY, - unitLabelColors + xAxis.title, + yAxis.title, + colorAxis.title, + xAxis.unit, + yAxis.unit, + colorAxis.unit ), marker: { size: 8, - color: colors, + color: data.color, colorscale: 'YlGnBu', line: { color: theme.palette.grey[800], @@ -259,26 +218,21 @@ const PlotScatter = React.memo(forwardRef(( // When color is not set, all points are displayed in a single plot with // primary theme color. } else { - const xArray = values.map((d) => new Quantity(d.x, unitXObj).toSystem(units).value()) - const yArray = values.map((d) => new Quantity(d.y, unitYObj).toSystem(units).value()) - const entryIdArray = values.map((d) => d.entry_id) - const unitLabelX = values[0] ? new Quantity(values[0].x, unitXObj).toSystem(units).label() : '' - const unitLabelY = values[0] ? new Quantity(values[0].y, unitYObj).toSystem(units).label() : '' traces.push({ - x: xArray, - y: yArray, - entry_id: entryIdArray, + x: data.x, + y: data.y, + entry_id: data.id, mode: 'markers', type: 'scattergl', textposition: 'top center', showlegend: false, hoverinfo: "text", hovertemplate: hoverTemplate( - filterData[x]?.label, - filterData[y]?.label, + xAxis.title, + yAxis.title, '', - unitLabelX, - unitLabelY, + xAxis.unit, + yAxis.unit, '' ), marker: { @@ -292,7 +246,7 @@ const PlotScatter = React.memo(forwardRef(( }) } setFinalData(traces) - }, [data, x, y, color, discrete, theme, units, unitX, unitY, unitColor, filterData]) + }, [colorAxis?.quantity, colorAxis?.title, colorAxis?.unit, data, discrete, theme, xAxis.title, xAxis.unit, yAxis.title, yAxis.unit]) const layout = useMemo(() => { return { @@ -356,7 +310,13 @@ const PlotScatter = React.memo(forwardRef(( return <div className={styles.root}> <div className={styles.yaxis}> - <FilterTitle quantity={y} variant="caption" rotation="up"/> + <FilterTitle + quantity={yAxis.quantity} + label={yAxis.title} + unit={yAxis.unit} + variant="caption" + rotation="up" + /> </div> <div className={styles.plot}> <Plot @@ -376,13 +336,20 @@ const PlotScatter = React.memo(forwardRef(( </div> <div className={styles.square} /> <div className={styles.xaxis}> - <FilterTitle quantity={x} variant="caption"/> + <FilterTitle + quantity={xAxis.quantity} + label={xAxis.title} + unit={xAxis.unit} + variant="caption" + /> </div> - {!discrete && color && + {!discrete && colorAxis && <div className={styles.color}> <FilterTitle rotation="down" - quantity={color} + quantity={colorAxis.quantity} + unit={colorAxis.unit} + label={colorAxis.title} description="" variant="caption" /> @@ -392,14 +359,11 @@ const PlotScatter = React.memo(forwardRef(( })) PlotScatter.propTypes = { - data: PropTypes.arrayOf(PropTypes.object), + data: PropTypes.object, title: PropTypes.string, - x: PropTypes.string, - y: PropTypes.string, - color: PropTypes.string, - unitX: PropTypes.string, - unitY: PropTypes.string, - unitColor: PropTypes.string, + xAxis: PropTypes.object, // Contains x-axis settings + yAxis: PropTypes.object, // Contains y-axis settings + colorAxis: PropTypes.object, // Contains colorbar settings discrete: PropTypes.bool, autorange: PropTypes.bool, dragmode: PropTypes.string, diff --git a/gui/src/components/search/Filter.js b/gui/src/components/search/Filter.js index 27d92211bbdb3c55a92269823df67fbe383f09b4..efd69fd370365a559ea3997eb647d74d9d3464c8 100644 --- a/gui/src/components/search/Filter.js +++ b/gui/src/components/search/Filter.js @@ -125,20 +125,20 @@ export class Filter { this.quantity = params?.quantity || def?.quantity this.schema = params?.schema || def?.schema - function getRepeats(def) { + function getRepeatsSection(def) { + return isNil(def?.repeats) + ? false + : def.repeats + } + function getRepeatsQuantity(def) { if (!isEmpty(def?.shape)) return true - if (!isNil(def?.repeats)) { - return def.repeats - } else if (parent) { - return getRepeats(parent) - } - return false + return getRepeatsSection(def) } this.dtype = params?.dtype || getDatatype(def) this.description = params?.description || def?.description this.unit = params?.unit || def?.unit - this.dimension = def?.unit && new Unit(def?.unit).dimension() + this.dimension = def?.unit ? new Unit(def?.unit).dimension() : 'dimensionless' this.label = params?.label || formatLabel(this.name) let parentName if (parent) { @@ -166,7 +166,9 @@ export class Filter { this.aggs = params?.aggs this.requestQuantity = params?.requestQuantity this.nested = params?.nested === undefined ? false : params?.nested - this.repeats = params?.repeats === undefined ? getRepeats(def) : params?.repeats + this.repeats = params?.repeats === undefined ? getRepeatsQuantity(def) : params?.repeats + this.repeats_section = getRepeatsSection(def) + this.scalar = isEmpty(def?.shape) this.global = params?.global === undefined ? false : params?.global this.section = !isNil(def?.nested) this.customSerialization = !!params?.serializerExact diff --git a/gui/src/components/search/FilterRegistry.js b/gui/src/components/search/FilterRegistry.js index 89b0b7d1427869f48452643583b0090cd16044a5..57ade3c7eec52d0c412d343bb4e5bdee72d070e8 100644 --- a/gui/src/components/search/FilterRegistry.js +++ b/gui/src/components/search/FilterRegistry.js @@ -256,12 +256,24 @@ registerFilter( {name: 'sbu_coordination_number', ...numberHistogramQuantity} ] ) +registerFilter( + 'results.material.elemental_composition', + idStructure, + nestedQuantity, + [ + {name: 'element', ...termQuantity}, + {name: 'atomic_fraction', ...numberHistogramQuantity}, + {name: 'mass_fraction', ...numberHistogramQuantity} + ] +) registerFilter( 'results.material.topology.elemental_composition', idStructure, nestedQuantity, [ - {name: 'element', ...termQuantity} + {name: 'element', ...termQuantity}, + {name: 'atomic_fraction', ...numberHistogramQuantity}, + {name: 'mass_fraction', ...numberHistogramQuantity} ] ) registerFilter( @@ -441,6 +453,16 @@ registerFilter( {name: 'value', ...numberHistogramQuantity, scale: '1/4'} ] ) +registerFilter( + 'results.properties.electronic.band_gap', + idElectronic, + nestedQuantity, + [ + {name: 'type', ...termQuantity}, + {name: 'value', ...numberHistogramQuantity, scale: '1/4'} + ] +) +registerFilter('results.properties.electronic.band_gap.provenance.label', idElectronic, termQuantity) registerFilter( 'results.properties.optoelectronic.solar_cell', idSolarCell, diff --git a/gui/src/components/search/FilterTitle.js b/gui/src/components/search/FilterTitle.js index 5e53bcbec174c427e7c7ed5545f3965423715b03..e1d3e2c80eb3444e320d7168c29aa92488729d18 100644 --- a/gui/src/components/search/FilterTitle.js +++ b/gui/src/components/search/FilterTitle.js @@ -56,6 +56,7 @@ const FilterTitle = React.memo(({ quantity, label, description, + unit, variant, full, TooltipProps, @@ -78,14 +79,18 @@ const FilterTitle = React.memo(({ ? filterData[quantity]?.labelFull : filterData[quantity]?.label) if (!disableUnit) { - const unit = filterData[quantity]?.unit + let finalUnit if (unit) { - const unitDef = new Unit(unit) - finalLabel = `${finalLabel} (${unitDef.toSystem(units).label()})` + finalUnit = new Unit(unit).label() + } else if (filterData[quantity]?.unit) { + finalUnit = new Unit(filterData[quantity].unit).toSystem(units).label() + } + if (finalUnit) { + finalLabel = `${finalLabel} (${finalUnit})` } } return finalLabel - }, [filterData, quantity, units, label, disableUnit, full]) + }, [filterData, quantity, units, unit, label, disableUnit, full]) // Determine the final description const finalDescription = description || filterData[quantity]?.description || '' @@ -112,6 +117,7 @@ const FilterTitle = React.memo(({ FilterTitle.propTypes = { quantity: PropTypes.string, label: PropTypes.string, + unit: PropTypes.string, description: PropTypes.string, variant: PropTypes.string, full: PropTypes.bool, diff --git a/gui/src/components/search/SearchBar.spec.js b/gui/src/components/search/SearchBar.spec.js index 3d4b818a896739d8b0797753f25c173eaf3f8a0c..4411240dadbd8e80f5e538cec098f9caba2d1c96 100644 --- a/gui/src/components/search/SearchBar.spec.js +++ b/gui/src/components/search/SearchBar.spec.js @@ -62,7 +62,6 @@ describe('searchbar queries', function() { const textInput = screen.getByRole('textbox') await userEvent.type(textInput, input) expect(screen.getByRole('textbox')).toHaveValue(input) - expect(screen.getByText(type)) await userEvent.keyboard('{enter}') expect(screen.getByRole('textbox')).toHaveValue('') }) diff --git a/gui/src/components/search/conftest.spec.js b/gui/src/components/search/conftest.spec.js index 36f3ac68c750170b1bb6ccccde10552b4b1ca66c..af45df7b7e6eed0078b56f223853d1d2cf230e12 100644 --- a/gui/src/components/search/conftest.spec.js +++ b/gui/src/components/search/conftest.spec.js @@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event' import { SearchContext } from './SearchContext' import { defaultFilterData } from './FilterRegistry' import { format } from 'date-fns' -import { DType } from '../../utils' +import { DType, parseJMESPath } from '../../utils' import { Unit } from '../units/Unit' import { ui } from '../../config' import { menuMap } from './menus/FilterMainMenu' @@ -75,8 +75,8 @@ export async function expectFilterTitle(quantity, label, description, unit, disa ) if (finalUnit) finalLabel = `${finalLabel} (${finalUnit})` } - await root.findByText(finalLabel) - expect(root.getByTooltip(finalDescription)).toBeInTheDocument() + await root.findAllByText(finalLabel) + expect(root.getAllByTooltip(finalDescription)[0]).toBeInTheDocument() } /** @@ -144,10 +144,22 @@ export async function expectWidgetTerms(widget, loaded, items, prompt, root = sc * @param {object} widget The widget setup * @param {bool} loaded Whether the data is already loaded */ -export async function expectWidgetScatterPlot(widget, loaded, root = screen) { +export async function expectWidgetScatterPlot(widget, loaded, colorTitle, legend, root = screen) { // Test immediately displayed elements - await expectFilterTitle(widget.x) - await expectFilterTitle(widget.y) + const {quantity: x} = parseJMESPath(widget.x.quantity) + const {quantity: y} = parseJMESPath(widget.y.quantity) + await expectFilterTitle(x) + await expectFilterTitle(y) + if (colorTitle) { + if (colorTitle.quantity) { + await expectFilterTitle(colorTitle.quantity) + } else { + await expectFilterTitle(undefined, colorTitle.title, colorTitle.unit) + } + } + for (const label of legend || []) { + root.getByText(label) + } // Check that placeholder disappears if (!loaded) { diff --git a/gui/src/components/search/input/InputMetainfo.js b/gui/src/components/search/input/InputMetainfo.js index 15faa08a9c1be44f0b896f154153dba56debc4eb..ae9304eacc7096603855f9c18b03e88ec9a6ada2 100644 --- a/gui/src/components/search/input/InputMetainfo.js +++ b/gui/src/components/search/input/InputMetainfo.js @@ -20,98 +20,29 @@ import React, { useCallback, useMemo, useEffect, - useRef, useContext, - createContext + createContext, + useRef } from 'react' import { makeStyles } from '@material-ui/core/styles' import PropTypes from 'prop-types' import { Tooltip, List, ListItemText, ListSubheader } from '@material-ui/core' import HelpOutlineIcon from '@material-ui/icons/HelpOutline' -import { getSchemaAbbreviation, getSuggestions } from '../../../utils' +import { getSchemaAbbreviation, getSuggestions, parseJMESPath } from '../../../utils' import { useSearchContext } from '../SearchContext' import { VariableSizeList } from 'react-window' import { InputText } from './InputText' /** - * A metainfo option shown as a suggestion. + * Wrapper around InputText that is specialized in showing metainfo options. The + * allowed options are controlled. */ -export const useMetainfoOptionStyles = makeStyles(theme => ({ - optionText: { - flexGrow: 1, - overflowX: 'scroll', - '&::-webkit-scrollbar': { - display: 'none' - }, - '-ms-overflow-style': 'none', - scrollbarWidth: 'none' - }, - noWrap: { - whiteSpace: 'nowrap' - }, - option: { - width: '100%', - display: 'flex', - alignItems: 'stretch', - // The description icon is hidden until the item is hovered. It is not - // removed from the document with "display: none" in order for the hover to - // not change the layout which may cause other elements to shift around. - '& .description': { - visibility: "hidden", - marginRight: theme.spacing(-0.5), - display: 'flex', - width: theme.spacing(5), - marginLeft: theme.spacing(1), - alignItems: 'center', - justifyContent: 'center' - }, - '&:hover .description': { - visibility: "visible" - } - } -})) -export const MetainfoOption = ({id, options}) => { - const styles = useMetainfoOptionStyles() - const option = options[id] - const primary = option.primary || option.definition?.quantity || option.key - const dtype = option.dtype || option.definition?.dtype - const schema = getSchemaAbbreviation(option.schema || option.definition?.schema) - const secondary = option.secondary || ((dtype || schema) - ? `${dtype} ${schema ? `| ${schema}` : ''}` - : null) - const description = option.description || option.definition?.description - - return <div className={styles.option}> - <ListItemText - primary={primary} - secondary={secondary} - className={styles.optionText} - primaryTypographyProps={{className: styles.noWrap}} - secondaryTypographyProps={{className: styles.noWrap}} - /> - {description && - <Tooltip title={description || ''}> - <div className="description"> - <HelpOutlineIcon fontSize="small" color="action"/> - </div> - </Tooltip> - } - </div> -} - -MetainfoOption.propTypes = { - id: PropTypes.string, - options: PropTypes.object -} - -/** - * Wrapper around InputText that is specialized in showing metainfo options. - */ -export const InputMetainfo = React.memo(({ +export const InputMetainfoControlled = React.memo(({ label, value, options, error, + validate, onChange, onSelect, onAccept, @@ -122,7 +53,9 @@ export const InputMetainfo = React.memo(({ InputProps, PaperComponent, group, - loading + loading, + disableValidation, + disableValidateOnSelect }) => { // Predefine all option objects, all option paths and also pre-tokenize the // options for faster matching. @@ -133,32 +66,35 @@ export const InputMetainfo = React.memo(({ return { keys, keysSet, filter } }, [options]) + const textProps = useMemo(() => { + return { + label, + ...TextFieldProps + } + }, [label, TextFieldProps]) + // Used to validate the input and raise errors - const validate = useCallback((value) => { + const validateFinal = useCallback((value) => { + if (disableValidation) { + return {valid: true, error: undefined} + } const empty = !value || value.length === 0 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: 'Invalid value for this field'} } return {valid: true, error: undefined} - }, [keysSet, optional]) - - // Handles the final acceptance of a value - const handleAccept = useCallback((key) => { - const {valid, error} = validate(key) - if (valid) { - onAccept && onAccept(key, options[key]) - } else { - onError && onError(error) - } - }, [validate, onError, onAccept, options]) + }, [validate, keysSet, optional, disableValidation]) - // Handles the final acceptance of a value + // Handles the selectance of a suggested value const handleSelect = useCallback((key) => { - onSelect && onSelect(key, options[key]) + onSelect?.(key, options[key]) }, [onSelect, options]) // Used to filter the shown options based on input @@ -170,16 +106,15 @@ export const InputMetainfo = React.memo(({ return <InputText value={value || null} - label={label} error={error} onChange={onChange} onSelect={handleSelect} - onAccept={handleAccept} + onAccept={onAccept} onBlur={onBlur} onError={onError} suggestions={keys} ListboxComponent={ListboxMetainfo} - TextFieldProps={TextFieldProps} + TextFieldProps={textProps} PaperComponent={PaperComponent} InputProps={InputProps} groupBy={group && ((key) => options?.[key]?.group)} @@ -188,10 +123,12 @@ export const InputMetainfo = React.memo(({ filterOptions={filterOptions} loading={loading} renderOption={(id) => <MetainfoOption id={id} options={options} />} + validate={validateFinal} + disableValidateOnSelect={disableValidateOnSelect} /> }) -InputMetainfo.propTypes = { +InputMetainfoControlled.propTypes = { label: PropTypes.string, value: PropTypes.string, options: PropTypes.objectOf(PropTypes.shape({ @@ -205,6 +142,7 @@ InputMetainfo.propTypes = { group: PropTypes.string // Optional group information })), error: PropTypes.string, + validate: PropTypes.func, // Optional custom validation function onChange: PropTypes.func, onSelect: PropTypes.func, onAccept: PropTypes.func, @@ -215,79 +153,104 @@ InputMetainfo.propTypes = { TextFieldProps: PropTypes.object, InputProps: PropTypes.object, PaperComponent: PropTypes.any, - loading: PropTypes.bool + loading: PropTypes.bool, + disableValidation: PropTypes.bool, + disableValidateOnSelect: PropTypes.bool } -InputMetainfo.defaultProps = { - TextFieldProps: {label: "quantity"} +InputMetainfoControlled.defaultProps = { + label: "quantity" } /** - * Wrapper around InputMetainfo which automatically shows suggestions and only - * accepts metainfo that exist in the current search context. + * Wrapper around InputText that is specialized in showing metainfo options. The + * allowed options are uncontrolled: they are specified by the current + * SearchContext. */ -export const InputSearchMetainfo = React.memo(({ - value, - label, - error, - onChange, - onSelect, - onAccept, - onError, +export const InputMetainfo = React.memo(({ dtypes, dtypesRepeatable, - optional, - disableNonAggregatable + disableNonAggregatable, + ...rest }) => { const { filterData } = useSearchContext() // Fetch the available metainfo names and create options that are compatible // with InputMetainfo. - const suggestions = useMemo(() => { - const suggestions = Object.fromEntries( - Object.entries(filterData) - .filter(([key, data]) => { - if (disableNonAggregatable && !data.aggregatable) return false - const dtype = data?.dtype - return data?.repeats - ? dtypesRepeatable?.has(dtype) - : dtypes?.has(dtype) - }) - .map(([key, data]) => [key, { - key: key, - definition: data - }]) - ) - return suggestions + const options = useMemo(() => { + return getMetainfoOptions(filterData, dtypes, dtypesRepeatable, disableNonAggregatable) }, [filterData, dtypes, dtypesRepeatable, disableNonAggregatable]) - return <InputMetainfo - options={suggestions} - value={value} - label={label} - error={error} - onChange={onChange} - onSelect={onSelect} - onAccept={onAccept} - onError={onError} - optional={optional} + return <InputMetainfoControlled + options={options} + {...rest} /> }) -InputSearchMetainfo.propTypes = { - label: PropTypes.string, - value: PropTypes.string, - error: PropTypes.string, - onChange: PropTypes.func, - onSelect: PropTypes.func, - onAccept: PropTypes.func, - onError: PropTypes.func, +InputMetainfo.propTypes = { + /* List of allowed data types for non-repeatable quantities. */ + dtypes: PropTypes.object, + /* List of allowed data types for repeatable quantities. */ + dtypesRepeatable: PropTypes.object, + /* Whether non-aggregatable values are excluded */ + disableNonAggregatable: PropTypes.bool +} + +/** + * Wrapper around InputText which accepts JMESPath syntax for quantities. The + * allowed quantities are uncontrolled: they are specified by the current + * SearchContext. + */ +export const InputJMESPath = React.memo(React.forwardRef(({ + dtypes, + dtypesRepeatable, + disableNonAggregatable, + ...rest +}, ref) => { + const { filterData } = useSearchContext() + + // Fetch the available metainfo names and create options that are compatible + // with InputMetainfo. + const [options, keysSet] = useMemo(() => { + const options = getMetainfoOptions(filterData, dtypes, dtypesRepeatable, disableNonAggregatable) + const keysSet = new Set(Object.keys(options)) + return [options, keysSet] + }, [filterData, dtypes, dtypesRepeatable, disableNonAggregatable]) + + // Used to validate the JMESPath input + const validate = useCallback((value) => { + const {quantity, path, extras, error: errorParse, schema} = parseJMESPath(value) + if (errorParse) { + return {valid: false, error: 'Invalid JMESPath query, please check your syntax.'} + } + if (!(keysSet.has(quantity))) { + return {valid: false, error: `The quantity "${quantity}" is not available.`} + } + for (const extra of extras) { + if (!filterData[extra]) { + return {valid: false, error: `The quantity "${extra}" is not available.`} + } + } + if (filterData[quantity].repeats_section && quantity === path + schema) { + return {valid: false, error: `The quantity "${quantity}" is contained in at least one repeatable section. Please use JMESPath syntax to select one or more target sections.`} + } + return {valid: true, error: undefined} + }, [keysSet, filterData]) + + return <InputMetainfoControlled + options={options} + validate={validate} + disableValidateOnSelect + ref={ref} + {...rest} + /> +})) + +InputJMESPath.propTypes = { /* List of allowed data types for non-repeatable quantities. */ dtypes: PropTypes.object, /* List of allowed data types for repeatable quantities. */ dtypesRepeatable: PropTypes.object, - /* Whether the value is optional */ - optional: PropTypes.bool, /* Whether non-aggregatable values are excluded */ disableNonAggregatable: PropTypes.bool } @@ -376,3 +339,99 @@ export function renderRow({ data, index, style }) { } }) } + +/* A metainfo option shown as a suggestion. + */ +export const useMetainfoOptionStyles = makeStyles(theme => ({ + optionText: { + flexGrow: 1, + overflowX: 'scroll', + '&::-webkit-scrollbar': { + display: 'none' + }, + '-ms-overflow-style': 'none', + scrollbarWidth: 'none' + }, + noWrap: { + whiteSpace: 'nowrap' + }, + option: { + width: '100%', + display: 'flex', + alignItems: 'stretch', + // The description icon is hidden until the item is hovered. It is not + // removed from the document with "display: none" in order for the hover to + // not change the layout which may cause other elements to shift around. + '& .description': { + visibility: "hidden", + marginRight: theme.spacing(-0.5), + display: 'flex', + width: theme.spacing(5), + marginLeft: theme.spacing(1), + alignItems: 'center', + justifyContent: 'center' + }, + '&:hover .description': { + visibility: "visible" + } + } +})) +export const MetainfoOption = ({id, options}) => { + const styles = useMetainfoOptionStyles() + const option = options[id] + const primary = option.primary || option.definition?.quantity || option.key + const dtype = option.dtype || option.definition?.dtype + const schema = getSchemaAbbreviation(option.schema || option.definition?.schema) + const secondary = option.secondary || ((dtype || schema) + ? `${dtype} ${schema ? `| ${schema}` : ''}` + : null) + const description = option.description || option.definition?.description + + return <div className={styles.option}> + <ListItemText + primary={primary} + secondary={secondary} + className={styles.optionText} + primaryTypographyProps={{className: styles.noWrap}} + secondaryTypographyProps={{className: styles.noWrap}} + /> + {description && + <Tooltip title={description || ''}> + <div className="description"> + <HelpOutlineIcon fontSize="small" color="action"/> + </div> + </Tooltip> + } + </div> +} + +MetainfoOption.propTypes = { + id: PropTypes.string, + options: PropTypes.object +} + +/** + * Used to filter a set of filterData according to the given filtering options. + * + * @param {*} filterData The filterData to filter + * @param {*} dtypes Included data types as a set + * @param {*} dtypesRepeatable Included repeatabel data types as a set + * @param {*} disableNonAggregatable Whether to filter out non-aggregatable filters + * @returns Object containing the filtered filters + */ +function getMetainfoOptions(filterData, dtypes, dtypesRepeatable, disableNonAggregatable) { + return Object.fromEntries( + Object.entries(filterData) + .filter(([key, data]) => { + if (disableNonAggregatable && !data.aggregatable) return false + const dtype = data?.dtype + return data?.repeats + ? dtypesRepeatable?.has(dtype) + : dtypes?.has(dtype) + }) + .map(([key, data]) => [key, { + key: key, + definition: data + }]) + ) +} diff --git a/gui/src/components/search/input/InputMetainfo.spec.js b/gui/src/components/search/input/InputMetainfo.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0bc65da2fe6cbb29ca35a3e8602210aa50db9b98 --- /dev/null +++ b/gui/src/components/search/input/InputMetainfo.spec.js @@ -0,0 +1,65 @@ +/* + * 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 { render, screen } from '../../conftest.spec' +import { InputJMESPath } from './InputMetainfo' +import { SearchContext } from '../SearchContext' +import { ui } from '../../../config' +import { DType } from '../../../utils' + +const context = ui.apps.options.entries + +test.each([ + ['filter exists', 'results.material.n_elements', null], + ['filter does not exist', 'not.present', "The quantity \"not.present\" is not available."], + ['valid jmespath', 'results.material.n_elements[0]', null], + ['invalid jmespath', 'results.material.n_elements[0', 'Invalid JMESPath query, please check your syntax.'] +])('%s', async (name, input, error) => { + const onErrorMock = jest.fn() + const onAcceptMock = jest.fn() + const onChangeMock = jest.fn() + render( + <SearchContext + resource={context.resource} + initialPagination={context.pagination} + initialColumns={context.columns} + initialRows={context.rows} + initialFilters={context?.filters} + initialFilterMenus={context.filter_menus} + initialFiltersLocked={context.filters_locked} + initialDashboard={context?.dashboard} + initialSearchSyntaxes={context?.search_syntaxes} + > + <InputJMESPath + value={input} + onAccept={onAcceptMock} + onError={onErrorMock} + onChange={onChangeMock} + dtypes={new Set([DType.String, DType.Int])} + /> + </SearchContext> + ) + const textInput = screen.getByRole('textbox') + await userEvent.type(textInput, '{Enter}') + if (error) { + expect(onErrorMock).toHaveBeenCalledWith(error) + } else { + expect(onAcceptMock).toHaveBeenCalledWith(input, undefined) + } +}) diff --git a/gui/src/components/search/input/InputText.js b/gui/src/components/search/input/InputText.js index 23a370c42202fed7b224c974c220687dd9c2c947..ef41ea725b4887040cf3ac73ad86c14958602b4e 100644 --- a/gui/src/components/search/input/InputText.js +++ b/gui/src/components/search/input/InputText.js @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback, useState, useMemo, useRef } from 'react' +import React, { useCallback, useState, useMemo, useRef, useEffect } from 'react' import { makeStyles, useTheme } from '@material-ui/core/styles' import PropTypes from 'prop-types' import clsx from 'clsx' @@ -119,7 +119,9 @@ export const InputText = React.memo(({ InputProps, PaperComponent, disableClearable, - disableAcceptOnBlur + disableAcceptOnBlur, + validate, + disableValidateOnSelect }) => { const theme = useTheme() const styles = useInputTextStyles({classes: classes, theme: theme}) @@ -137,6 +139,36 @@ export const InputText = React.memo(({ setOpen(false) }, [onChange, onError]) + const handleAccept = useCallback((value) => { + const {valid, error, data} = validate ? validate(value) : {valid: true, error: undefined, data: undefined} + if (valid) { + onAccept?.(value && value.trim(), data) + } else { + onError?.(error) + } + }, [onAccept, validate, onError]) + + // Validate the initial value if it is non-empty. + useEffect(() => { + if (!(isNil(value) || value?.trim?.() === '')) { + handleAccept(value) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSelect = useCallback((value) => { + if (disableValidateOnSelect) { + onSelect?.(value && value.trim()) + } else { + const {valid, error, data} = validate ? validate(value) : {valid: true, error: undefined, data: undefined} + if (valid) { + onSelect?.(value && value.trim(), data) + } else { + onError?.(error) + } + } + }, [onSelect, disableValidateOnSelect, validate, onError]) + // Handle item highlighting: items can he highlighted with mouse or keyboard. const handleHighlight = useCallback((event, value, reason) => { onHighlight?.(value, reason) @@ -146,8 +178,8 @@ export const InputText = React.memo(({ // Handle blur const handleBlur = useCallback(() => { onBlur?.() - !disableAcceptOnBlur && onAccept?.(value) - }, [onBlur, onAccept, value, disableAcceptOnBlur]) + !disableAcceptOnBlur && handleAccept(value) + }, [onBlur, handleAccept, value, disableAcceptOnBlur]) // Handles special key presses const handleKeyDown = useCallback((event) => { @@ -166,15 +198,15 @@ export const InputText = React.memo(({ // or if menu is not open submit the value. if (event.key === 'Enter') { if (open && highlightRef.current) { - onSelect?.(getOptionLabel(highlightRef.current).trim()) + handleSelect?.(getOptionLabel(highlightRef.current).trim()) } else { - onAccept?.(value && value.trim()) + handleAccept(value && value.trim()) } event.stopPropagation() event.preventDefault() setOpen(false) } - }, [open, suggestions, onSelect, onAccept, value, getOptionLabel, clearInputValue, highlightRef]) + }, [open, suggestions, handleSelect, handleAccept, value, getOptionLabel, clearInputValue, highlightRef]) // Handle input events. Errors are cleaned in input change, regular typing // emits onChange, selection with mouse emits onSelect. @@ -183,12 +215,12 @@ export const InputText = React.memo(({ onError && onError(undefined) if (event) { if (reason === 'reset') { - onSelect?.(value) + handleSelect?.(value) } else { onChange?.(value) } } - }, [onChange, onSelect, onError]) + }, [onChange, handleSelect, onError]) return <div className={clsx(className, styles.root)}> <Autocomplete @@ -303,6 +335,8 @@ InputText.propTypes = { disableClearable: PropTypes.bool, autoHighlight: PropTypes.bool, disableAcceptOnBlur: PropTypes.bool, + validate: PropTypes.func, // Function that can be used to validate the input + disableValidateOnSelect: PropTypes.bool, // Whether validation on selecting autocompletion value should be disabled suggestAllOnFocus: PropTypes.bool, // Whether to provide all suggestion values when input is focused showOpenSuggestions: PropTypes.bool, // Whether to show button for opening suggestions className: PropTypes.string, diff --git a/gui/src/components/search/menus/FilterSubMenuCustomQuantities.js b/gui/src/components/search/menus/FilterSubMenuCustomQuantities.js index a7c7bff626715d8af28b9dc7886d0ec1bd179ecc..bff2e151f9b0344d92a7098e57026358ee4cba21 100644 --- a/gui/src/components/search/menus/FilterSubMenuCustomQuantities.js +++ b/gui/src/components/search/menus/FilterSubMenuCustomQuantities.js @@ -40,7 +40,7 @@ import { StringEditQuantity } from '../../editQuantity/StringEditQuantity' import { EnumEditQuantity } from '../../editQuantity/EnumEditQuantity' import { DateTimeEditQuantity, DateEditQuantity } from '../../editQuantity/DateTimeEditQuantity' import { editQuantityComponents } from '../../editQuantity/EditQuantity' -import { InputMetainfo } from '../../search/input/InputMetainfo' +import { InputMetainfoControlled } from '../../search/input/InputMetainfo' import { DType, getDatatype, rsplit, parseQuantityName } from '../../../utils' import { InputTextField } from '../input/InputText' @@ -192,7 +192,7 @@ const QuantityFilter = React.memo(({quantities, filter, onChange}) => { return (<React.Fragment> <Box display="flex" flexWrap="wrap" flexDirection="row" alignItems="flex-start" marginTop={1}> <Box marginBottom={1} width="100%"> - <InputMetainfo + <InputMetainfoControlled options={options} value={id} onChange={handleIdChange} diff --git a/gui/src/components/search/widgets/Dashboard.js b/gui/src/components/search/widgets/Dashboard.js index 3b4b8b711ebed871aacffd3e3ae1536f20f492f1..a33c766b7041af5fa902f6683c95f29bc0d2c476 100644 --- a/gui/src/components/search/widgets/Dashboard.js +++ b/gui/src/components/search/widgets/Dashboard.js @@ -29,6 +29,7 @@ import { DialogTitle, withStyles } from '@material-ui/core' +import { isArray, cloneDeep } from 'lodash' import CodeIcon from '@material-ui/icons/Code' import ExpandMoreIcon from '@material-ui/icons/ExpandMore' import ExpandLessIcon from '@material-ui/icons/ExpandLess' @@ -37,13 +38,13 @@ import ReplayIcon from '@material-ui/icons/Replay' import WidgetGrid from './WidgetGrid' import { Actions, Action } from '../../Actions' import { useSearchContext } from '../SearchContext' -import { WidgetScatterPlotEdit, schemaWidgetScatterPlot } from './WidgetScatterPlot' +import { WidgetScatterPlotEdit, schemaWidgetScatterPlot } from './WidgetScatterPlotEdit' import { WidgetHistogramEdit, schemaWidgetHistogram } from './WidgetHistogram' import { WidgetTermsEdit, schemaWidgetTerms } from './WidgetTerms' import { WidgetPeriodicTableEdit, schemaWidgetPeriodicTable } from './WidgetPeriodicTable' import InputConfig from '../input/InputConfig' +import { cleanse } from '../../../utils' import Markdown from '../../Markdown' -import { isArray } from 'lodash' import { getWidgetsObject } from './Widget' import { ContentButton } from '../../buttons/SourceDialogButton' @@ -252,7 +253,7 @@ const Dashboard = React.memo(() => { {widgets && Object.entries(widgets).map(([id, value]) => { if (!value.editing) return null const comp = { - scatterplot: <WidgetScatterPlotEdit key={id} {...value}/>, + scatterplot: <WidgetScatterPlotEdit key={id} widget={value}/>, periodictable: <WidgetPeriodicTableEdit key={id} {...value}/>, histogram: <WidgetHistogramEdit key={id} {...value}/>, terms: <WidgetTermsEdit key={id} {...value}/> @@ -290,8 +291,7 @@ const schemas = { * A button that displays a dialog that can be used to modify the current * dashboard setup. */ -export const DashboardExportButton = React.memo((props) => { - const {tooltip, title, DialogProps, ButtonProps} = props +export const DashboardExportButton = React.memo(({tooltip, title, DialogProps, ButtonProps}) => { const {useWidgetsState} = useSearchContext() const [widgets, setWidgets] = useWidgetsState() const [widgetExport, setWidgetExport] = useState() @@ -302,11 +302,19 @@ export const DashboardExportButton = React.memo((props) => { // The widget export data. Only reacts if the menu is open. useEffect(() => { if (!open) return + // Work on a copy: data in Recoil is unmutable + const widgetsExport = cloneDeep(widgets) + const exp = Object - .values(widgets) + .values(widgetsExport) .map((widget) => { const schema = schemas[widget.type] const casted = schema?.cast(widget, {stripUnknown: true}) + + // Remove undefined values. YUP cannot do this, and the YAML + // serialization will otherwise include these. + cleanse(casted) + // Perform custom sort: type first, layout last const sorted = {} if (casted['type']) sorted['type'] = casted['type'] diff --git a/gui/src/components/search/widgets/Dashboard.spec.js b/gui/src/components/search/widgets/Dashboard.spec.js index 848e8f476f47fd430f673f522e4e93f1cd167d8b..1ce25ff33819efea48a856e2ddf50faf3f96de96 100644 --- a/gui/src/components/search/widgets/Dashboard.spec.js +++ b/gui/src/components/search/widgets/Dashboard.spec.js @@ -89,9 +89,9 @@ describe('displaying an initial widget and removing it', () => { type: 'scatterplot', label: 'Test label', description: 'Custom scatter plot', - x: 'results.properties.optoelectronic.solar_cell.open_circuit_voltage', - y: 'results.properties.optoelectronic.solar_cell.efficiency', - color: 'results.properties.optoelectronic.solar_cell.short_circuit_current_density', + x: {quantity: 'results.properties.optoelectronic.solar_cell.open_circuit_voltage'}, + y: {quantity: 'results.properties.optoelectronic.solar_cell.efficiency'}, + markers: {color: {quantity: 'results.properties.optoelectronic.solar_cell.short_circuit_current_density'}}, size: 1000, autorange: true, editing: false, diff --git a/gui/src/components/search/widgets/Widget.js b/gui/src/components/search/widgets/Widget.js index 0eb1da5224b56fdd27d6ba979c536c003091933d..38d9aaf83a5522ba2a0f67267c8402f08c2802e6 100644 --- a/gui/src/components/search/widgets/Widget.js +++ b/gui/src/components/search/widgets/Widget.js @@ -18,7 +18,7 @@ import React, { useCallback, useMemo } from 'react' import clsx from 'clsx' import PropTypes from 'prop-types' -import { cloneDeep } from 'lodash' +import { cloneDeep, isNil } from 'lodash' import { object, string, number } from 'yup' import { makeStyles } from '@material-ui/core/styles' import { Edit } from '@material-ui/icons' @@ -88,7 +88,7 @@ export const schemaLayout = object({ 'is-integer-or-infinity', // eslint-disable-next-line no-template-curly-in-string '${path} is not a valid integer number', - (value, context) => Number.isInteger(value) || value === Infinity + (value, context) => Number.isInteger(value) || value === Infinity || isNil(value) ), y: number().integer(), w: number().integer(), @@ -109,6 +109,19 @@ export const schemaWidget = object({ editing: string().strip(), visible: string().strip() }) +export const schemaAxis = object({ + quantity: string().required(), + unit: string().nullable(), + title: string().nullable() +}) +export const schemaAxisOptional = object({ + quantity: string().nullable(), + unit: string().nullable(), + title: string().nullable() +}) +export const schemaMarkers = object({ + color: schemaAxisOptional +}) /** * Transforms a list of widgets into the internal object representation. diff --git a/gui/src/components/search/widgets/WidgetEdit.js b/gui/src/components/search/widgets/WidgetEdit.js index 706e7b612d505edae39e8e2d922c25f8af537d99..6d5474579bf85c17f33def7b7d60be3c186f2241 100644 --- a/gui/src/components/search/widgets/WidgetEdit.js +++ b/gui/src/components/search/widgets/WidgetEdit.js @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, {useCallback, useState} from 'react' +import React, {useCallback} from 'react' import PropTypes from 'prop-types' import { List, @@ -26,14 +26,11 @@ import { DialogContent, DialogTitle, makeStyles, - withStyles, - Typography, - Accordion as MuiAccordion, - AccordionDetails as MuiAccordionDetails, - AccordionSummary as MuiAccordionSummary + MenuItem, + TextField, + Typography } from '@material-ui/core' import { useSearchContext } from '../SearchContext' -import ExpandMoreIcon from '@material-ui/icons/ExpandMore' /** * A dialog that is used to configure a widget. @@ -47,39 +44,46 @@ export const WidgetEditDialog = React.memo(({id, title, open, visible, onAccept, const styles = useStyles() const { useRemoveWidget } = useSearchContext() const removeWidget = useRemoveWidget() - const handleClose = useCallback(() => { - // If the widget has not bee visualized, then closing the dialog deletes - // the widget completely. - onClose && onClose() - if (!visible) { - removeWidget(id) - } - }, [onClose, removeWidget, id, visible]) - const handleAccept = useCallback(() => { - onAccept && onAccept() - }, [onAccept]) + const handleClose = useCallback((event, reason) => { + // Do not close dialog on backdrop click: user may lose lot of filled info + // accidentally + if (reason === 'backdropClick') { + return + } - return <Dialog - fullWidth={true} - maxWidth="sm" - open={open} - classes={{paperWidthSm: styles.width}} - onClose={handleClose} - > - <DialogTitle>{title || ''}</DialogTitle> - <DialogContent> - {children} - </DialogContent> - <DialogActions> - <Button onClick={handleClose} color="primary"> - Cancel - </Button> - <Button disabled={!!error} onClick={handleAccept} color="primary"> - Done - </Button> - </DialogActions> - </Dialog> + // If the widget has not bee visualized, then closing the dialog deletes + // the widget completely. + onClose && onClose() + if (!visible) { + removeWidget(id) + } + }, [id, onClose, removeWidget, visible]) + + const handleAccept = useCallback(() => { + onAccept && onAccept() + }, [onAccept]) + + return <Dialog + fullWidth={true} + maxWidth="sm" + open={open} + classes={{paperWidthSm: styles.width}} + onClose={handleClose} + > + <DialogTitle>{title || ''}</DialogTitle> + <DialogContent> + {children} + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary"> + Cancel + </Button> + <Button disabled={!!error} onClick={handleAccept} color="primary"> + Done + </Button> + </DialogActions> + </Dialog> }) WidgetEditDialog.propTypes = { @@ -91,47 +95,12 @@ WidgetEditDialog.propTypes = { onAccept: PropTypes.func, error: PropTypes.bool, children: PropTypes.node + } /** * A group of options in an edit dialog. */ - -const Accordion = withStyles((theme) => ({ - root: { - boxShadow: 'none', - margin: 0, - '&$expanded': { - margin: 0 - } - }, - expanded: { - margin: 0 - } -}))(MuiAccordion) - -const AccordionDetails = withStyles((theme) => ({ - root: { - padding: theme.spacing(0) - } -}))(MuiAccordionDetails) - -const AccordionSummary = withStyles((theme) => ({ - root: { - minHeight: 24, - padding: 0, - '&$expanded': { - minHeight: 24 - } - }, - content: { - '&$expanded': { - margin: '0px 0' - } - }, - expanded: {} -}))(MuiAccordionSummary) - const useEditGroupStyles = makeStyles((theme) => ({ heading: { color: theme.palette.primary.main @@ -142,23 +111,13 @@ const useEditGroupStyles = makeStyles((theme) => ({ })) export const WidgetEditGroup = React.memo(({title, children}) => { const styles = useEditGroupStyles() - const [expanded, setExpanded] = useState(true) - return <Accordion expanded={expanded} onChange={() => { setExpanded(old => !old) }}> - <AccordionSummary - expandIcon={<ExpandMoreIcon />} - IconButtonProps={{ - size: "small" - }} - > - <Typography variant="button" className={styles.heading}>{title}</Typography> - </AccordionSummary> - <AccordionDetails> - <List dense className={styles.list}> - {children} - </List> - </AccordionDetails> - </Accordion> + return <> + <Typography variant="button" className={styles.heading}>{title}</Typography> + <List dense className={styles.list}> + {children} + </List> + </> }) WidgetEditGroup.propTypes = { @@ -178,3 +137,30 @@ export const WidgetEditOption = React.memo(({children}) => { WidgetEditOption.propTypes = { children: PropTypes.node } + +/** + * Select (=dropdown) component for widget edit. + */ +export const WidgetEditSelect = React.memo(({label, disabled, options, value, onChange}) => { + return <TextField + select + fullWidth + label={label} + variant="filled" + value={value || ''} + onChange={onChange} + disabled={disabled} + > + {Object.entries(options).map(([key, value]) => + <MenuItem value={value} key={key}>{key}</MenuItem> + )} + </TextField> +}) + +WidgetEditSelect.propTypes = { + label: PropTypes.string, + disabled: PropTypes.bool, + options: PropTypes.object, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + onChange: PropTypes.func +} diff --git a/gui/src/components/search/widgets/WidgetGrid.js b/gui/src/components/search/widgets/WidgetGrid.js index 8dc16bffca0983c26a1db39dffc94bb03342bbfd..f3b2dcd5831b3613b64e44d6ddd7d312f5f3f9c1 100644 --- a/gui/src/components/search/widgets/WidgetGrid.js +++ b/gui/src/components/search/widgets/WidgetGrid.js @@ -291,7 +291,7 @@ const WidgetGrid = React.memo(({ } }, [width]) - // The layouts are stored when they are changed. This allows retaining the the + // The layouts are stored when they are changed. This allows retaining the // layout for each breakpoint individually. Notice that we need to feed in the // changed layout back to the component so that it works in a controlled // manner. diff --git a/gui/src/components/search/widgets/WidgetHistogram.js b/gui/src/components/search/widgets/WidgetHistogram.js index 3f2d2d8eb3362f7122b1cc067cbfda6f15d6c6b1..34aeab59b0dd6fa1e98af0f45ac5e100f8baff7a 100644 --- a/gui/src/components/search/widgets/WidgetHistogram.js +++ b/gui/src/components/search/widgets/WidgetHistogram.js @@ -25,7 +25,7 @@ import { FormControlLabel } from '@material-ui/core' import { useSearchContext } from '../SearchContext' -import { InputSearchMetainfo } from '../input/InputMetainfo' +import { InputMetainfo } from '../input/InputMetainfo' import { Widget, schemaWidget } from './Widget' import { ActionCheckbox, ActionSelect } from '../../Actions' import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption } from './WidgetEdit' @@ -166,7 +166,7 @@ export const WidgetHistogramEdit = React.memo((props) => { > <WidgetEditGroup title="x axis"> <WidgetEditOption> - <InputSearchMetainfo + <InputMetainfo label="quantity" value={settings.quantity} error={errors.quantity} diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.js b/gui/src/components/search/widgets/WidgetScatterPlot.js index a7ed2946814ee4cc54e7b5a0d7bda707ea4aea50..87d641ec7b4decba864494d5bc6d8f7760a33c74 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlot.js +++ b/gui/src/components/search/widgets/WidgetScatterPlot.js @@ -17,21 +17,20 @@ */ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import PropTypes from 'prop-types' -import { string, number, bool } from 'yup' -import { isEmpty } from 'lodash' +import { number, bool, reach } from 'yup' +import jmespath from 'jmespath' +import { isEmpty, isArray, isEqual, cloneDeep, range, isNil, flattenDeep } from 'lodash' import { Divider, - TextField, - MenuItem, Tooltip, makeStyles, Checkbox, FormControlLabel } from '@material-ui/core' -import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab' -import { InputSearchMetainfo } from '../input/InputMetainfo' -import { Widget, schemaWidget } from './Widget' -import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption } from './WidgetEdit' +import { ToggleButton, ToggleButtonGroup, Alert } from '@material-ui/lab' +import { InputJMESPath } from '../input/InputMetainfo' +import { Widget, schemaWidget, schemaAxis, schemaMarkers } from './Widget' +import { WidgetEditDialog, WidgetEditGroup, WidgetEditOption, WidgetEditSelect } from './WidgetEdit' import { useSearchContext } from '../SearchContext' import Floatable from '../../visualization/Floatable' import PlotScatter from '../../plotting/PlotScatter' @@ -39,15 +38,21 @@ import { Action, ActionCheckbox } from '../../Actions' import { CropFree, PanTool, Fullscreen, Replay } from '@material-ui/icons' import { autorangeDescription } from './WidgetHistogram' import { styled } from '@material-ui/core/styles' -import { DType } from '../../../utils' +import { DType, setDeep, parseJMESPath } from '../../../utils' import { Quantity } from '../../units/Quantity' import { Unit } from '../../units/Unit' import { useUnitContext } from '../../units/UnitContext' +import { InputTextField } from '../input/InputText' +import UnitInput from '../../units/UnitInput' // Predefined in order to not break memoization const dtypesNumeric = new Set([DType.Int, DType.Float]) const dtypesColor = new Set([DType.String, DType.Enum, DType.Float, DType.Int]) -const dtypesColorRepeatable = new Set([DType.String, DType.Enum]) +const nPointsOptions = { + 100: 100, + 1000: 1000, + 10000: 10000 +} const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ '& .MuiToggleButtonGroup-grouped': { @@ -75,6 +80,9 @@ const useStyles = makeStyles((theme) => ({ }, divider: { margin: theme.spacing(0.5, 0.5) + }, + alert: { + overflow: 'auto' } })) @@ -85,7 +93,7 @@ export const WidgetScatterPlot = React.memo(( description, x, y, - color, + markers, size, autorange, dragmode, @@ -98,29 +106,67 @@ export const WidgetScatterPlot = React.memo(( const [float, setFloat] = useState(false) const [loading, setLoading] = useState(true) const { useSetWidget, useHits, filterData, useSetFilter } = useSearchContext() - const setXFilter = useSetFilter(x) - const setYFilter = useSetFilter(y) - const discrete = useMemo(() => { - return new Set([DType.String, DType.Enum]).has(filterData[color]?.dtype) - }, [filterData, color]) - const filterX = filterData[x] - const filterY = filterData[y] - const filterColor = filterData[color] - const unitX = filterX?.unit || 'dimensionless' - const unitY = filterY?.unit || 'dimensionless' - const unitColor = filterColor?.unit + + // Parse additional JMESPath config + const [xParsed, yParsed, colorParsed, error] = useMemo(() => { + const xParsed = parseJMESPath(x?.quantity) + const yParsed = parseJMESPath(y?.quantity) + const colorParsed = markers?.color?.quantity ? parseJMESPath(markers.color.quantity) : {} + if (xParsed.error || yParsed.error || colorParsed.error) { + return [{}, {}, {}, 'Invalid JMESPath query, please check your syntax.'] + } + return [xParsed, yParsed, colorParsed, undefined] + }, [markers?.color?.quantity, x?.quantity, y?.quantity]) + + // Parse units + const {unitXObj, unitYObj, unitColorObj, displayUnitX, displayUnitY, displayUnitColor, discrete} = useMemo(() => { + if (error) return {} + const unitXObj = new Unit(filterData[xParsed.quantity].unit || 'dimensionless') + const unitYObj = new Unit(filterData[yParsed.quantity].unit || 'dimensionless') + const unitColorObj = new Unit(filterData[colorParsed?.quantity]?.unit || 'dimensionless') + const displayUnitX = x.unit ? new Unit(x.unit) : unitXObj.toSystem(units) + const displayUnitY = y.unit ? new Unit(y.unit) : unitYObj.toSystem(units) + const displayUnitColor = markers?.color?.unit ? new Unit(markers?.color?.unit) : unitColorObj.toSystem(units) + const discrete = colorParsed?.quantity && new Set([DType.String, DType.Enum]).has(filterData[colorParsed.quantity]?.dtype) + return {unitXObj, unitYObj, unitColorObj, displayUnitX, displayUnitY, displayUnitColor, discrete} + }, [filterData, x.unit, xParsed.quantity, y.unit, yParsed.quantity, markers?.color?.unit, colorParsed?.quantity, units, error]) + + // Create final axis config for the plot + const {xAxis, yAxis, colorAxis} = useMemo(() => { + if (error) return {} + const xTitle = x.title || filterData[xParsed.quantity]?.label + const yTitle = y.title || filterData[yParsed.quantity]?.label + const colorTitle = markers?.color?.title || filterData[colorParsed.quantity]?.label + const unitLabelX = displayUnitX.label() + const unitLabelY = displayUnitY.label() + const unitLabelColor = displayUnitColor.label() + return { + xAxis: {...x, ...xParsed, title: xTitle, unit: unitLabelX}, + yAxis: {...y, ...yParsed, title: yTitle, unit: unitLabelY}, + colorAxis: markers?.color ? {...markers.color, ...colorParsed, title: colorTitle, unit: unitLabelColor} : {} + } + }, [colorParsed, displayUnitColor, displayUnitX, displayUnitY, filterData, markers?.color, x, xParsed, y, yParsed, error]) + + const setXFilter = useSetFilter(xParsed.quantity) + const setYFilter = useSetFilter(yParsed.quantity) + const setWidget = useSetWidget(id) const pagination = useMemo(() => ({ page_size: size, order: 'asc' }), [size]) const required = useMemo(() => { - const include = ['entry_id'] - !isEmpty(x) && include.push(x) - !isEmpty(y) && include.push(y) - !isEmpty(color) && include.push(color) - return {include} - }, [x, y, color]) + const include = new Set(['entry_id']) + for (const config of [xParsed, yParsed, colorParsed]) { + if (!isEmpty(config)) { + include.add(config.quantity) + for (const extra of config.extras) { + include.add(extra) + } + } + } + return {include: [...include]} + }, [xParsed, yParsed, colorParsed]) useEffect(() => { setLoading(true) @@ -129,7 +175,126 @@ export const WidgetScatterPlot = React.memo(( const hitsCallback = useCallback(() => { setLoading(false) }, []) + + // Fetch the data using the useHits hook that automatically applies the + // existing filters. We filter out data that is invalid (e.g. no values or + // incompatible sizes between x/y/color). TODO: The API should support an + // "exists" query that could be used to return hits that actually have the + // requested values. This way we would get a better match for the query size + // and we would not need to manually validate and check the results. const hits = useHits(id, required, pagination, hitsCallback) + const dataRaw = useMemo(() => { + if (!hits || error) return + function getData(hit) { + const hitData = {} + + // Get each property using JMESPath. Errors at this stage will simply + // cause the entry to be ignored. + for (const [name, path] of [['x', xParsed.path], ['y', yParsed.path], ['color', colorParsed.path]]) { + if (isEmpty(path)) continue + let value + try { + value = jmespath.search(hit, path) + } catch (e) { + return {error: 'Invalid JMESPATH'} + } + // Missing x/y/color value will cause an error unless dealing with + // discretized colors + if (isNil(value)) { + if (name === 'color' && discrete) { + value = 'undefined' + } else { + return {error: 'Empty value'} + } + } + hitData[name] = value + } + + // Get the shapes + const xShape = getShape(hitData.x) + const yShape = getShape(hitData.y) + + // Check if x/y leaf shapes match + if (xShape[xShape.length - 1] !== yShape[yShape.length - 1]) { + return {error: 'Incompatible size for x/y'} + } + + // If x/y shapes do not match, extend accordingly + const biggestShape = [xShape, yShape].reduce((prev, current) => { + return (prev.length > current.length) + ? prev + : current + }) + hitData.x = extendFront(hitData.x, xShape, biggestShape) + hitData.y = extendFront(hitData.y, yShape, biggestShape) + + // Modify color dimensions + let colorShape = colorParsed.path && getShape(hitData.color) + if (colorShape && !isEqual(colorShape, biggestShape)) { + // If color has one more dimension than other arrays and it is discrete, + // we reduce the last dimension to a single string + if (discrete && colorShape.length === biggestShape.length + 1) { + hitData.color = reduceInner(hitData.color) + colorShape = colorShape.slice(0, -1) + } + // Scalar color values are extended + if (colorShape.length === 0 || (colorShape.length === 1 && colorShape[0] === 1)) { + hitData.color = fill( + biggestShape, + colorShape.length === 0 + ? hitData.color + : hitData.color[0] + ) + // Colors are extended according to traces + } else if ((colorShape.length < biggestShape.length) && colorShape[0] === biggestShape[0]) { + hitData.color = extendBack(hitData.color, colorShape, biggestShape) + } else { + return {error: 'Incompatible size for color'} + } + } + + // Flatten arrays + hitData.x = flatten(hitData.x) + hitData.y = flatten(hitData.y) + hitData.color = colorParsed.path && flatten(hitData.color) + + // If shapes still don't match, skip entry. TODO: This check is not ideal, + // since we may be accepting accidentally mathing sizes. A proper shape + // check that would also allow "ragged arrays" would be better. + if (hitData.x.length !== hitData.y.length || (colorParsed.path && hitData.x.length !== hitData.color.length)) { + return {error: 'Incompatible number of elements'} + } + + return {hitData, nPoints: hitData.x.length} + } + const x = [] + const y = [] + const color = colorParsed.path ? [] : undefined + const id = [] + for (const hit of hits) { + const {hitData, error, nPoints} = getData(hit) + if (error || !nPoints) continue + for (const i of range(nPoints)) { + x.push(hitData.x[i]) + y.push(hitData.y[i]) + colorParsed.path && color.push(hitData.color?.[i]) + id.push(hit.entry_id) + } + } + return {x, y, color, id} + }, [discrete, hits, xParsed.path, yParsed.path, colorParsed.path, error]) + + // Perform unit conversion, report errors + const data = useMemo(() => { + if (!dataRaw) return + const x = new Quantity(dataRaw.x, unitXObj).to(displayUnitX).value() + const y = new Quantity(dataRaw.y, unitYObj).to(displayUnitY).value() + const color = dataRaw.color && (discrete + ? dataRaw.color + : new Quantity(dataRaw.color, unitColorObj).to(displayUnitColor).value() + ) + return {x, y, color, id: dataRaw.id} + }, [dataRaw, displayUnitColor, displayUnitX, displayUnitY, unitColorObj, unitXObj, unitYObj, discrete]) const handleEdit = useCallback(() => { setWidget(old => { return {...old, editing: true } }) @@ -154,24 +319,21 @@ export const WidgetScatterPlot = React.memo(( const handleSelected = useCallback((data) => { const range = data?.range - if (range) { - const unitXConverted = new Unit(unitX).toSystem(units) - const unitYConverted = new Unit(unitY).toSystem(units) - setXFilter({ - gte: new Quantity(range.x[0], unitXConverted), - lte: new Quantity(range.x[1], unitXConverted) - }) - setYFilter({ - gte: new Quantity(range.y[0], unitYConverted), - lte: new Quantity(range.y[1], unitYConverted) - }) - onSelected && onSelected(data) - } - }, [onSelected, setXFilter, setYFilter, unitX, unitY, units]) + if (!range) return + setXFilter({ + gte: new Quantity(range.x[0], displayUnitX), + lte: new Quantity(range.x[1], displayUnitX) + }) + setYFilter({ + gte: new Quantity(range.y[0], displayUnitY), + lte: new Quantity(range.y[1], displayUnitY) + }) + onSelected?.(data) + }, [onSelected, setXFilter, setYFilter, displayUnitX, displayUnitY]) - const handleDeselect = () => { - onSelected && onSelected(undefined) - } + const handleDeselect = useCallback(() => { + onSelected?.(undefined) + }, [onSelected]) const actions = useMemo(() => { return <> @@ -226,23 +388,25 @@ export const WidgetScatterPlot = React.memo(( actions={actions} className={styles.widget} > - <PlotScatter - data={loading ? undefined : hits} - x={x} - y={y} - color={color} - unitX={unitX} - unitY={unitY} - unitColor={unitColor} - discrete={discrete} - autorange={autorange} - onSelected={handleSelected} - onDeselect={handleDeselect} - dragmode={dragmode} - onNavigateToEntry={handleNavigated} - data-testid={id} - ref={canvas} - /> + {error + ? <Alert severity="error" className={styles.alert}> + {error} + </Alert> + : <PlotScatter + data={loading ? undefined : data} + xAxis={xAxis} + yAxis={yAxis} + colorAxis={colorAxis} + discrete={discrete} + autorange={autorange} + onSelected={handleSelected} + onDeselect={handleDeselect} + dragmode={dragmode} + onNavigateToEntry={handleNavigated} + data-testid={id} + ref={canvas} + /> + } </Widget> </Floatable> }) @@ -251,9 +415,9 @@ WidgetScatterPlot.propTypes = { id: PropTypes.string.isRequired, label: PropTypes.string, description: PropTypes.string, - x: PropTypes.string, - y: PropTypes.string, - color: PropTypes.string, + x: PropTypes.object, + y: PropTypes.object, + markers: PropTypes.object, size: PropTypes.number, autorange: PropTypes.bool, dragmode: PropTypes.string, @@ -264,110 +428,168 @@ WidgetScatterPlot.propTypes = { /** * A dialog that is used to configure a scatter plot widget. */ -export const WidgetScatterPlotEdit = React.memo((props) => { - const { id, editing, visible } = props - const { useSetWidget } = useSearchContext() - const [settings, setSettings] = useState(props) +export const WidgetScatterPlotEdit = React.memo(({widget}) => { + const { filterData, useSetWidget } = useSearchContext() + const [settings, setSettings] = useState(cloneDeep(widget)) const [errors, setErrors] = useState({}) - const setWidget = useSetWidget(id) - const hasError = useMemo(() => { - return Object.values(errors).some((d) => !!d) || !schemaWidgetScatterPlot.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 [dimensions, setDimensions] = useState({}) + const setWidget = useSetWidget(widget.id) 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 handleChange = useCallback((key, value) => { + setSettings(old => { + const newValue = {...old} + setDeep(newValue, key, value) + return newValue + }) + }, [setSettings]) + const handleClose = useCallback(() => { setWidget(old => ({...old, editing: false})) }, [setWidget]) const handleAccept = useCallback((key, value) => { try { - schemaWidgetScatterPlot.validateSyncAt(key, {[key]: value}) + reach(schemaWidgetScatterPlot, key).validateSync(value) } catch (e) { handleError(key, e.message) return } setErrors(old => ({...old, [key]: undefined})) - setSettings(old => ({...old, [key]: value})) - }, [handleError, setSettings]) + 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]) + + // Upon accepting the entire form, we perform final validation that also + // takes into account cross-field incompatibilities const handleEditAccept = useCallback(() => { - handleSubmit({...settings, editing: false, visible: true}) - }, [handleSubmit, settings]) + // Check for independent errors from components + const independentErrors = Object.values(errors).some(x => !!x) + if (!independentErrors) { + setWidget(old => ({...old, ...{...settings, editing: false, visible: true}})) + } + }, [settings, setWidget, errors]) return <WidgetEditDialog - id={id} - open={editing} - visible={visible} + id={widget.id} + open={widget.editing} + visible={widget.visible} title="Edit scatter plot widget" onClose={handleClose} onAccept={handleEditAccept} - error={hasError} > <WidgetEditGroup title="x axis"> <WidgetEditOption> - <InputSearchMetainfo + <InputJMESPath label="quantity" - value={settings.x} - error={errors.x} - onChange={(value) => handleChange('x', value)} - onSelect={(value) => handleAccept('x', value)} - onError={(value) => handleError('x', value)} + value={settings.x?.quantity} + onChange={(value) => handleChange('x.quantity', value)} + onSelect={(value) => handleAcceptQuantity('x.quantity', value)} + onAccept={(value) => handleAcceptQuantity('x.quantity', value)} + error={errors['x.quantity']} + onError={(value) => handleErrorQuantity('x.quantity', value)} dtypes={dtypesNumeric} + dtypesRepeatable={dtypesNumeric} + /> + </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="y axis"> <WidgetEditOption> - <InputSearchMetainfo + <InputJMESPath label="quantity" - value={settings.y} - error={errors.y} - onChange={(value) => handleChange('y', value)} - onSelect={(value) => handleAccept('y', value)} - onError={(value) => handleError('y', value)} + value={settings.y?.quantity} + onChange={(value) => handleChange('y.quantity', value)} + onSelect={(value) => handleAcceptQuantity('y.quantity', value)} + onAccept={(value) => handleAcceptQuantity('y.quantity', value)} + error={errors['y.quantity']} + onError={(value) => handleErrorQuantity('y.quantity', value)} dtypes={dtypesNumeric} + dtypesRepeatable={dtypesNumeric} + /> + </WidgetEditOption> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings.y?.title} + onChange={(event) => handleChange('y.title', event.target.value)} + /> + </WidgetEditOption> + <WidgetEditOption> + <UnitInput + label='unit' + value={settings.y?.unit} + onChange={(value) => handleChange('y.unit', value)} + onSelect={(value) => handleAccept('y.unit', value)} + onAccept={(value) => handleAccept('y.unit', value)} + error={errors['y.unit']} + onError={(value) => handleError('y.unit', value)} + dimension={dimensions['y.quantity'] || null} + optional + disableGroup /> </WidgetEditOption> </WidgetEditGroup> - <WidgetEditGroup title="color"> + <WidgetEditGroup title="marker color"> <WidgetEditOption> - <InputSearchMetainfo + <InputJMESPath label="quantity" - value={settings.color} - error={errors.color} - onChange={(value) => handleChange('color', value)} - onSelect={(value) => handleAccept('color', value)} - onError={(value) => handleError('color', value)} + value={settings?.markers?.color?.quantity} + onChange={(value) => handleChange('markers.color.quantity', value)} + onSelect={(value) => handleAccept('markers.color.quantity', value)} + onAccept={(value) => handleAccept('markers.color.quantity', value)} + error={errors['markers.color.quantity']} + onError={(value) => handleError('markers.color.quantity', value)} dtypes={dtypesColor} - dtypesRepeatable={dtypesColorRepeatable} + dtypesRepeatable={dtypesColor} + optional /> </WidgetEditOption> </WidgetEditGroup> <WidgetEditGroup title="general"> <WidgetEditOption> - <TextField - select - fullWidth - label="Maximum number of points" - variant="filled" + <WidgetEditSelect + label="Maximum number of entries to load" + options={nPointsOptions} value={settings.size} onChange={(event) => { handleChange('size', event.target.value) }} - > - <MenuItem value={100}>100</MenuItem> - <MenuItem value={1000}>1000</MenuItem> - <MenuItem value={10000}>10000</MenuItem> - </TextField> + /> </WidgetEditOption> <WidgetEditOption> <FormControlLabel @@ -380,21 +602,125 @@ export const WidgetScatterPlotEdit = React.memo((props) => { }) WidgetScatterPlotEdit.propTypes = { - id: PropTypes.string.isRequired, - editing: PropTypes.bool, - visible: PropTypes.bool, - x: PropTypes.string, - y: PropTypes.string, - color: PropTypes.string, - size: PropTypes.number, - autorange: PropTypes.bool, - onClose: PropTypes.func + widget: PropTypes.object } export const schemaWidgetScatterPlot = schemaWidget.shape({ - x: string().required('Quantity for the x axis is required.'), - y: string().required('Quantity for the y axis is required.'), - color: string(), + x: schemaAxis.required('Quantity for the x axis is required.'), + y: schemaAxis.required('Quantity for the y axis is required.'), + markers: schemaMarkers, size: number().integer().required('Size is required.'), autorange: bool() }) + +/** + * Used to flatten the input into a single array of values. + */ +function flatten(input) { + return isArray(input) + ? flattenDeep(input) + : [input] +} + +/** + * Gets the shape of an abitrarily nested array. + */ +function getShape(input) { + if (!isArray(input)) { + return [] + } + + const shape = [] + let inner = input + + while (isArray(inner)) { + shape.push(inner.length) + inner = inner[0] + } + + return shape +} + +/** + * Reduces the innermost dimension into a single value. + */ +function reduceInner(input) { + function reduceRec(inp) { + if (isArray(inp)) { + if (isArray(inp[0])) { + for (let i = 0; i < inp.length; ++i) { + inp[i] = reduceRec(inp[i]) + } + } else { + return inp.sort().join(", ") + } + } + return inp + } + return reduceRec(input) +} + +/** + * Resizes the given array to a new size by extending the data to fit the front + * dimensions (similar to array = array[None, :] in NumPy). + */ +function extendFront(array, oldShape, newShape) { + // If shape is already correct, return the input array + const diff = newShape.length - oldShape.length + if (diff === 0) return array + + // Extend the array + const extendedArray = [] + function extendRec(depth) { + const dim = newShape[depth] + const hasData = depth === diff + if (hasData) { + return array + } else { + for (let j = 0; j < dim; ++j) { + extendedArray.push(extendRec(depth + 1)) + } + } + return extendedArray + } + extendRec(0) + return extendedArray +} + +/** + * Resizes the given array to a new size by extending the data to fit the last + * dimensions dimensions (similar to array = array[:, None] in NumPy). + */ +function extendBack(array, oldShape, newShape) { + // If shape is already correct, return the input array + const diff = newShape.length - oldShape.length + if (diff === 0) return array + + // Extend the array + const extendedArray = [] + const nTraces = newShape[0] + for (let i = 0; i < nTraces; ++i) { + const traceArray = [] + for (let j = 0; j < newShape[1]; ++j) { + traceArray.push([array[i]]) + } + extendedArray.push(traceArray) + } + return extendedArray +} + +/** + * Creates a new array with the given shape, filled with the given value. + */ +function fill(shape, fillValue) { + if (shape.length === 0) { + return fillValue + } else { + const innerShape = shape.slice(1) + const innerArray = [] + for (let i = 0; i < shape[0]; i++) { + innerArray.push(fill(innerShape, fillValue)) + } + return innerArray + } +} diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.spec.js b/gui/src/components/search/widgets/WidgetScatterPlot.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..36fd9c1f9e7ba5d91069ae9a948be0e818578efe --- /dev/null +++ b/gui/src/components/search/widgets/WidgetScatterPlot.spec.js @@ -0,0 +1,180 @@ +/* + * 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, { useMemo } from 'react' +import { screen } from '../../conftest.spec' +import { expectWidgetScatterPlot, renderSearchEntry } from '../conftest.spec' +import { WidgetScatterPlot } from './WidgetScatterPlot' + +// Mock the resize observer that defines the widget size +jest.mock('react-resize-detector', () => { + return {useResizeDetector: () => { + return {height: 400, width: 400, ref: undefined} + }} +}) + +// Mock the useHits hook that returns the plotted data. Note that the useHits +// hook needs to memo things to prevent rendering loops. +const mockUseMemo = useMemo +jest.mock('../SearchContext', () => ({ + ...jest.requireActual('../SearchContext'), + useSearchContext: () => ({ + ...jest.requireActual('../SearchContext').useSearchContext(), + useHits: (id, required, pagination, callback) => { + const response = mockUseMemo(() => { + callback() + return [ + { + entry_id: 0, + results: { + material: {n_elements: 1, elements: ['Si'], chemical_formula_hill: 'Si2'}, + properties: { + electronic: {band_gap: [{value: 0}, {value: 1}]}, + catalytic: { + reactivity: { + test_temperatures: [0, 1, 2], + reactants: [ + {name: 'CO2', conversion: [0, 1, 2]}, + {name: 'NO2', conversion: [3, 4, 5]} + ] + } + } + } + } + }, + { + entry_id: 1, + results: { + material: {n_elements: 1, elements: ['C', 'O'], chemical_formula_hill: 'CO2'}, + properties: { + electronic: {band_gap: [{value: 0}, {value: 1}]}, + catalytic: { + reactivity: { + test_temperatures: [0, 1, 2], + reactants: [ + {name: 'H2O', conversion: [0, 1, 2]}, + {name: 'O2', conversion: [3, 4, 5]} + ] + } + } + } + } + } + ] + }, []) + return response + } + }) +})) + +describe('test different combinations of x/y/color produced with JMESPath', () => { + test.each([ + ['scalar quantity', 'results.material.n_elements', 'results.material.n_elements', 'results.material.n_elements'], + ['index expression', 'results.properties.electronic.band_gap[0].value', 'results.properties.electronic.band_gap[0].value', 'results.properties.electronic.band_gap[0].value'], + ['slicing', 'results.properties.electronic.band_gap[1:2].value', 'results.properties.electronic.band_gap[1:2].value', 'results.properties.electronic.band_gap[1:2].value'], + ['function', 'min(results.properties.electronic.band_gap[*].value)', 'min(results.properties.electronic.band_gap[*].value)', 'min(results.properties.electronic.band_gap[*].value)'], + ['filter projection', "results.material.topology[?label=='original'].cell.a", "results.material.topology[?label=='original'].cell.a", "results.material.topology[?label=='original'].cell.a"], + ['1D array vs 2D array vs 1D color', 'results.properties.catalytic.reactivity.test_temperatures', 'results.properties.catalytic.reactivity.reactants[*].conversion', 'results.properties.catalytic.reactivity.reactants[*].name'] + ])('%s', async (name, x, y, color) => { + const config = { + id: '0', + scale: 'linear', + x: {quantity: x}, + y: {quantity: y}, + markers: {color: {quantity: color}} + } + renderSearchEntry(<WidgetScatterPlot {...config} />) + await expectWidgetScatterPlot(config, false) + }) +}) + +describe('test custom axis titles', () => { + test.each([ + ['x, no unit', {x: {title: 'My Title', quantity: 'results.material.n_elements'}}, 'My Title'], + ['y, no unit', {y: {title: 'My Title', quantity: 'results.material.n_elements'}}, 'My Title'], + ['color, no unit', {markers: {color: {title: 'My Title', quantity: 'results.material.n_elements'}}}, 'My Title'], + ['x, with unit', {x: {title: 'My Title', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}, 'My Title (eV)'], + ['y, with unit', {y: {title: 'My Title', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}, 'My Title (eV)'], + ['color, with unit', {markers: {color: {title: 'My Title', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}}, 'My Title (eV)'] + ])('%s', async (name, config, title) => { + const configFinal = { + id: '0', + scale: 'linear', + x: {quantity: 'results.material.n_elements'}, + y: {quantity: 'results.material.n_elements'}, + markers: {color: {quantity: 'results.material.n_elements'}}, + ...config + } + renderSearchEntry(<WidgetScatterPlot {...configFinal} />) + screen.getByText(title) + }) +}) + +describe('test custom axis units', () => { + test.each([ + ['x', {x: {unit: 'Ha', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}, 'Final Energy Difference (Ha)'], + ['y', {y: {unit: 'Ha', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}, 'Final Energy Difference (Ha)'], + ['color', {markers: {color: {unit: 'Ha', quantity: 'results.properties.geometry_optimization.final_energy_difference'}}}, 'Final Energy Difference (Ha)'] + ])('%s', async (name, config, title) => { + const configFinal = { + id: '0', + scale: 'linear', + x: {quantity: 'results.material.n_elements'}, + y: {quantity: 'results.material.n_elements'}, + markers: {color: {quantity: 'results.material.n_elements'}}, + ...config + } + renderSearchEntry(<WidgetScatterPlot {...configFinal} />) + screen.getByText(title) + }) +}) + +describe('test different colors', () => { + test.each([ + ['empty', undefined, undefined, []], + ['scalar integer', 'results.material.n_elements', {quantity: 'results.material.n_elements'}, []], + ['scalar float', 'results.properties.geometry_optimization.final_energy_difference', {quantity: 'results.properties.geometry_optimization.final_energy_difference'}, []], + ['scalar string', 'results.material.chemical_formula_hill', undefined, ['Si2', 'CO2']], + ['array string', 'results.material.elements', undefined, ['Si', 'C, O']] + ])('%s', async (name, axis, colorTitle, legend) => { + const config = { + id: '0', + scale: 'linear', + x: {quantity: 'results.material.n_elements'}, + y: {quantity: 'results.material.n_elements'}, + markers: {color: {quantity: axis}} + } + renderSearchEntry(<WidgetScatterPlot {...config} />) + await expectWidgetScatterPlot(config, false, colorTitle, legend) + }) +}) + +describe('test error messages', () => { + test.each([ + ['invalid JMESPath', 'results.properties.electronic.band_gap[*.value', 'Invalid JMESPath query, please check your syntax.'] + ])('%s', async (name, axis, message) => { + const config = { + id: '0', + scale: 'linear', + x: {quantity: axis}, + y: {quantity: axis}, + markers: {color: {quantity: axis}} + } + renderSearchEntry(<WidgetScatterPlot {...config} />) + screen.getByText(message, {exact: false}) + }) +}) diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js new file mode 100644 index 0000000000000000000000000000000000000000..383e7d27b1cbd890a54d08d5ec3b3c20729647d8 --- /dev/null +++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js @@ -0,0 +1,268 @@ +/* + * 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, {useCallback, useState} from 'react' +import PropTypes from 'prop-types' +import { number, bool, reach } from 'yup' +import { cloneDeep } from 'lodash' +import { + Checkbox, + FormControlLabel +} from '@material-ui/core' +import { InputJMESPath } from '../input/InputMetainfo' +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 { InputTextField } from '../input/InputText' +import UnitInput from '../../units/UnitInput' + +// Predefined in order to not break memoization +const dtypesNumeric = new Set([DType.Int, DType.Float]) +const dtypesColor = new Set([DType.String, DType.Enum, DType.Float, DType.Int]) +const nPointsOptions = { + 100: 100, + 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. + */ +export const WidgetScatterPlotEdit = 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 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 handleChange = useCallback((key, value) => { + setSettings(old => { + const newValue = {...old} + setDeep(newValue, key, value) + return newValue + }) + }, [setSettings]) + + const handleClose = useCallback(() => { + setWidget(old => ({...old, editing: false})) + }, [setWidget]) + + const handleAccept = useCallback((key, value) => { + try { + reach(schemaWidgetScatterPlot, 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]) + + // Upon accepting the entire form, we perform final validation that also + // takes into account cross-field incompatibilities + const handleEditAccept = useCallback(() => { + // Check for independent errors from components + 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. + const xEmpty = isEmptyString(settings?.x?.quantity) + if (xEmpty) { + handleErrorQuantity('x.quantity', 'Please specify a value') + } + const yEmpty = isEmptyString(settings?.y?.quantity) + if (yEmpty) { + handleErrorQuantity('y.quantity', 'Please specify a value') + } + + if (!independentErrors && !xEmpty && !yEmpty) { + 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 scatter plot widget" + onClose={handleClose} + onAccept={handleEditAccept} + > + <WidgetEditGroup title="x axis"> + <WidgetEditOption> + <InputJMESPath + label="quantity" + value={settings.x?.quantity} + onChange={(value) => handleChange('x.quantity', value)} + onSelect={(value) => handleAcceptQuantity('x.quantity', value)} + onAccept={(value) => handleAcceptQuantity('x.quantity', value)} + error={errors['x.quantity']} + onError={(value) => handleErrorQuantity('x.quantity', value)} + dtypes={dtypesNumeric} + dtypesRepeatable={dtypesNumeric} + /> + </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="y axis"> + <WidgetEditOption> + <InputJMESPath + label="quantity" + value={settings.y?.quantity} + onChange={(value) => handleChange('y.quantity', value)} + onSelect={(value) => handleAcceptQuantity('y.quantity', value)} + onAccept={(value) => handleAcceptQuantity('y.quantity', value)} + error={errors['y.quantity']} + onError={(value) => handleErrorQuantity('y.quantity', value)} + dtypes={dtypesNumeric} + dtypesRepeatable={dtypesNumeric} + /> + </WidgetEditOption> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings.y?.title} + onChange={(event) => handleChange('y.title', event.target.value)} + /> + </WidgetEditOption> + <WidgetEditOption> + <UnitInput + label='unit' + value={settings.y?.unit} + onChange={(value) => handleChange('y.unit', value)} + onSelect={(value) => handleAccept('y.unit', value)} + onAccept={(value) => handleAccept('y.unit', value)} + error={errors['y.unit']} + onError={(value) => handleError('y.unit', value)} + dimension={dimensions['y.quantity'] || null} + optional + disableGroup + /> + </WidgetEditOption> + </WidgetEditGroup> + <WidgetEditGroup title="marker color"> + <WidgetEditOption> + <InputJMESPath + label="quantity" + value={settings?.markers?.color?.quantity} + onChange={(value) => handleChange('markers.color.quantity', value)} + onSelect={(value) => handleAcceptQuantity('markers.color.quantity', value)} + onAccept={(value) => handleAcceptQuantity('markers.color.quantity', value)} + error={errors['markers.color.quantity']} + onError={(value) => handleErrorQuantity('markers.color.quantity', value)} + dtypes={dtypesColor} + dtypesRepeatable={dtypesColor} + optional + /> + </WidgetEditOption> + <WidgetEditOption> + <InputTextField + label="title" + fullWidth + value={settings.markers?.color?.title} + onChange={(event) => handleChange('markers.color.title', event.target.value)} + /> + </WidgetEditOption> + <WidgetEditOption> + <UnitInput + label='unit' + value={settings.markers?.color?.unit} + onChange={(value) => handleChange('markers.color.unit', value)} + onSelect={(value) => handleAccept('markers.color.unit', value)} + onAccept={(value) => handleAccept('markers.color.unit', value)} + error={errors['markers.color.unit']} + onError={(value) => handleError('markers.color.unit', value)} + dimension={dimensions['markers.color.quantity'] || null} + optional + disableGroup + /> + </WidgetEditOption> + </WidgetEditGroup> + <WidgetEditGroup title="general"> + <WidgetEditOption> + <WidgetEditSelect + label="Maximum number of entries to load" + options={nPointsOptions} + value={settings.size} + onChange={(event) => { handleChange('size', event.target.value) }} + /> + </WidgetEditOption> + <WidgetEditOption> + <FormControlLabel + control={<Checkbox checked={settings.autorange} onChange={(event, value) => handleChange('autorange', value)}/>} + label={autorangeDescription} + /> + </WidgetEditOption> + </WidgetEditGroup> + </WidgetEditDialog> +}) + +WidgetScatterPlotEdit.propTypes = { + widget: PropTypes.object +} + +export const schemaWidgetScatterPlot = schemaWidget.shape({ + x: schemaAxis.required('Quantity for the x axis is required.'), + y: schemaAxis.required('Quantity for the y axis is required.'), + markers: schemaMarkers, + size: number().integer().required('Size is required.'), + autorange: bool() +}) diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..04382e408e777565031aabbde5fce54232c96a2f --- /dev/null +++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.spec.js @@ -0,0 +1,54 @@ +/* + * 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 { 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'], + ['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.'], + ['invalid jmespath x', {x: {quantity: 'results.material.n_elements[*'}}, 'Invalid JMESPath query, please check your syntax.'], + ['invalid jmespath y', {y: {quantity: 'results.material.n_elements[*'}}, 'Invalid JMESPath query, please check your syntax.'], + ['invalid jmespath color', {markers: {color: {quantity: 'results.material.n_elements[*'}}}, 'Invalid JMESPath query, please check your syntax.'], + ['no jmespath for repeating x', {x: {quantity: 'results.material.topology.cell.a'}}, 'The quantity "results.material.topology.cell.a" is contained in at least one repeatable section. Please use JMESPath syntax to select one or more target sections.'], + ['no jmespath for repeating y', {y: {quantity: 'results.material.topology.cell.a'}}, 'The quantity "results.material.topology.cell.a" is contained in at least one repeatable section. Please use JMESPath syntax to select one or more target sections.'], + ['no jmespath for repeating color', {markers: {color: {quantity: 'results.material.topology.cell.a'}}}, 'The quantity "results.material.topology.cell.a" is contained in at least one repeatable section. Please use JMESPath syntax to select one or more target sections.'], + ['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"'] + ])('%s', async (name, config, error) => { + const finalConfig = { + id: '0', + editing: true, + ...config + } + renderSearchEntry(<WidgetScatterPlotEdit widget={finalConfig} />) + const button = screen.getByText('Done') + await userEvent.click(button) + screen.getByText(error) + }) +}) diff --git a/gui/src/components/search/widgets/WidgetTerms.js b/gui/src/components/search/widgets/WidgetTerms.js index ea2f0d1ccce6a28247b6ffdc1a936065b35685e9..4eb9dd0135189acaff620c4c5c7ccb4bb6b273e9 100644 --- a/gui/src/components/search/widgets/WidgetTerms.js +++ b/gui/src/components/search/widgets/WidgetTerms.js @@ -30,7 +30,7 @@ import { } from '@material-ui/core' import { useResizeDetector } from 'react-resize-detector' import { useSearchContext } from '../SearchContext' -import { InputSearchMetainfo } from '../input/InputMetainfo' +import { InputMetainfo } from '../input/InputMetainfo' import { InputTextQuantity } from '../input/InputText' import InputItem, { inputItemHeight } from '../input/InputItem' import InputUnavailable from '../input/InputUnavailable' @@ -306,7 +306,7 @@ export const WidgetTermsEdit = React.memo((props) => { > <WidgetEditGroup title="x axis"> <WidgetEditOption> - <InputSearchMetainfo + <InputMetainfo label="quantity" value={settings.quantity} error={errors.quantity} diff --git a/gui/src/components/units/Quantity.js b/gui/src/components/units/Quantity.js index 803e884c7a31e9249cebe1c1d3c9507586cbbfd4..66217f1beb5d99939321e91e622cdff3c19e8b38 100644 --- a/gui/src/components/units/Quantity.js +++ b/gui/src/components/units/Quantity.js @@ -16,8 +16,9 @@ * limitations under the License. */ -import {isNumber, isArray, isNil} from 'lodash' -import {Unit} from './Unit' +import {isNumber, isArray} from 'lodash' +import {Unit, normalizeExpression} from './Unit' +import { Unit as UnitMathJS } from 'mathjs' import {mapDeep} from '../../utils' /** @@ -35,7 +36,7 @@ export class Quantity { constructor(value, unit, normalized = false) { this.unit = new Unit(unit) if (!isNumber(value) && !isArray(value)) { - throw Error('Please provide the the value as a number, or as a multidimensional array of numbers.') + throw Error('Please provide the value as a number, or as a multidimensional array of numbers.') } // This attribute stores the quantity value in 'normalized' form that is @@ -76,7 +77,7 @@ export class Quantity { return this.unit.label() } - dimension(base) { + dimension(base = false) { return this.unit.dimension(base) } @@ -96,7 +97,7 @@ export class Quantity { * Checks if the given Quantity is equal to this one. * @param {Quantity} quantity Quantity to compare to * @returns boolean Whether quantities are equal - */ + */ equal(quantity) { if (quantity instanceof Quantity) { return this.normalized_value === quantity.normalized_value && this.unit.equalBase(quantity.unit) @@ -110,44 +111,77 @@ export class Quantity { * Convenience function for parsing value and unit information from a string. * * @param {string} input The input string to parse - * @param {boolean} requireValue Whether a value is required. - * @param {boolean} requireUnit Whether a unit is required. - * @param {string} dimension Dimension for the unit. Nil value means a - * dimensionless unit. + * @param {string} dimension Dimension for the unit. Defaults to 'dimensionless' + * if not specified. If you want to disable dimension checks, use null. + * @param {boolean} requireValue Whether an explicit numeric value is required at the start of the input. + * @param {boolean} requireUnit Whether an explicit unit in the input is required at the end of the input. * @returns Object containing the following properties, if available: * - value: Numerical value as a number - * - valueString: Numerical value as a string + * - valueString: The original number input as a string. Note that this can only return + * the number when it is used as a prefix, and does not work with numbers that are + * part of a complex expression, e.g. 300 eV / 1000 K. * - unit: Unit instance - * - unitString: Unit as a string * - error: Error messsage */ -export function parseQuantity(input, requireValue = true, requireUnit = true, dimension = undefined) { +export function parseQuantity(input, dimension = 'dimensionless', requireValue = false, requireUnit = false) { input = input.trim() - const valueString = input.match(/^[+-]?((\d+\.\d+|\d+\.|\.\d?|\d+)(e|e\+|e-)\d+|(\d+\.\d+|\d+\.|\.\d?|\d+))?/)?.[0] - if (requireValue && isNil(valueString)) { - return {error: 'Enter a valid numerical value'} - } - const value = Number(valueString) - const unitString = input.substring(valueString.length).trim() - const dim = isNil(dimension) ? 'dimensionless' : dimension - if (unitString === '' && dim !== 'dimensionless' && requireUnit) { - return {value, valueString, unitString, error: 'Unit is required'} + let error + let value + let valueString = input.match(/^[+-]?((\d+\.\d+|\d+\.|\.\d?|\d+)(e|e\+|e-)\d+|(\d+\.\d+|\d+\.|\.\d?|\d+))?/)?.[0] + const unitString = input.substring(valueString.length)?.trim() || '' + + // Check value if required + if (valueString === '') { + valueString = undefined + value = undefined + if (requireValue) { + error = 'Enter a valid numerical value' + } + } else { + value = Number(valueString) } - if (unitString === '' && !requireUnit) { - return {value, valueString, unitString} + + // Check unit if required + if (requireUnit) { + if (unitString === '') { + return {valueString, value, error: 'Unit is required'} + } } - if (dim === 'dimensionless' && unitString !== '') { - return {value, valueString, unitString, error: 'Enter a numerical value without units'} + + // Try to parse with MathJS: it can extract the unit even when it is mixed + // with numbers + input = normalizeExpression(input) + let unitMathJS + try { + unitMathJS = UnitMathJS.parse(input, {allowNoUnits: true}) + } catch (e) { + return {valueString, error: e.message} } + let unit + unitMathJS.value = null try { - unit = new Unit(dim === 'dimensionless' ? 'dimensionless' : input) - } catch { - return {valueString, value, unitString, error: `Unit "${unitString}" is not available`} + unit = new Unit(unitMathJS) + } catch (e) { + error = e.msg + } + if (error) { + return {valueString, value, unit, error} } - const inputDim = unit.dimension(false) - if (inputDim !== dimension) { - return {valueString, value, unitString, unit, error: `Unit "${unitString}" has incompatible dimension`} + + // If unit is not required and it is dimensionless, return without new unit + if (!requireUnit && unit.dimension() === 'dimensionless') { + return {valueString, value} } - return {value, valueString, unit, unitString} + + // TODO: This check is not enough: the input may be compatible after the base + // units are compared. + if (dimension !== null) { + const inputDim = unit.dimension() + if (inputDim !== dimension) { + error = `Unit "${unit.label(false)}" is incompatible with dimension "${dimension}"` + } + } + + return {value, valueString, unit, error} } diff --git a/gui/src/components/units/Quantity.spec.js b/gui/src/components/units/Quantity.spec.js index e0a1f225eed595c1df0780b2521b305f5c1e5251..205326066f8ed5d12e9952d05eb89b776b0470ee 100644 --- a/gui/src/components/units/Quantity.spec.js +++ b/gui/src/components/units/Quantity.spec.js @@ -16,7 +16,8 @@ * limitations under the License. */ -import { Quantity } from './Quantity' +import { Unit } from './Unit' +import { Quantity, parseQuantity } from './Quantity' import { dimensionMap } from './UnitContext' test('conversion works both ways for each compatible unit', async () => { @@ -121,3 +122,23 @@ test.each([ } expect(valueA).toBeCloseTo(10 * valueB) }) + +test.each([ + ['number only', '100', undefined, true, false, {valueString: '100', value: 100}], + ['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'}], + ['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')}] + +] +)('test parseQuantity: %s', async (name, input, dimension, requireValue, requireUnit, expected) => { + const result = parseQuantity(input, dimension, requireValue, requireUnit) + expect(result.valueString === expected.valueString).toBe(true) + expect(result.value === expected.value).toBe(true) + expect(result.unit?.label() === expected.unit?.label()).toBe(true) + expect(result.error === expected.error).toBe(true) +}) diff --git a/gui/src/components/units/Unit.js b/gui/src/components/units/Unit.js index d0a3e25307dd85036bcce5edcf0641a3a79e9cea..220ac7bc8e3aeb3437b357abfb47510ebb07149c 100644 --- a/gui/src/components/units/Unit.js +++ b/gui/src/components/units/Unit.js @@ -19,10 +19,12 @@ import {isNil, has, isString} from 'lodash' import {Unit as UnitMathJS} from 'mathjs' import {unitToAbbreviationMap} from './UnitContext' +export const DIMENSIONLESS = 'dimensionless' + /** * Helper class for persisting unit information. * - * Builds upon the math.js Unit class system, but adds additional functionality, + * Builds upon the math.js Unit class, but adds additional functionality, * including: * - Ability to convert to any unit system given as an argument * - Abbreviated labels for dense formatting @@ -33,7 +35,7 @@ export class Unit { */ constructor(unit) { if (isString(unit)) { - unit = this.normalizeExpression(unit) + unit = normalizeExpression(unit) unit = new UnitMathJS(undefined, unit) } else if (unit instanceof Unit) { unit = unit.mathjsUnit.clone() @@ -47,25 +49,6 @@ export class Unit { // this._label = undefined } - /** - * Normalizes the given expression into a format that can be parsed by MathJS. - * - * This function will replace the Pint power symbol of '**' with the symbol - * '^' used by MathJS. In addition, we convert any 'delta'-units (see: - * https://pint.readthedocs.io/en/stable/nonmult.html) into their regular - * counterparts: MathJS will automatically ignore the offset when using - * non-multiplicative units in expressions. - * - * @param {str} expression Expression - * @returns string Expression in normalized form - */ - normalizeExpression(expression) { - let normalized = expression.replace(/\*\*/g, '^') - normalized = normalized.replace(/delta_/g, '') - normalized = normalized.replace(/Δ/g, '') - return normalized - } - /** * Checks if the given unit has the same base dimensions as this one. * @param {str | Unit} unit Unit to compare to @@ -73,7 +56,7 @@ export class Unit { */ equalBase(unit) { if (isString(unit)) { - unit = this.normalizeExpression(unit) + unit = normalizeExpression(unit) unit = new Unit(unit) } return this.mathjsUnit.equalBase(unit.mathjsUnit) @@ -101,7 +84,7 @@ export class Unit { let nDen = 0 function getName(unit) { - if (unit.base.key === 'dimensionless') return '' + if (unit.base.key === DIMENSIONLESS) return '' return abbreviate ? unitToAbbreviationMap?.[unit.name] || unit.name : unit.name @@ -207,7 +190,7 @@ export class Unit { * the original unit dimensions are used. * @returns The dimensionality as a string, e.g. 'time^2 energy mass^-2' */ - dimension(base = true) { + dimension(base = false) { const dimensions = Object.keys(UnitMathJS.BASE_UNITS) const dimensionMap = Object.fromEntries(dimensions.map(name => [name, 0])) @@ -227,9 +210,10 @@ export class Unit { } } } - return Object.entries(dimensionMap) - .filter(d => d[1] !== 0) - .map(d => `${d[0]}${((d[1] < 0 || d[1] > 1) && `^${d[1]}`) || ''}`).join(' ') + const dims = Object.entries(dimensionMap).filter(d => d[1] !== 0) + return dims.length > 0 + ? dims.map(d => `${d[0]}${((d[1] < 0 || d[1] > 1) && `^${d[1]}`) || ''}`).join(' ') + : DIMENSIONLESS } /** @@ -240,7 +224,7 @@ export class Unit { */ to(unit) { if (isString(unit)) { - unit = this.normalizeExpression(unit) + unit = normalizeExpression(unit) } else if (unit instanceof Unit) { unit = unit.label() } else { @@ -251,7 +235,7 @@ export class Unit { // to parse units like 1/<unit> as Math.js units which have values, and then // will raise an exception when converting between valueless and valued // unit. The workaround is to explicitly define a valueless unit. - unit = new UnitMathJS(undefined, unit) + unit = new UnitMathJS(undefined, unit === '' ? DIMENSIONLESS : unit) return new Unit(this.mathjsUnit.to(unit)) } @@ -362,3 +346,22 @@ export class Unit { return new Unit(ret) } } + +/** + * Normalizes the given expression into a format that can be parsed by MathJS. + * + * This function will replace the Pint power symbol of '**' with the symbol + * '^' used by MathJS. In addition, we convert any 'delta'-units (see: + * https://pint.readthedocs.io/en/stable/nonmult.html) into their regular + * counterparts: MathJS will automatically ignore the offset when using + * non-multiplicative units in expressions. + * + * @param {str} expression Expression + * @returns string Expression in normalized form + */ +export function normalizeExpression(expression) { + let normalized = expression.replace(/\*\*/g, '^') + normalized = normalized.replace(/delta_/g, '') + normalized = normalized.replace(/Δ/g, '') + return normalized +} diff --git a/gui/src/components/units/Unit.spec.js b/gui/src/components/units/Unit.spec.js index 851b28246b3e9489a5daee9631e5c9b67a5ebb91..7cd88133bc700fbb8a531c4b5726dbc17959d7c9 100644 --- a/gui/src/components/units/Unit.spec.js +++ b/gui/src/components/units/Unit.spec.js @@ -63,15 +63,15 @@ test.each([ }) test.each([ - ['dimensionless', 'dimensionless', 'dimensionless'], - ['single unit', 'meter', 'length'], - ['fixed order 1', 'meter * second', 'length time'], - ['fixed order 2', 'second * meter', 'length time'], - ['power', 'meter^3 * second^-1', 'length^3 time^-1'], + ['dimensionless', 'dimensionless', 'dimensionless', false], + ['single unit', 'meter', 'length', false], + ['fixed order 1', 'meter * second', 'length time', false], + ['fixed order 2', 'second * meter', 'length time', false], + ['power', 'meter^3 * second^-1', 'length^3 time^-1', false], ['in derived', 'joule', 'energy', false], - ['in base', 'joule', 'mass length^2 time^-2'] + ['in base units', 'joule', 'mass length^2 time^-2', true] ] -)('test getting dimension": %s', async (name, unit, dimension, base = true) => { +)('test getting dimension": %s', async (name, unit, dimension, base) => { const a = new Unit(unit) expect(a.dimension(base)).toBe(dimension) }) diff --git a/gui/src/components/units/UnitDimensionSelect.js b/gui/src/components/units/UnitDimensionSelect.js index aa7fdbba778f794e77015bd86f6fd267614b24cb..ad06d16703b1a883514205e739445aa625869421 100644 --- a/gui/src/components/units/UnitDimensionSelect.js +++ b/gui/src/components/units/UnitDimensionSelect.js @@ -38,7 +38,7 @@ const UnitDimensionSelect = React.memo(({label, dimension, onChange, disabled}) setInputValue(unit?.definition) }, [unit, dimension]) - const handleAccept = useCallback((unit, unitString) => { + const handleAccept = useCallback((unitString, unit) => { setUnits(old => { const newUnits = { ...old, @@ -50,14 +50,10 @@ const UnitDimensionSelect = React.memo(({label, dimension, onChange, disabled}) oldValue.current = unitString }, [dimension, onChange, setUnits]) - const handleSelect = useCallback((unit, unitString) => { - handleAccept(unit, unit.label()) + const handleSelect = useCallback((unitString, unit) => { + handleAccept(unit.label(), unit) }, [handleAccept]) - const handleBlur = useCallback((onAccept) => { - onAccept(inputValue) - }, [inputValue]) - const handleChange = useCallback((value) => { oldValue.current = value setInputValue(value) @@ -70,7 +66,6 @@ const UnitDimensionSelect = React.memo(({label, dimension, onChange, disabled}) onChange={handleChange} onAccept={handleAccept} onSelect={handleSelect} - onBlur={handleBlur} onError={setError} dimension={dimension} error={error} diff --git a/gui/src/components/units/UnitInput.js b/gui/src/components/units/UnitInput.js index 9f84b838fd9a9ca9faa3e1f4ade678a4d3fe22fa..bddff7a034e939ae3b58ce033a27d25297e163d7 100644 --- a/gui/src/components/units/UnitInput.js +++ b/gui/src/components/units/UnitInput.js @@ -18,6 +18,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useContext, createContext} from 'react' import PropTypes from 'prop-types' +import {isNil} from 'lodash' import {getSuggestions} from '../../utils' import {unitMap} from './UnitContext' import {parseQuantity} from './Quantity' @@ -47,7 +48,7 @@ export const useInputStyles = makeStyles(theme => ({ alignItems: 'stretch' } })) -export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect, onError, onBlur, dimension, options, disabled, label, disableGroup}) => { +export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect, onError, dimension, options, disabled, label, disableGroup, optional}) => { const styles = useInputStyles() // Predefine all option objects, all option paths and also pre-tokenize the @@ -55,11 +56,12 @@ export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect const {keys, filter, finalOptions} = useMemo(() => { const finalOptions = {} Object.entries(unitMap) - .filter(([key, unit]) => unit.dimension === dimension) + .filter(([key, unit]) => dimension ? unit.dimension === dimension : true) .forEach(([key, unit]) => { + const unitAbbreviation = unit.abbreviation ? ` (${unit.abbreviation})` : '' finalOptions[key] = { key: key, - primary: `${unit.label} (${unit.abbreviation})`, + primary: `${unit.label}${unitAbbreviation}`, secondary: unit.aliases?.splice(1).join(', '), dimension: unit.dimension, unit: unit @@ -70,31 +72,27 @@ export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect return {keys, filter, finalOptions} }, [dimension]) - const handleChange = useCallback((value) => { - onChange?.(value) - }, [onChange]) - - const handleAccept = useCallback((key) => { - const {unit, unitString, error} = parseQuantity(key, false, true, dimension) - if (error) { - onError(error) - } else { - onAccept?.(unit, unitString) + const validate = useCallback((value) => { + if (isNil(value) || value?.trim?.() === '') { + if (optional) { + return {valid: true} + } else { + return {valid: false, error: 'Please specify a value'} + } } - }, [onAccept, onError, dimension]) - - const handleError = useCallback((value) => { - onError?.(value) - }, [onError]) + const {error, unit} = parseQuantity(value, dimension, false, true) + return {valid: !error, error, data: unit} + }, [optional, dimension]) - const handleSelect = useCallback((key) => { - const {unit, unitString, error} = parseQuantity(key, false, true, dimension) + // Revalidate input when dimension changes + useEffect(() => { + if (!value) return + const {error} = validate(value) if (error) { - onError(error) - } else { - onSelect?.(unit, unitString) + onError?.(error) } - }, [onSelect, onError, dimension]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dimension]) // Used to filter the shown options based on input const filterOptions = useCallback((opt, { inputValue }) => { @@ -107,13 +105,12 @@ export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect value={value} TextFieldProps={{label, disabled}} error={error} - onChange={handleChange} - onSelect={handleSelect} - onAccept={handleAccept} - onError={handleError} - onBlur={() => { onBlur(handleAccept) }} + onChange={onChange} + onSelect={onSelect} + onAccept={onAccept} + onError={onError} + validate={validate} disableClearable - disableAcceptOnBlur suggestAllOnFocus showOpenSuggestions suggestions={keys} @@ -148,7 +145,8 @@ UnitInput.propTypes = { onBlur: PropTypes.func, onError: PropTypes.func, disabled: PropTypes.bool, - disableGroup: PropTypes.bool + disableGroup: PropTypes.bool, + optional: PropTypes.bool } export default UnitInput diff --git a/gui/src/utils.js b/gui/src/utils.js index ee928ab45b22cfaf4b0596a1ceb03438901167b7..c4305bfbebd2e40985616962a758942c342a0e52 100644 --- a/gui/src/utils.js +++ b/gui/src/utils.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ /* * Copyright The NOMAD Authors. * @@ -16,9 +17,10 @@ * limitations under the License. */ import minimatch from 'minimatch' -import { cloneDeep, merge, isSet, isNil, isArray, isString, isNumber, isPlainObject, startCase, isEmpty } from 'lodash' +import { cloneDeep, merge, isSet, isNil, isArray, isString, isNumber, isPlainObject, startCase, isEmpty, keys } from 'lodash' import { Quantity } from './components/units/Quantity' import { format } from 'date-fns' +import jmespath from 'jmespath' import { dateFormat, guiBase, apiBase, searchQuantities, parserMetadata, schemaSeparator, dtypeSeparator, yamlSchemaPrefix } from './config' const crypto = require('crypto') @@ -100,6 +102,65 @@ export function getDeep(data, path, separator = '.') { return segments.reduce((current, segment) => current && current[segment], data) } +/** + * Used to retrieve all found options from a nested and repeated structure using + * depth-first search. + * + * @param {object} data The data to traverse + * @param {str} path Path to traverse + * @return All found values at the given path. Empty list if nothing was found. + */ +export function getDeepAll(data, path, separator = '.') { + const segments = path.split(separator) + const values = [] + + function getRecursive(data, segments, values) { + let current = data + let i = 0 + for (const segment of segments) { + i += 1 + current = current[segment] + if (!current) break + if (isArray(current)) { + for (const item of current) { + getRecursive(item, segments.slice(i), values) + } + } else if (isPlainObject(current)) { + getRecursive(current, segments.slice(i + 1), values) + } else { + values.push(current) + } + } + } + + getRecursive(data, segments, values) + return values +} + +/** + * Used to set a value to a nested object. Will create the hierarchy on the go + * if it is missing. + * + * @param {object} data The data to traverse. + * @param {str} path Path to use + * @param {value} path Value to store + */ +export function setDeep(data, path, value, separator = '.') { + const segments = path.split(separator) + let current = data + for (let i = 0; i < segments.length; ++i) { + const segment = segments[i] + if (i === segments.length - 1) { + current[segment] = value + } else { + if (isNil(current[segment])) { + current[segment] = {} + } + current = current[segment] + } + } +} + /** * Map that works on n-dimensional arrays. Implemented with simple for loops for * performance. @@ -1555,3 +1616,121 @@ export function glob(path, include, exclude) { } return match } + +/** + * Used to validate JMESPath. + * @param {str} input + * @returns + */ +export function validateJMESPath(input) { + return jmespath.compile(input) +} + +/** + * Used to parse a JMESPath input. + * @param {str} input + * @returns + */ +export function parseJMESPath(input) { + // Remove possible schema+dtype specification + const regexp = /#[a-zA-Z0-9_.#]+/g + const match = regexp.exec(input) + let schema = '' + let path = input + if (match) { + schema = match[0] + path = input.slice(0, match.index) + input.slice(match.index + schema.length) + } + + // Try to compile JMESPath call, report error + let error + let ast + try { + ast = validateJMESPath(path) + } catch (e) { + return {quantity: undefined, path: undefined, extras: undefined, error: e.message, schema: ''} + } + + // Walk down depth-first the AST to extract the targeted quantity + function recurseAST(node) { + const type = node?.type + const name = node?.name + const children = node?.children + let field = [] + let extras = [] + + if (children) { + const childFields = [] + const childExtras = [] + for (const child of children) { + const [fieldInner, extrasInner] = recurseAST(child) + childFields.push(fieldInner) + childExtras.push(extrasInner) + } + // In filter projections we save the filter field in extras + if (type === 'FilterProjection') { + for (const childField of childFields.slice(0, 2)) { + field = [...field, ...childField] + } + extras = [...extras, [...childFields[0], ...childFields[2]]] + // In *_by we save the referenced variable in extras + } else if (type === 'Function' && name === 'min_by') { + field = [...field, ...childFields[0]] + extras = [...extras, [...childFields[0], ...childFields[1]]] + // For other types we simply extend definitions and extras in the order + // they are defined in + } else { + for (const childField of childFields) { + field = [...field, ...childField] + } + for (const childExtra of childExtras) { + extras = [...extras, ...childExtra] + } + } + } else { + if (type === 'Field') { + return [[name], extras] + } + } + return [field, extras] + } + + const [field, extrasList] = recurseAST(ast) + const quantity = field.join('.') + schema + const extras = extrasList.map(x => x.join('.') + schema) + + return {quantity, extras, path, schema, error} +} + +/** + * Cleans the given object/array recursively from undefined values and empty + * objects. + * @param {*} obj The input list or object to clean. + */ +export function cleanse(obj) { + // Clean array + if (isArray(obj)) { + for (const item of obj) { + cleanse(item) + } + // Clean object + } else { + for (const key of keys(obj)) { + // Get this value and its type + const value = obj[key] + const type = typeof value + if (isPlainObject(value)) { + cleanse(value) + if (isEmpty(value)) { + delete obj[key] + } + } else if (isArray(value)) { + for (const item of value) { + cleanse(item) + } + } else if (type === "undefined") { + delete obj[key] + } + } + } +} diff --git a/gui/src/utils.spec.js b/gui/src/utils.spec.js index 0fc6fdf82a5bb543a51c4de430a954027b53823b..c88944d5eddddce52d06ac12abb708386b974305 100644 --- a/gui/src/utils.spec.js +++ b/gui/src/utils.spec.js @@ -24,9 +24,11 @@ import { resolveNomadUrl, normalizeNomadUrl, refType, - refRelativeTo + refRelativeTo, + parseJMESPath } from './utils' import { apiBase, urlAbs } from './config' +import { isEqual } from 'lodash' describe('titleCase', () => { it('runs on empty strings', () => { @@ -557,3 +559,153 @@ test.each([ ])('absolute url creation: %s ', (id, input, output, base, protocol = undefined) => { expect(urlAbs(input, base, protocol)).toBe(output) }) + +test.each([ + [ + 'simple subexpression', + 'results.material.n_elements', + { + quantity: 'results.material.n_elements', + path: 'results.material.n_elements', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'index expression', + 'results.material.elements[0]', + { + quantity: 'results.material.elements', + path: 'results.material.elements[0]', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'slicing ', + 'results.material.elements[0:5]', + { + quantity: 'results.material.elements', + path: 'results.material.elements[0:5]', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'list projection', + 'results.properties.electronic.band_gap[*].value', + { + quantity: 'results.properties.electronic.band_gap.value', + path: 'results.properties.electronic.band_gap[*].value', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'flatten projection', + 'results.properties[].electronic.band_gap[].value', + { + quantity: 'results.properties.electronic.band_gap.value', + path: 'results.properties[].electronic.band_gap[].value', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'function with one argument', + 'min(results.properties.electronic.band_gap[*].value)', + { + quantity: 'results.properties.electronic.band_gap.value', + path: 'min(results.properties.electronic.band_gap[*].value)', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'function with two arguments', + 'min_by(results.properties.electronic.band_gap[*], &value).type', + { + quantity: 'results.properties.electronic.band_gap.type', + path: 'min_by(results.properties.electronic.band_gap[*], &value).type', + extras: ['results.properties.electronic.band_gap.value'], + error: undefined, + schema: '' + } + ], + [ + 'filter projection', + "results.material.topology[?label=='original'].cell.a", + { + quantity: 'results.material.topology.cell.a', + path: "results.material.topology[?label=='original'].cell.a", + extras: ['results.material.topology.label'], + error: undefined, + schema: '' + } + ], + [ + 'pipe', + 'results.properties.electronic.band_gap[*].value | min(@)', + { + quantity: 'results.properties.electronic.band_gap.value', + path: 'results.properties.electronic.band_gap[*].value | min(@)', + extras: [], + error: undefined, + schema: '' + } + ], + [ + 'schema name and dtype are handled correctly 1', + 'min_by(results.properties.electronic.band_gap[*], &value).type#MySchema#int', + { + quantity: 'results.properties.electronic.band_gap.type#MySchema#int', + path: 'min_by(results.properties.electronic.band_gap[*], &value).type', + extras: ['results.properties.electronic.band_gap.value#MySchema#int'], + error: undefined, + schema: '#MySchema#int' + } + ], + [ + 'schema name and dtype are handled correctly 2', + 'results.properties.electronic.band_gap[*].value#MySchema | min(@)', + { + quantity: 'results.properties.electronic.band_gap.value#MySchema', + path: 'results.properties.electronic.band_gap[*].value | min(@)', + extras: [], + error: undefined, + schema: '#MySchema' + } + ], + [ + 'syntax error', + 'results.material.n_elements[*', + { + quantity: undefined, + path: undefined, + extras: undefined, + error: 'Expected Rbracket, got: EOF', + schema: '' + } + ] + // Object projection is not supported, as we cannot tell ES which properties + // to fetch. If the JMESPath query is made by the API, then this might work as + // well. + // [ + // 'object projection', + // 'results.properties.electronic.*.type', + // { + // quantity: '?', + // path: '?', + // extras: [], + // error: undefined + // } + // ], +])('parseJMESPath: %s ', (id, input, output) => { + expect(isEqual(parseJMESPath(input), output)).toBe(true) +}) diff --git a/gui/tests/artifacts.js b/gui/tests/artifacts.js index 8b34a7bc18d901928e4761235d59cd0a3dc9aa2a..e58563155dc78e1bef0bc6abfee56e3bd4ac65a1 100644 --- a/gui/tests/artifacts.js +++ b/gui/tests/artifacts.js @@ -8,7 +8,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "upload_name": { "name": "upload_name", @@ -19,6 +20,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "upload_create_time": { @@ -29,7 +31,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "entry_id": { "name": "entry_id", @@ -39,7 +42,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "entry_name": { "name": "entry_name", @@ -50,6 +54,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "entry_name.prefix": { @@ -60,7 +65,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "entry_type": { "name": "entry_type", @@ -70,7 +76,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "calc_id": { "name": "calc_id", @@ -80,7 +87,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "entry_create_time": { "name": "entry_create_time", @@ -90,7 +98,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "parser_name": { "name": "parser_name", @@ -100,7 +109,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "mainfile": { "name": "mainfile", @@ -111,6 +121,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "mainfile.path": { @@ -121,7 +132,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "mainfile_key": { "name": "mainfile_key", @@ -131,7 +143,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "mainfile_key.path": { "name": "mainfile_key", @@ -141,7 +154,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "text_search_contents": { "name": "text_search_contents", @@ -154,7 +168,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "files": { "name": "files", @@ -167,7 +182,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "files.path": { "name": "files", @@ -180,7 +196,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "pid": { "name": "pid", @@ -190,7 +207,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "raw_id": { "name": "raw_id", @@ -200,7 +218,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "external_id": { "name": "external_id", @@ -210,7 +229,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "published": { "name": "published", @@ -220,7 +240,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "publish_time": { "name": "publish_time", @@ -230,7 +251,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "with_embargo": { "name": "with_embargo", @@ -240,7 +262,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "processed": { "name": "processed", @@ -250,7 +273,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "last_processing_time": { "name": "last_processing_time", @@ -260,7 +284,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "processing_errors": { "name": "processing_errors", @@ -273,7 +298,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "nomad_version": { "name": "nomad_version", @@ -283,7 +309,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "nomad_commit": { "name": "nomad_commit", @@ -293,7 +320,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "comment": { "name": "comment", @@ -303,7 +331,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "references": { "name": "references", @@ -316,7 +345,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "external_db": { "name": "external_db", @@ -333,7 +363,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "origin": { "name": "origin", @@ -343,7 +374,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "main_author.name": { "name": "name", @@ -353,6 +385,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "main_author.name.text": { @@ -362,7 +395,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "main_author.user_id": { "name": "user_id", @@ -372,7 +406,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "main_author": { "name": "main_author", @@ -382,7 +417,8 @@ window.nomadArtifacts = { "type_data": "User" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "authors.name": { "name": "name", @@ -392,6 +428,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "authors.name.text": { @@ -401,7 +438,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "authors": { "name": "authors", @@ -414,7 +452,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "writers.name": { "name": "name", @@ -424,6 +463,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "writers.name.text": { @@ -433,7 +473,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "writers.user_id": { "name": "user_id", @@ -443,7 +484,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "writers": { "name": "writers", @@ -456,7 +498,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "writer_groups": { "name": "writer_groups", @@ -469,7 +512,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "viewers.name": { "name": "name", @@ -479,6 +523,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "viewers.name.text": { @@ -488,7 +533,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "viewers.user_id": { "name": "user_id", @@ -498,7 +544,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "viewers": { "name": "viewers", @@ -511,7 +558,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "viewer_groups": { "name": "viewer_groups", @@ -524,7 +572,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets.dataset_id": { "name": "dataset_id", @@ -534,7 +583,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets.dataset_name": { "name": "dataset_name", @@ -545,6 +595,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "datasets.doi": { @@ -555,7 +606,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets.dataset_create_time": { "name": "dataset_create_time", @@ -565,7 +617,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets.dataset_modified_time": { "name": "dataset_modified_time", @@ -575,7 +628,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets.dataset_type": { "name": "dataset_type", @@ -588,7 +642,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "datasets": { "name": "datasets", @@ -601,7 +656,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "domain": { "name": "domain", @@ -614,7 +670,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "n_quantities": { "name": "n_quantities", @@ -624,7 +681,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "quantities": { "name": "quantities", @@ -637,7 +695,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "quantities.path": { "name": "quantities", @@ -650,7 +709,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "sections": { "name": "sections", @@ -663,7 +723,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.elements": { "name": "elements", @@ -796,7 +857,8 @@ window.nomadArtifacts = { "1..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.nelements": { "name": "nelements", @@ -806,7 +868,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.elements_ratios": { "name": "elements_ratios", @@ -819,7 +882,8 @@ window.nomadArtifacts = { "nelements" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.chemical_formula_descriptive": { "name": "chemical_formula_descriptive", @@ -829,7 +893,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.chemical_formula_reduced": { "name": "chemical_formula_reduced", @@ -839,7 +904,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.chemical_formula_hill": { "name": "chemical_formula_hill", @@ -849,7 +915,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.chemical_formula_anonymous": { "name": "chemical_formula_anonymous", @@ -859,7 +926,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.nperiodic_dimensions": { "name": "nperiodic_dimensions", @@ -869,7 +937,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.nsites": { "name": "nsites", @@ -879,7 +948,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "optimade.structure_features": { "name": "structure_features", @@ -896,7 +966,8 @@ window.nomadArtifacts = { "1..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "section_defs.definition_qualified_name": { "name": "definition_qualified_name", @@ -906,7 +977,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "section_defs.definition_id": { "name": "definition_id", @@ -916,7 +988,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "section_defs.used_directly": { "name": "used_directly", @@ -926,7 +999,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_reference": { "name": "target_reference", @@ -936,7 +1010,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_entry_id": { "name": "target_entry_id", @@ -946,7 +1021,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_mainfile": { "name": "target_mainfile", @@ -956,7 +1032,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_upload_id": { "name": "target_upload_id", @@ -966,7 +1043,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_name": { "name": "target_name", @@ -976,7 +1054,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.target_path": { "name": "target_path", @@ -986,7 +1065,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.source_name": { "name": "source_name", @@ -996,7 +1076,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.source_path": { "name": "source_path", @@ -1006,7 +1087,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "entry_references.source_quantity": { "name": "source_quantity", @@ -1016,7 +1098,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.id": { "name": "id", @@ -1026,7 +1109,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.definition": { "name": "definition", @@ -1036,7 +1120,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.path_archive": { "name": "path_archive", @@ -1046,7 +1131,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.bool_value": { "name": "bool_value", @@ -1056,7 +1142,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.str_value": { "name": "str_value", @@ -1066,7 +1153,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.str_value.keyword": { "name": "str_value", @@ -1076,7 +1164,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.int_value": { "name": "int_value", @@ -1086,7 +1175,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.float_value": { "name": "float_value", @@ -1096,7 +1186,8 @@ window.nomadArtifacts = { "type_data": "float" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "search_quantities.datetime_value": { "name": "datetime_value", @@ -1106,7 +1197,8 @@ window.nomadArtifacts = { "type_data": "nomad.metainfo.metainfo._Datetime" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.material_id": { "name": "material_id", @@ -1116,7 +1208,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.material_name": { "name": "material_name", @@ -1127,6 +1220,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.structural_type": { @@ -1147,6 +1241,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.dimensionality": { @@ -1163,6 +1258,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.building_block": { @@ -1179,6 +1275,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.functional_type": { @@ -1193,6 +1290,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.compound_type": { @@ -1207,6 +1305,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.elements": { @@ -1341,6 +1440,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.n_elements": { @@ -1351,7 +1451,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.elements_exclusive": { "name": "elements_exclusive", @@ -1361,7 +1462,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.chemical_formula_descriptive": { "name": "chemical_formula_descriptive", @@ -1372,6 +1474,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.chemical_formula_reduced": { @@ -1383,6 +1486,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.chemical_formula_hill": { @@ -1394,6 +1498,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.chemical_formula_iupac": { @@ -1405,6 +1510,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.chemical_formula_anonymous": { @@ -1416,6 +1522,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.chemical_formula_reduced_fragments": { @@ -1429,7 +1536,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.elemental_composition.element": { "name": "element", @@ -1559,6 +1667,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.elemental_composition.atomic_fraction": { @@ -1569,7 +1678,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.elemental_composition.mass_fraction": { "name": "mass_fraction", @@ -1579,7 +1689,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.symmetry.bravais_lattice": { "name": "bravais_lattice", @@ -1606,6 +1717,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.crystal_system": { @@ -1626,6 +1738,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.hall_number": { @@ -1637,7 +1750,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.symmetry.hall_symbol": { "name": "hall_symbol", @@ -1649,6 +1763,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.point_group": { @@ -1661,6 +1776,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.space_group_number": { @@ -1672,7 +1788,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.symmetry.space_group_symbol": { "name": "space_group_symbol", @@ -1684,6 +1801,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.prototype_formula": { @@ -1694,7 +1812,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.material.symmetry.prototype_aflow_id": { "name": "prototype_aflow_id", @@ -1705,6 +1824,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.structure_name": { @@ -1735,6 +1855,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.symmetry.strukturbericht_designation": { @@ -1746,6 +1867,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material.topology.system_id": { @@ -1756,7 +1878,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.label": { "name": "label", @@ -1767,6 +1890,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.method": { @@ -1783,6 +1907,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.description": { @@ -1793,7 +1918,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.material_id": { "name": "material_id", @@ -1803,7 +1929,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.material_name": { "name": "material_name", @@ -1814,6 +1941,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.structural_type": { @@ -1838,6 +1966,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.dimensionality": { @@ -1854,6 +1983,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.building_block": { @@ -1870,6 +2000,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.functional_type": { @@ -1884,6 +2015,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.compound_type": { @@ -1898,6 +2030,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.elements": { @@ -2032,6 +2165,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.n_elements": { @@ -2042,7 +2176,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.elements_exclusive": { "name": "elements_exclusive", @@ -2052,7 +2187,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.chemical_formula_descriptive": { "name": "chemical_formula_descriptive", @@ -2063,6 +2199,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.chemical_formula_reduced": { @@ -2074,6 +2211,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.chemical_formula_hill": { @@ -2085,6 +2223,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.chemical_formula_iupac": { @@ -2096,6 +2235,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.chemical_formula_anonymous": { @@ -2107,6 +2247,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.chemical_formula_reduced_fragments": { @@ -2120,7 +2261,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.parent_system": { "name": "parent_system", @@ -2130,7 +2272,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.child_systems": { "name": "child_systems", @@ -2143,7 +2286,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.atomic_fraction": { "name": "atomic_fraction", @@ -2153,7 +2297,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.mass_fraction": { "name": "mass_fraction", @@ -2163,7 +2308,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.n_atoms": { "name": "n_atoms", @@ -2174,7 +2320,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.sbu_type": { "name": "sbu_type", @@ -2186,6 +2333,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.largest_cavity_diameter": { @@ -2197,7 +2345,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.pore_limiting_diameter": { "name": "pore_limiting_diameter", @@ -2208,7 +2357,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.largest_included_sphere_along_free_sphere_path": { "name": "largest_included_sphere_along_free_sphere_path", @@ -2219,7 +2369,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.accessible_surface_area": { "name": "accessible_surface_area", @@ -2230,7 +2381,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 2", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.accessible_volume": { "name": "accessible_volume", @@ -2241,7 +2393,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.void_fraction": { "name": "void_fraction", @@ -2251,7 +2404,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.n_channels": { "name": "n_channels", @@ -2262,7 +2416,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.sbu_coordination_number": { "name": "sbu_coordination_number", @@ -2272,7 +2427,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.elemental_composition.element": { "name": "element", @@ -2402,6 +2558,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.elemental_composition.atomic_fraction": { @@ -2412,7 +2569,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.elemental_composition.mass_fraction": { "name": "mass_fraction", @@ -2422,7 +2580,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.system_relation.type": { "name": "type", @@ -2439,6 +2598,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.cell.a": { @@ -2450,7 +2610,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.b": { "name": "b", @@ -2461,7 +2622,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.c": { "name": "c", @@ -2472,7 +2634,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.alpha": { "name": "alpha", @@ -2483,7 +2646,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.beta": { "name": "beta", @@ -2494,7 +2658,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.gamma": { "name": "gamma", @@ -2505,7 +2670,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.volume": { "name": "volume", @@ -2516,7 +2682,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.atomic_density": { "name": "atomic_density", @@ -2527,7 +2694,8 @@ window.nomadArtifacts = { }, "unit": "1 / meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.cell.mass_density": { "name": "mass_density", @@ -2538,7 +2706,8 @@ window.nomadArtifacts = { }, "unit": "kilogram / meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.symmetry.bravais_lattice": { "name": "bravais_lattice", @@ -2565,6 +2734,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.crystal_system": { @@ -2585,6 +2755,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.hall_number": { @@ -2596,7 +2767,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.symmetry.hall_symbol": { "name": "hall_symbol", @@ -2608,6 +2780,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.point_group": { @@ -2620,6 +2793,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.space_group_number": { @@ -2631,7 +2805,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.symmetry.space_group_symbol": { "name": "space_group_symbol", @@ -2643,6 +2818,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.strukturbericht_designation": { @@ -2654,6 +2830,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.prototype_label_aflow": { @@ -2666,6 +2843,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.symmetry.prototype_name": { @@ -2696,6 +2874,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.material.topology.active_orbitals.n_quantum_number": { @@ -2707,7 +2886,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.j_quantum_number": { "name": "j_quantum_number", @@ -2720,7 +2900,8 @@ window.nomadArtifacts = { "1..2" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.mj_quantum_number": { "name": "mj_quantum_number", @@ -2733,7 +2914,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.degeneracy": { "name": "degeneracy", @@ -2743,7 +2925,8 @@ window.nomadArtifacts = { "type_data": "int32" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.n_electrons_excited": { "name": "n_electrons_excited", @@ -2754,7 +2937,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.occupation": { "name": "occupation", @@ -2764,7 +2948,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.l_quantum_symbol": { "name": "l_quantum_symbol", @@ -2774,7 +2959,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.ml_quantum_symbol": { "name": "ml_quantum_symbol", @@ -2784,7 +2970,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.material.topology.active_orbitals.ms_quantum_symbol": { "name": "ms_quantum_symbol", @@ -2794,7 +2981,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.method.method_id": { "name": "method_id", @@ -2804,7 +2992,8 @@ window.nomadArtifacts = { "type_data": "str" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.method_name": { "name": "method_name", @@ -2828,6 +3017,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.workflow_name": { @@ -2838,6 +3028,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.program_name": { @@ -2849,6 +3040,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.program_version": { @@ -2860,6 +3052,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.program_version_internal": { @@ -2871,6 +3064,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.basis_set_type": { @@ -2891,6 +3085,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.core_electron_treatment": { @@ -2907,6 +3102,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.spin_polarized": { @@ -2917,7 +3113,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.scf_threshold_energy_change": { "name": "scf_threshold_energy_change", @@ -2929,7 +3126,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.van_der_Waals_method": { "name": "van_der_Waals_method", @@ -2941,6 +3139,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.relativity_method": { @@ -2957,6 +3156,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.smearing_kind": { @@ -2969,6 +3169,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.smearing_width": { @@ -2980,7 +3181,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.jacobs_ladder": { "name": "jacobs_ladder", @@ -2998,7 +3200,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.xc_functional_type": { "name": "xc_functional_type", @@ -3016,7 +3219,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.xc_functional_names": { "name": "xc_functional_names", @@ -3030,6 +3234,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dft.exact_exchange_mixing_factor": { @@ -3040,7 +3245,8 @@ window.nomadArtifacts = { "type_data": "float64" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dft.hubbard_kanamori_model.u_effective": { "name": "u_effective", @@ -3052,7 +3258,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.method.simulation.dft.hubbard_kanamori_model.u": { "name": "u", @@ -3064,7 +3271,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.method.simulation.dft.hubbard_kanamori_model.j": { "name": "j", @@ -3076,7 +3284,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.method.simulation.tb.type": { "name": "type", @@ -3093,6 +3302,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.tb.localization_type": { @@ -3107,6 +3317,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.gw.type": { @@ -3128,6 +3339,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.gw.basis_set_type": { @@ -3148,6 +3360,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.gw.starting_point_type": { @@ -3167,7 +3380,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.gw.starting_point_names": { "name": "starting_point_names", @@ -3181,6 +3395,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.bse.type": { @@ -3198,6 +3413,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.bse.basis_set_type": { @@ -3218,6 +3434,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.bse.starting_point_type": { @@ -3237,7 +3454,8 @@ window.nomadArtifacts = { ] }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.bse.starting_point_names": { "name": "starting_point_names", @@ -3251,6 +3469,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.bse.solver": { @@ -3269,6 +3488,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.bse.gw_type": { @@ -3289,6 +3509,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dmft.impurity_solver_type": { @@ -3313,6 +3534,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dmft.inverse_temperature": { @@ -3325,7 +3547,8 @@ window.nomadArtifacts = { "unit": "1 / joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dmft.magnetic_state": { "name": "magnetic_state", @@ -3341,6 +3564,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.dmft.u": { @@ -3353,7 +3577,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dmft.jh": { "name": "jh", @@ -3365,7 +3590,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.dmft.analytical_continuation": { "name": "analytical_continuation", @@ -3381,7 +3607,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.precision.k_line_density": { "name": "k_line_density", @@ -3393,7 +3620,8 @@ window.nomadArtifacts = { "unit": "meter", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.precision.native_tier": { "name": "native_tier", @@ -3404,7 +3632,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.precision.basis_set": { "name": "basis_set", @@ -3429,6 +3658,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.method.simulation.precision.planewave_cutoff": { @@ -3441,7 +3671,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.simulation.precision.apw_cutoff": { "name": "apw_cutoff", @@ -3452,7 +3683,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.method.measurement.xrd.diffraction_method_name": { "name": "diffraction_method_name", @@ -3471,6 +3703,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.n_calculations": { @@ -3481,7 +3714,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.available_properties": { "name": "available_properties", @@ -3494,7 +3728,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structural.radial_distribution_function.type": { "name": "type", @@ -3509,6 +3744,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.structural.radial_distribution_function.label": { @@ -3521,6 +3757,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.structural.radial_distribution_function.provenance.label": { @@ -3532,7 +3769,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.radial_distribution_function.provenance.molecular_dynamics.time_step": { "name": "time_step", @@ -3544,7 +3782,8 @@ window.nomadArtifacts = { "unit": "second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.radial_distribution_function.provenance.molecular_dynamics.ensemble_type": { "name": "ensemble_type", @@ -3560,7 +3799,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.radius_of_gyration.kind": { "name": "kind", @@ -3572,6 +3812,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.structural.radius_of_gyration.label": { @@ -3584,6 +3825,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.structural.radius_of_gyration.provenance.label": { @@ -3595,7 +3837,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.radius_of_gyration.provenance.molecular_dynamics.time_step": { "name": "time_step", @@ -3607,7 +3850,8 @@ window.nomadArtifacts = { "unit": "second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.radius_of_gyration.provenance.molecular_dynamics.ensemble_type": { "name": "ensemble_type", @@ -3623,7 +3867,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structural.diffraction_pattern.incident_beam_wavelength": { "name": "incident_beam_wavelength", @@ -3634,7 +3879,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.dynamical.mean_squared_displacement.type": { "name": "type", @@ -3649,6 +3895,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.dynamical.mean_squared_displacement.label": { @@ -3661,6 +3908,7 @@ window.nomadArtifacts = { "shape": [], "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.dynamical.mean_squared_displacement.provenance.label": { @@ -3672,7 +3920,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.dynamical.mean_squared_displacement.provenance.molecular_dynamics.time_step": { "name": "time_step", @@ -3684,7 +3933,8 @@ window.nomadArtifacts = { "unit": "second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.dynamical.mean_squared_displacement.provenance.molecular_dynamics.ensemble_type": { "name": "ensemble_type", @@ -3700,7 +3950,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.structures.structure_original.nperiodic_dimensions": { "name": "nperiodic_dimensions", @@ -3710,7 +3961,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.n_sites": { "name": "n_sites", @@ -3720,7 +3972,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.cell_volume": { "name": "cell_volume", @@ -3731,7 +3984,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.a": { "name": "a", @@ -3742,7 +3996,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.b": { "name": "b", @@ -3753,7 +4008,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.c": { "name": "c", @@ -3764,7 +4020,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.alpha": { "name": "alpha", @@ -3775,7 +4032,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.beta": { "name": "beta", @@ -3786,7 +4044,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_original.lattice_parameters.gamma": { "name": "gamma", @@ -3797,7 +4056,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.nperiodic_dimensions": { "name": "nperiodic_dimensions", @@ -3807,7 +4067,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.n_sites": { "name": "n_sites", @@ -3817,7 +4078,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.cell_volume": { "name": "cell_volume", @@ -3828,7 +4090,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.a": { "name": "a", @@ -3839,7 +4102,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.b": { "name": "b", @@ -3850,7 +4114,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.c": { "name": "c", @@ -3861,7 +4126,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.alpha": { "name": "alpha", @@ -3872,7 +4138,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.beta": { "name": "beta", @@ -3883,7 +4150,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_conventional.lattice_parameters.gamma": { "name": "gamma", @@ -3894,7 +4162,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.nperiodic_dimensions": { "name": "nperiodic_dimensions", @@ -3904,7 +4173,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.n_sites": { "name": "n_sites", @@ -3914,7 +4184,8 @@ window.nomadArtifacts = { "type_data": "int" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.cell_volume": { "name": "cell_volume", @@ -3925,7 +4196,8 @@ window.nomadArtifacts = { }, "unit": "meter ** 3", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.a": { "name": "a", @@ -3936,7 +4208,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.b": { "name": "b", @@ -3947,7 +4220,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.c": { "name": "c", @@ -3958,7 +4232,8 @@ window.nomadArtifacts = { }, "unit": "meter", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.alpha": { "name": "alpha", @@ -3969,7 +4244,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.beta": { "name": "beta", @@ -3980,7 +4256,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.structures.structure_primitive.lattice_parameters.gamma": { "name": "gamma", @@ -3991,7 +4268,8 @@ window.nomadArtifacts = { }, "unit": "radian", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.electronic.band_gap.index": { "name": "index", @@ -4001,7 +4279,8 @@ window.nomadArtifacts = { "type_data": "int32" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_gap.value": { "name": "value", @@ -4013,7 +4292,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_gap.type": { "name": "type", @@ -4027,7 +4307,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_gap.provenance.label": { "name": "label", @@ -4038,7 +4319,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic.spin_polarized": { "name": "spin_polarized", @@ -4048,7 +4330,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic.band_gap.index": { "name": "index", @@ -4058,7 +4341,8 @@ window.nomadArtifacts = { "type_data": "int32" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic.band_gap.value": { "name": "value", @@ -4070,7 +4354,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic.band_gap.type": { "name": "type", @@ -4084,7 +4369,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic.band_gap.provenance.label": { "name": "label", @@ -4095,7 +4381,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.spin_polarized": { "name": "spin_polarized", @@ -4105,7 +4392,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.has_projected": { "name": "has_projected", @@ -4115,7 +4403,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.data.band_gap.index": { "name": "index", @@ -4125,7 +4414,8 @@ window.nomadArtifacts = { "type_data": "int32" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.data.band_gap.value": { "name": "value", @@ -4137,7 +4427,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.data.band_gap.type": { "name": "type", @@ -4151,7 +4442,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.dos_electronic_new.data.band_gap.provenance.label": { "name": "label", @@ -4162,7 +4454,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_structure_electronic.spin_polarized": { "name": "spin_polarized", @@ -4172,7 +4465,8 @@ window.nomadArtifacts = { "type_data": "bool" }, "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_structure_electronic.band_gap.index": { "name": "index", @@ -4182,7 +4476,8 @@ window.nomadArtifacts = { "type_data": "int32" }, "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_structure_electronic.band_gap.value": { "name": "value", @@ -4194,7 +4489,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_structure_electronic.band_gap.type": { "name": "type", @@ -4208,7 +4504,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.electronic.band_structure_electronic.band_gap.provenance.label": { "name": "label", @@ -4219,7 +4516,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.magnetic.spin_spin_coupling.source": { "name": "source", @@ -4233,6 +4531,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.magnetic.magnetic_susceptibility.source": { @@ -4247,6 +4546,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.optoelectronic.solar_cell.efficiency": { @@ -4258,7 +4558,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.fill_factor": { "name": "fill_factor", @@ -4269,7 +4570,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.open_circuit_voltage": { "name": "open_circuit_voltage", @@ -4281,7 +4583,8 @@ window.nomadArtifacts = { "unit": "volt", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.short_circuit_current_density": { "name": "short_circuit_current_density", @@ -4293,7 +4596,8 @@ window.nomadArtifacts = { "unit": "ampere / meter ** 2", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.illumination_intensity": { "name": "illumination_intensity", @@ -4305,7 +4609,8 @@ window.nomadArtifacts = { "unit": "watt / meter ** 2", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.device_area": { "name": "device_area", @@ -4317,7 +4622,8 @@ window.nomadArtifacts = { "unit": "meter ** 2", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.optoelectronic.solar_cell.device_architecture": { "name": "device_architecture", @@ -4328,6 +4634,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.device_stack": { @@ -4342,6 +4649,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.absorber": { @@ -4356,6 +4664,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.absorber_fabrication": { @@ -4370,6 +4679,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.electron_transport_layer": { @@ -4384,6 +4694,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.hole_transport_layer": { @@ -4398,6 +4709,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.substrate": { @@ -4412,6 +4724,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.optoelectronic.solar_cell.back_contact": { @@ -4426,6 +4739,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.properties.catalytic.reactivity.reaction_name": { @@ -4437,7 +4751,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.reaction_class": { "name": "reaction_class", @@ -4448,7 +4763,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.gas_hourly_space_velocity": { "name": "gas_hourly_space_velocity", @@ -4460,7 +4776,8 @@ window.nomadArtifacts = { "unit": "1 / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.flow_rate": { "name": "flow_rate", @@ -4472,7 +4789,8 @@ window.nomadArtifacts = { "unit": "meter ** 3 / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.test_temperatures": { "name": "test_temperatures", @@ -4486,7 +4804,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.time_on_stream": { "name": "time_on_stream", @@ -4500,7 +4819,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.total_time_on_stream": { "name": "total_time_on_stream", @@ -4512,7 +4832,8 @@ window.nomadArtifacts = { "unit": "second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.pressure": { "name": "pressure", @@ -4526,7 +4847,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.reactivity.reactants.name": { "name": "name", @@ -4537,7 +4859,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.reactants.gas_concentration_in": { "name": "gas_concentration_in", @@ -4548,7 +4871,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.reactants.gas_concentration_out": { "name": "gas_concentration_out", @@ -4559,7 +4883,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.reactants.conversion": { "name": "conversion", @@ -4570,7 +4895,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.products.name": { "name": "name", @@ -4581,7 +4907,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.products.gas_concentration_out": { "name": "gas_concentration_out", @@ -4592,7 +4919,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.products.selectivity": { "name": "selectivity", @@ -4603,7 +4931,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.products.space_time_yield": { "name": "space_time_yield", @@ -4615,7 +4944,8 @@ window.nomadArtifacts = { "unit": "1 / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.rates.reaction_rate": { "name": "reaction_rate", @@ -4627,7 +4957,8 @@ window.nomadArtifacts = { "unit": "mole / gram / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.rates.specific_mass_rate": { "name": "specific_mass_rate", @@ -4639,7 +4970,8 @@ window.nomadArtifacts = { "unit": "mole / gram / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.rates.specific_surface_area_rate": { "name": "specific_surface_area_rate", @@ -4651,7 +4983,8 @@ window.nomadArtifacts = { "unit": "mole / meter ** 2 / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.reactivity.rates.turn_over_frequency": { "name": "turn_over_frequency", @@ -4663,7 +4996,8 @@ window.nomadArtifacts = { "unit": "1 / second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.catalytic.catalyst_synthesis.catalyst_name": { "name": "catalyst_name", @@ -4674,7 +5008,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.catalyst_synthesis.preparation_method": { "name": "preparation_method", @@ -4685,7 +5020,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.catalyst_synthesis.catalyst_type": { "name": "catalyst_type", @@ -4696,7 +5032,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.catalyst_characterization.surface_area": { "name": "surface_area", @@ -4708,7 +5045,8 @@ window.nomadArtifacts = { "unit": "meter ** 2 / gram", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.catalytic.catalyst_characterization.method_surface_area": { "name": "method_surface_area", @@ -4719,7 +5057,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.mechanical.energy_volume_curve.type": { "name": "type", @@ -4740,6 +5079,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.mechanical.bulk_modulus.type": { @@ -4764,6 +5104,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.mechanical.bulk_modulus.value": { @@ -4775,7 +5116,8 @@ window.nomadArtifacts = { }, "unit": "pascal", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.mechanical.shear_modulus.type": { "name": "type", @@ -4790,6 +5132,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.mechanical.shear_modulus.value": { @@ -4801,7 +5144,8 @@ window.nomadArtifacts = { }, "unit": "pascal", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.thermodynamic.trajectory.available_properties": { "name": "available_properties", @@ -4819,7 +5163,8 @@ window.nomadArtifacts = { "0..*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.thermodynamic.trajectory.provenance.label": { "name": "label", @@ -4830,7 +5175,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step": { "name": "time_step", @@ -4842,7 +5188,8 @@ window.nomadArtifacts = { "unit": "second", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type": { "name": "ensemble_type", @@ -4858,7 +5205,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.spectroscopic.spectra.type": { "name": "type", @@ -4878,6 +5226,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.spectroscopic.spectra.label": { @@ -4892,6 +5241,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.spectroscopic.spectra.provenance.label": { @@ -4903,7 +5253,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.spectroscopic.spectra.provenance.eels.detector_type": { "name": "detector_type", @@ -4914,6 +5265,7 @@ window.nomadArtifacts = { }, "aggregatable": true, "dynamic": false, + "repeats": true, "suggestion": true }, "results.properties.spectroscopic.spectra.provenance.eels.resolution": { @@ -4925,7 +5277,8 @@ window.nomadArtifacts = { }, "unit": "joule", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.spectroscopic.spectra.provenance.eels.max_energy": { "name": "max_energy", @@ -4936,7 +5289,8 @@ window.nomadArtifacts = { }, "unit": "joule", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.spectroscopic.spectra.provenance.eels.min_energy": { "name": "min_energy", @@ -4947,7 +5301,8 @@ window.nomadArtifacts = { }, "unit": "joule", "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.spectroscopic.spectra.provenance.electronic_structure.label": { "name": "label", @@ -4958,7 +5313,8 @@ window.nomadArtifacts = { }, "shape": [], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": true }, "results.properties.geometry_optimization.convergence_tolerance_energy_difference": { "name": "convergence_tolerance_energy_difference", @@ -4970,7 +5326,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.geometry_optimization.convergence_tolerance_force_maximum": { "name": "convergence_tolerance_force_maximum", @@ -4982,7 +5339,8 @@ window.nomadArtifacts = { "unit": "newton", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.geometry_optimization.final_force_maximum": { "name": "final_force_maximum", @@ -4994,7 +5352,8 @@ window.nomadArtifacts = { "unit": "newton", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.geometry_optimization.final_energy_difference": { "name": "final_energy_difference", @@ -5006,7 +5365,8 @@ window.nomadArtifacts = { "unit": "joule", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.properties.geometry_optimization.final_displacement_maximum": { "name": "final_displacement_maximum", @@ -5018,7 +5378,8 @@ window.nomadArtifacts = { "unit": "meter", "shape": [], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.sections": { "name": "sections", @@ -5031,7 +5392,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.tags": { "name": "tags", @@ -5044,7 +5406,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.names": { "name": "names", @@ -5057,7 +5420,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.descriptions": { "name": "descriptions", @@ -5070,7 +5434,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": false, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.instruments": { "name": "instruments", @@ -5083,7 +5448,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.methods": { "name": "methods", @@ -5096,7 +5462,8 @@ window.nomadArtifacts = { "*" ], "aggregatable": true, - "dynamic": false + "dynamic": false, + "repeats": false }, "results.eln.lab_ids": { "name": "lab_ids", @@ -5110,6 +5477,7 @@ window.nomadArtifacts = { ], "aggregatable": true, "dynamic": false, + "repeats": false, "suggestion": true }, "results.material": { diff --git a/gui/tests/env.js b/gui/tests/env.js index bf5a2a57cc5e61b7e835f996b5e89c079fbef21c..3e413294bc539faf2765d9d8bcc051b84c4a0a51 100644 --- a/gui/tests/env.js +++ b/gui/tests/env.js @@ -957,44 +957,44 @@ window.nomadEnv = { "type": "periodictable", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 9, "w": 13, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 11, "w": 14, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 11, "w": 14, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 8, "w": 12, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 8, "w": 12, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.elements", @@ -1004,44 +1004,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 9, "w": 6, "x": 30, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 24, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 5, "x": 19, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 12, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 13 + "y": 13, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.symmetry.space_group_symbol", @@ -1052,44 +1052,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 9, "w": 6, "x": 19, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 11, "w": 5, "x": 19, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 19, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.structural_type", @@ -1100,44 +1100,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 9, "w": 6, "x": 13, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 11, "w": 5, "x": 14, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 14, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 8, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 } }, "quantity": "results.method.simulation.program_name", @@ -1148,44 +1148,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 9, "w": 5, "x": 25, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 24, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 5, "x": 14, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 6, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 13 + "y": 13, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.symmetry.crystal_system", @@ -1892,44 +1892,44 @@ window.nomadEnv = { "type": "periodictable", "layout": { "xxl": { - "minH": 8, - "minW": 12, "h": 8, "w": 13, "x": 0, - "y": 0 + "y": 0, + "minH": 8, + "minW": 12 }, "xl": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 0 + "y": 0, + "minH": 8, + "minW": 12 }, "lg": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 0 + "y": 0, + "minH": 8, + "minW": 12 }, "md": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 0 + "y": 0, + "minH": 8, + "minW": 12 }, "sm": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 16 + "y": 16, + "minH": 8, + "minW": 12 } }, "quantity": "results.material.elements", @@ -1939,49 +1939,57 @@ window.nomadEnv = { "type": "scatterplot", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 8, "w": 12, "x": 24, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 8, "w": 9, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 12, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 9, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 + } + }, + "x": { + "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage" + }, + "y": { + "quantity": "results.properties.optoelectronic.solar_cell.efficiency" + }, + "markers": { + "color": { + "quantity": "results.properties.optoelectronic.solar_cell.short_circuit_current_density" } }, - "x": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", - "y": "results.properties.optoelectronic.solar_cell.efficiency", - "color": "results.properties.optoelectronic.solar_cell.short_circuit_current_density", "size": 1000, "autorange": true }, @@ -1989,49 +1997,57 @@ window.nomadEnv = { "type": "scatterplot", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 8, "w": 11, "x": 13, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 8, "w": 9, "x": 21, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 12, "x": 0, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 9, "x": 9, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 + } + }, + "x": { + "quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage" + }, + "y": { + "quantity": "results.properties.optoelectronic.solar_cell.efficiency" + }, + "markers": { + "color": { + "quantity": "results.properties.optoelectronic.solar_cell.device_architecture" } }, - "x": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", - "y": "results.properties.optoelectronic.solar_cell.efficiency", - "color": "results.properties.optoelectronic.solar_cell.device_architecture", "size": 1000, "autorange": true }, @@ -2039,44 +2055,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 14, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 14, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 4 + "y": 4, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 6, "w": 4, "x": 0, - "y": 10 + "y": 10, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.optoelectronic.solar_cell.device_stack", @@ -2087,44 +2103,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 3, "w": 8, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 3, "w": 8, "x": 0, - "y": 11 + "y": 11, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 4, "w": 12, "x": 12, - "y": 12 + "y": 12, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 3, "w": 8, "x": 10, - "y": 17 + "y": 17, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 8, "x": 4, - "y": 13 + "y": 13, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity", @@ -2137,44 +2153,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 8, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 8, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 18, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 0, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.optoelectronic.solar_cell.absorber_fabrication", @@ -2185,44 +2201,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 11 + "y": 11, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 12, "x": 12, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 10, - "y": 14 + "y": 14, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 4, - "y": 10 + "y": 10, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", @@ -2235,44 +2251,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 20, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 25, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 18, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 0, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 4, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.optoelectronic.solar_cell.electron_transport_layer", @@ -2283,44 +2299,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 26, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 20, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 12, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 5, "x": 5, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 8, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.optoelectronic.solar_cell.hole_transport_layer", @@ -2538,44 +2554,44 @@ window.nomadEnv = { "type": "periodictable", "layout": { "xxl": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 5 + "y": 5, + "minH": 8, + "minW": 12 }, "xl": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 5 + "y": 5, + "minH": 8, + "minW": 12 }, "lg": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 6 + "y": 6, + "minH": 8, + "minW": 12 }, "md": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 5 + "y": 5, + "minH": 8, + "minW": 12 }, "sm": { - "minH": 8, - "minW": 12, "h": 8, "w": 12, "x": 0, - "y": 5 + "y": 5, + "minH": 8, + "minW": 12 } }, "quantity": "results.material.elements", @@ -2585,44 +2601,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.catalytic.reactivity.reactants.name", @@ -2633,44 +2649,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 8, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.catalytic.reactivity.reaction_name", @@ -2681,44 +2697,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 12, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 6, "w": 6, "x": 6, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 5, "w": 6, "x": 6, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 4, "x": 4, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.catalytic.reactivity.products.name", @@ -2729,44 +2745,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 5 + "y": 5, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 4, "x": 8, - "y": 13 + "y": 13, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.catalytic.catalyst_synthesis.preparation_method", @@ -2777,44 +2793,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 10 + "y": 10, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 6, "x": 12, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 4, "x": 8, - "y": 16 + "y": 16, + "minH": 3, + "minW": 3 } }, "quantity": "results.properties.catalytic.catalyst_synthesis.catalyst_type", @@ -2825,44 +2841,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 14 + "y": 14, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.test_temperatures", @@ -2875,44 +2891,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 17 + "y": 17, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 18 + "y": 18, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 9, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 22 + "y": 22, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.gas_hourly_space_velocity", @@ -2925,44 +2941,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 9, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 9, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 9, - "y": 14 + "y": 14, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 9, - "y": 13 + "y": 13, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.reactants.gas_concentration_in", @@ -2975,44 +2991,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 9, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 9, - "y": 17 + "y": 17, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 9, - "y": 14 + "y": 14, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 16 + "y": 16, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.pressure", @@ -3025,44 +3041,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 19 + "y": 19, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 21 + "y": 21, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 26 + "y": 26, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 22 + "y": 22, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 33 + "y": 33, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.products.selectivity", @@ -3075,44 +3091,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 22 + "y": 22, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 25 + "y": 25, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 22 + "y": 22, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 19 + "y": 19, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 30 + "y": 30, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.reactants.conversion", @@ -3125,44 +3141,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 8, - "y": 25 + "y": 25, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 9, - "y": 29 + "y": 29, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 12, "x": 0, - "y": 30 + "y": 30, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 25 + "y": 25, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 36 + "y": 36, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.reactivity.rates.reaction_rate", @@ -3175,49 +3191,57 @@ window.nomadEnv = { "type": "scatterplot", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 10, "x": 8, - "y": 19 + "y": 19, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 8, "w": 9, "x": 9, - "y": 21 + "y": 21, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 8, "w": 9, "x": 9, - "y": 22 + "y": 22, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 6, "w": 9, "x": 9, - "y": 19 + "y": 19, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 5, "w": 8, "x": 9, - "y": 25 + "y": 25, + "minH": 3, + "minW": 3 + } + }, + "x": { + "quantity": "results.properties.catalytic.reactivity.reactants.conversion" + }, + "y": { + "quantity": "results.properties.catalytic.reactivity.products.selectivity" + }, + "markers": { + "color": { + "quantity": "results.properties.catalytic.catalyst_characterization.surface_area" } }, - "x": "results.properties.catalytic.reactivity.reactants.conversion", - "y": "results.properties.catalytic.reactivity.products.selectivity", - "color": "results.properties.catalytic.catalyst_characterization.surface_area", "size": 1000, "autorange": true }, @@ -3225,44 +3249,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 25 + "y": 25, + "minH": 3, + "minW": 8 }, "xl": { - "minH": 3, - "minW": 8, "h": 4, "w": 9, "x": 0, - "y": 29 + "y": 29, + "minH": 3, + "minW": 8 }, "lg": { - "minH": 3, - "minW": 8, "h": 4, "w": 12, "x": 0, - "y": 34 + "y": 34, + "minH": 3, + "minW": 8 }, "md": { - "minH": 3, - "minW": 8, "h": 3, "w": 9, "x": 0, - "y": 28 + "y": 28, + "minH": 3, + "minW": 8 }, "sm": { - "minH": 3, - "minW": 8, "h": 3, "w": 8, "x": 0, - "y": 39 + "y": 39, + "minH": 3, + "minW": 8 } }, "quantity": "results.properties.catalytic.catalyst_characterization.surface_area", @@ -3394,44 +3418,44 @@ window.nomadEnv = { "type": "periodictable", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 10, "w": 25, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 9, "w": 19, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 9, "w": 15, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 8, "w": 11, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 6, "w": 9, "x": 0, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.elements", @@ -3441,44 +3465,44 @@ window.nomadEnv = { "type": "terms", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 10, "w": 11, "x": 25, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 9, "w": 11, "x": 19, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 9, "w": 9, "x": 15, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 8, "w": 7, "x": 11, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 6, "w": 3, "x": 9, - "y": 0 + "y": 0, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.topology.sbu_type", @@ -3489,44 +3513,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 19, "x": 0, - "y": 10 + "y": 10, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 15, "x": 0, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 12, "x": 0, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 9, "x": 0, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 6, "x": 0, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.topology.pore_limiting_diameter", @@ -3539,44 +3563,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 17, "x": 19, - "y": 10 + "y": 10, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 15, "x": 0, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 12, "x": 0, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 9, "x": 9, - "y": 8 + "y": 8, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 6, "x": 6, - "y": 6 + "y": 6, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.topology.largest_cavity_diameter", @@ -3589,44 +3613,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 19, "x": 0, - "y": 16 + "y": 16, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 15, "x": 15, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 12, "x": 11, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 9, "x": 0, - "y": 12 + "y": 12, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 6, "x": 0, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.topology.accessible_surface_area", @@ -3639,44 +3663,44 @@ window.nomadEnv = { "type": "histogram", "layout": { "xxl": { - "minH": 3, - "minW": 3, "h": 6, "w": 17, "x": 19, - "y": 16 + "y": 16, + "minH": 3, + "minW": 3 }, "xl": { - "minH": 3, - "minW": 3, "h": 5, "w": 15, "x": 15, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "lg": { - "minH": 3, - "minW": 3, "h": 5, "w": 12, "x": 11, - "y": 14 + "y": 14, + "minH": 3, + "minW": 3 }, "md": { - "minH": 3, - "minW": 3, "h": 4, "w": 9, "x": 9, - "y": 12 + "y": 12, + "minH": 3, + "minW": 3 }, "sm": { - "minH": 3, - "minW": 3, "h": 3, "w": 6, "x": 6, - "y": 9 + "y": 9, + "minH": 3, + "minW": 3 } }, "quantity": "results.material.topology.void_fraction", diff --git a/gui/yarn.lock b/gui/yarn.lock index 57b91c21cedeb6435e5b111a04588174d1e261c0..1901854ecba20c9611aca13662baa8669f1b4fb3 100644 --- a/gui/yarn.lock +++ b/gui/yarn.lock @@ -10039,6 +10039,11 @@ jest@26.6.0: import-local "^3.0.2" jest-cli "^26.6.0" +jmespath@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.16.0.tgz#b15b0a85dfd4d930d43e69ed605943c802785076" + integrity sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw== + js-levenshtein@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" diff --git a/nomad/cli/dev.py b/nomad/cli/dev.py index e1e9dbe7f2befaadc2098b58881decca1c73c336..37213205b8313cdf13bd70e7cf91bfba783009b4 100644 --- a/nomad/cli/dev.py +++ b/nomad/cli/dev.py @@ -177,12 +177,14 @@ def _generate_search_quantities(): 'aliases', 'aggregatable', 'dynamic', + 'repeats', ] metadict = search_quantity.definition.m_to_dict(with_meta=True) # UI needs to know whether the quantity can be used in # aggregations or not. metadict['aggregatable'] = search_quantity.aggregatable metadict['dynamic'] = search_quantity.dynamic + metadict['repeats'] = search_quantity.repeats if search_quantity.dynamic: splitted = search_quantity.qualified_name.split(schema_separator, 1) if len(splitted) == 2: diff --git a/nomad/config/models.py b/nomad/config/models.py index c911d9c19dcc29977112492d4fe1df1c1faa9965..177cbdd397bd996b96e205b31fbb34e28a0de41d 100644 --- a/nomad/config/models.py +++ b/nomad/config/models.py @@ -1300,12 +1300,12 @@ class SearchSyntaxes(StrictSettings): class Layout(StrictSettings): """Defines widget size and grid positioning for different breakpoints.""" - minH: int = Field(description='Minimum height in grid units.') - minW: int = Field(description='Minimum width in grid units.') h: int = Field(description='Height in grid units') w: int = Field(description='Width in grid units.') x: int = Field(description='Horizontal start location in the grid.') y: int = Field(description='Vertical start location in the grid.') + minH: Optional[int] = Field(3, description='Minimum height in grid units.') + minW: Optional[int] = Field(3, description='Minimum width in grid units.') class ScaleEnum(str, Enum): @@ -1323,6 +1323,31 @@ class BreakpointEnum(str, Enum): XXL = 'xxl' +class Axis(StrictSettings): + """Configuration for a plot axis.""" + + title: Optional[str] = Field(description="""Custom title to show for the axis.""") + unit: Optional[str] = Field( + description="""Custom unit used for displaying the values.""" + ) + quantity: str = Field( + description=""" + Path of the targeted quantity. Note that you can most of the features + JMESPath syntax here to further specify a selection of values. This + becomes especially useful when dealing with repeated sections or + statistical values. + """ + ) + + +class Markers(StrictSettings): + """Configuration for plot markers.""" + + color: Optional[Axis] = Field( + description='Configures the information source and display options for the marker colors.' + ) + + class Widget(StrictSettings): """Common configuration for all widgets.""" @@ -1366,7 +1391,7 @@ class WidgetHistogram(Widget): description=""" Maximum number of histogram bins. Notice that the actual number of bins may be smaller if there are fewer data items available. - """ + """ ) @@ -1386,13 +1411,27 @@ class WidgetScatterPlot(Widget): type: Literal['scatterplot'] = Field( description='Set as `scatterplot` to get this widget type.' ) - x: str = Field(description='X-axis quantity.') - y: str = Field(description='Y-axis quantity.') - color: Optional[str] = Field(description='Quantity used for coloring points.') + x: Union[Axis, str] = Field( + description='Configures the information source and display options for the x-axis.' + ) + y: Union[Axis, str] = Field( + description='Configures the information source and display options for the y-axis.' + ) + markers: Optional[Markers] = Field( + description='Configures the information source and display options for the markers.' + ) + color: Optional[str] = Field( + description=""" + Quantity used for coloring points. Note that this field is deprecated + and `markers` should be used instead. + """ + ) size: int = Field( 1000, description=""" - Maximum number of data points to fetch. Notice that the actual number may be less. + Maximum number of entries to fetch. Notice that the actual number may be + more of less, depending on how many entries exist and how many of the + requested values each entry contains. """, ) autorange: bool = Field( @@ -1400,6 +1439,21 @@ class WidgetScatterPlot(Widget): description='Whether to automatically set the range according to the data limits.', ) + @root_validator(pre=True) + def backwards_compatibility(cls, values): + """Ensures backwards compatibility of x, y, and color.""" + color = values.get('color') + if color is not None: + values['markers'] = {'color': {'quantity': color}} + del values['color'] + x = values.get('x') + if isinstance(x, str): + values['x'] = {'quantity': x} + y = values.get('y') + if isinstance(y, str): + values['y'] = {'quantity': y} + return values + # The 'discriminated union' feature of Pydantic is used here: # https://docs.pydantic.dev/usage/types/#discriminated-unions-aka-tagged-unions @@ -2527,9 +2581,17 @@ class UI(StrictSettings): 'type': 'scatterplot', 'autorange': True, 'size': 1000, - 'color': 'results.properties.optoelectronic.solar_cell.short_circuit_current_density', - 'y': 'results.properties.optoelectronic.solar_cell.efficiency', - 'x': 'results.properties.optoelectronic.solar_cell.open_circuit_voltage', + 'x': { + 'quantity': 'results.properties.optoelectronic.solar_cell.open_circuit_voltage' + }, + 'y': { + 'quantity': 'results.properties.optoelectronic.solar_cell.efficiency' + }, + 'markers': { + 'color': { + 'quantity': 'results.properties.optoelectronic.solar_cell.short_circuit_current_density' + } + }, 'layout': { 'xxl': { 'minH': 3, @@ -2577,9 +2639,17 @@ class UI(StrictSettings): 'type': 'scatterplot', 'autorange': True, 'size': 1000, - 'color': 'results.properties.optoelectronic.solar_cell.device_architecture', - 'y': 'results.properties.optoelectronic.solar_cell.efficiency', - 'x': 'results.properties.optoelectronic.solar_cell.open_circuit_voltage', + 'y': { + 'quantity': 'results.properties.optoelectronic.solar_cell.efficiency' + }, + 'x': { + 'quantity': 'results.properties.optoelectronic.solar_cell.open_circuit_voltage', + }, + 'markers': { + 'color': { + 'quantity': 'results.properties.optoelectronic.solar_cell.device_architecture', + } + }, 'layout': { 'xxl': { 'minH': 3, diff --git a/nomad/metainfo/elasticsearch_extension.py b/nomad/metainfo/elasticsearch_extension.py index 3c11639ecaa2130e38dde33cda87d16fff93919e..57b43e68f3334176756025faf5e6e87bb9978bc5 100644 --- a/nomad/metainfo/elasticsearch_extension.py +++ b/nomad/metainfo/elasticsearch_extension.py @@ -381,6 +381,7 @@ class DocumentType: section_def: Section, prefix: str = None, auto_include_subsections: bool = False, + repeats: bool = False, ): mappings: Dict[str, Any] = {} @@ -415,6 +416,7 @@ class DocumentType: reference_mapping = self._create_mapping_recursive( cast(Section, quantity_def.type.target_section_def), prefix=qualified_name, + repeats=repeats, ) if len(reference_mapping['properties']) > 0: mappings[quantity_def.name] = reference_mapping @@ -429,7 +431,7 @@ class DocumentType: mapping.update(**elasticsearch_annotation.mapping) self.indexed_properties.add(quantity_def) - self._register(elasticsearch_annotation, prefix) + self._register(elasticsearch_annotation, prefix, repeats) for sub_section_def in section_def.all_sub_sections.values(): annotation = sub_section_def.m_get_annotations(Elasticsearch) @@ -456,6 +458,7 @@ class DocumentType: sub_section_def.sub_section, prefix=qualified_name, auto_include_subsections=continue_with_auto_include_subsections, + repeats=repeats or sub_section_def.repeats, ) nested = annotation is not None and annotation.nested @@ -512,21 +515,22 @@ class DocumentType: # creating the dynamic quantities, this is the only way to prevent # infinite recursion, but it should be made possible in the GUI + search # API to query arbitrarily deep into the data structure. - def get_all_quantities(m_def, prefix=None, branch=None): + def get_all_quantities(m_def, prefix=None, branch=None, repeats=False): if branch is None: branch = set() for quantity_name, quantity in m_def.all_quantities.items(): quantity_name = f'{prefix}.{quantity_name}' if prefix else quantity_name - yield quantity, quantity_name + yield quantity, quantity_name, repeats for sub_section_def in m_def.all_sub_sections.values(): if sub_section_def in branch: continue new_branch = set(branch) new_branch.add(sub_section_def) name = sub_section_def.name + repeats = sub_section_def.repeats full_name = f'{prefix}.{name}' if prefix else name for item in get_all_quantities( - sub_section_def.sub_section, full_name, new_branch + sub_section_def.sub_section, full_name, new_branch, repeats ): yield item @@ -544,7 +548,7 @@ class DocumentType: section.section_cls, EntryData ): schema_name = section.qualified_name() - for quantity_def, path in get_all_quantities(section): + for quantity_def, path, repeats in get_all_quantities(section): annotation = create_dynamic_quantity_annotation( quantity_def ) @@ -552,13 +556,15 @@ class DocumentType: continue full_name = f'data.{path}{schema_separator}{schema_name}' search_quantity = SearchQuantity( - annotation, qualified_name=full_name + annotation, qualified_name=full_name, repeats=repeats ) quantities_dynamic[full_name] = search_quantity self.quantities.update(quantities_dynamic) - def _register(self, annotation, prefix): - search_quantity = SearchQuantity(annotation=annotation, prefix=prefix) + def _register(self, annotation, prefix, repeats): + search_quantity = SearchQuantity( + annotation=annotation, prefix=prefix, repeats=repeats + ) name = search_quantity.qualified_name assert ( @@ -1016,6 +1022,7 @@ class SearchQuantity: a qualified name that pin points its place in the sub-section hierarchy. Attributes: + annotation: The ES annotation that this search quantity is based on qualified_field: The full qualified name of the resulting elasticsearch field in the entry document type. This will be the quantity name (plus additional @@ -1026,11 +1033,15 @@ class SearchQuantity: qualified_name: Same name as qualified_field. This will be used to address the search property in our APIs. - definition: The metainfo quantity definition that this search quantity is based on + repeats: Whether this quantity is inside at least one repeatable section """ def __init__( - self, annotation: Elasticsearch, prefix: str = None, qualified_name: str = None + self, + annotation: Elasticsearch, + prefix: str = None, + qualified_name: str = None, + repeats: bool = False, ): """ Args: @@ -1056,6 +1067,7 @@ class SearchQuantity: self.qualified_field = qualified_field self.qualified_name = qualified_field + self.repeats = repeats if annotation.dynamic: self.qualified_name = qualified_name diff --git a/tests/data/schemas/nomadschemaexample/schema.py b/tests/data/schemas/nomadschemaexample/schema.py index 0e93759294e66817482a54c91a16c81dffecfb44..6d93b8d258549b38265c622ede60334f594baa69 100644 --- a/tests/data/schemas/nomadschemaexample/schema.py +++ b/tests/data/schemas/nomadschemaexample/schema.py @@ -20,7 +20,18 @@ class MySection(MSection): name = Quantity( type=str, a_eln=ELNAnnotation(component=ELNComponentEnum.StringEditQuantity), - description='For testing subsection quantity.', + description='For testing subsection string quantity.', + ) + count = Quantity( + type=int, + a_eln=ELNAnnotation(component=ELNComponentEnum.NumberEditQuantity), + description='For testing subsection integer quantity.', + ) + frequency = Quantity( + type=float, + unit='1/s', + a_eln=ELNAnnotation(component=ELNComponentEnum.NumberEditQuantity), + description='For testing subsection floating point quantity.', )