Commit 6faa1770 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Merge branch '723-gui-updates' into 'v1.0.0'

Resolve "GUI updates"

See merge request !534
parents 0b5a19e7 cccfb001
Pipeline #120489 passed with stages
in 30 minutes and 53 seconds
......@@ -333,7 +333,6 @@ const DatatableRow = React.memo(function DatatableRow({data, selected, uncollaps
const numberOfColumns = columns.length + (withSelectionFeature ? 1 : 0) + (actions ? 1 : 0)
const handleRowCollapseChange = (event) => {
event.stopPropagation()
onRowUncollapsed(uncollapsed ? null : row)
}
......
......@@ -24,30 +24,34 @@ import GeometryOptimization from '../../visualization/GeometryOptimization'
export default function GeometryOptimizationCard({index, archive, properties}) {
const units = useUnits()
const geoOptProps = index?.results?.properties?.geometry_optimization
// Find out which properties are present
const hasGeometryOptimization = properties.has('geometry_optimization')
// Find out which properties are present. If only one step is calculated
// (n_calculations == 1 and none of the convergence criteria are available),
// the card will not be displayed.
const hasEnergies = properties.has('geometry_optimization') &&
index?.results?.properties?.n_calculations > 1
const hasConvergence = properties.has('geometry_optimization') &&
(
index?.results?.properties?.geometry_optimization?.final_energy_difference ||
index?.results?.properties?.geometry_optimization?.final_displacement_maximum ||
index?.results?.properties?.geometry_optimization?.final_force_maximum
)
// Do not show the card if none of the properties are available, or if only
// one step is calculated.
if (!hasGeometryOptimization) {
// Do not show the card if none of the properties are available
if (!hasEnergies && !hasConvergence) {
return null
}
// Resolve energies
let energies = hasGeometryOptimization ? null : false
const geoOptPropsArchive = archive?.results?.properties?.geometry_optimization
if (hasGeometryOptimization && archive) {
energies = resolveRef(geoOptPropsArchive.energies, archive)
let energies = hasEnergies ? null : false
const energiesArchive = archive?.results?.properties?.geometry_optimization?.energies
if (hasEnergies && energiesArchive) {
energies = resolveRef(energiesArchive, archive)
}
// Resolve convergence properties
let convergence = false
const geoOptProps = index?.results?.properties?.geometry_optimization
const geoOptMethod = index.results?.method?.simulation?.geometry_optimization
if (hasGeometryOptimization) {
convergence = {...geoOptMethod, ...geoOptProps}
}
let convergence = hasConvergence ? geoOptProps : false
return <PropertyCard title="Geometry optimization">
<GeometryOptimization
......
......@@ -33,7 +33,7 @@ import UploadsPage, { help as uploadsHelp } from '../uploads/UploadsPage'
import UserdataPage, { help as userdataHelp } from '../UserdataPage'
import APIs from '../APIs'
import SearchPageEntries, {help as searchEntriesHelp} from '../search/SearchPageEntries'
import SearchPageMaterials, {help as searchMaterialsHelp} from '../search/SearchPageMaterials'
// import SearchPageMaterials, {help as searchMaterialsHelp} from '../search/SearchPageMaterials'
import { aitoolkitEnabled, appBase, oasis, encyclopediaBase } from '../../config'
import EntryQuery from '../entry/EntryQuery'
import ResolvePID from '../entry/ResolvePID'
......@@ -244,18 +244,23 @@ export const routes = [
routes: entryRoutes
},
{
path: 'materials',
exact: true,
cache: 'always',
component: SearchPageMaterials,
menu: 'Material Encyclopedia',
tooltip: 'Search materials',
breadcrumb: 'Materials search',
help: {
title: 'Searching for materials',
content: searchMaterialsHelp
}
href: 'https://nomad-lab.eu/prod/rae/encyclopedia',
tooltip: 'Search materials in the NOMAD Encyclopedia'
}
// {
// path: 'materials',
// exact: true,
// cache: 'always',
// component: SearchPageMaterials,
// menu: 'Material Encyclopedia',
// tooltip: 'Search materials',
// breadcrumb: 'Materials search',
// help: {
// title: 'Searching for materials',
// content: searchMaterialsHelp
// }
// }
]
},
{
......
......@@ -18,7 +18,7 @@
import { isNil } from 'lodash'
import { setToArray, getDatatype, getSerializer, getDeserializer, getLabel } from '../../utils'
import searchQuantities from '../../searchQuantities'
import { getDimension } from '../../units'
import { getDimension, Quantity } from '../../units'
import InputList from './input/InputList'
import InputPeriodicTable from './input/InputPeriodicTable'
import elementData from '../../elementData'
......@@ -76,6 +76,10 @@ export const labelArchive = 'Archive'
* As a shortcut you can provide an ES aggregation config as a string,
* e.g. "terms".
* - aggDefaultSize: The default aggregation size, may be overridden.
* - minOverride: Used to override the minimum value from a min_max
* aggregation for this field. Use SI units.
* - maxOverride: Used to override the maximum value from a min_max
* aggregation for this field. Use SI units.
* - value: Object containing a custom setter/getter for the filter value.
* - multiple: Whether the user can simultaneously provide multiple values for
* this filter.
......@@ -130,6 +134,21 @@ function saveFilter(name, group, config) {
data.stats = config.stats
data.options = config.options
data.unit = config.unit || searchQuantities[name]?.unit
data.minOverride = config.minOverride
data.maxOverride = config.maxOverride
if (data.unit) {
const unitDimension = getDimension(data.unit)
if (data.minOverride && unitDimension !== getDimension(data.minOverride.unit)) {
console.log(unitDimension)
console.log(getDimension(data.minOverride.unit))
throw Error('The dimension for minOverride and the filter unit do not match.')
}
if (data.maxOverride && unitDimension !== getDimension(data.maxOverride.unit)) {
console.log(unitDimension)
console.log(getDimension(data.maxOverride.unit))
throw Error('The dimension for maxOverride and the filter unit do not match.')
}
}
data.dtype = config.dtype || getDatatype(name)
data.serializerExact = getSerializer(data.dtype, false)
data.serializerPretty = getSerializer(data.dtype, true)
......@@ -340,7 +359,7 @@ registerFilter(
nestedQuantity,
[
{name: 'type', ...termQuantity},
{name: 'value', ...rangeQuantity}
{name: 'value', minOverride: new Quantity(0, 'electron_volt'), ...rangeQuantity}
]
)
registerFilter(
......@@ -379,9 +398,9 @@ registerFilter(
labelGeometryOptimization,
nestedQuantity,
[
{name: 'final_energy_difference', ...rangeQuantity},
{name: 'final_displacement_maximum', ...rangeQuantity},
{name: 'final_force_maximum', ...rangeQuantity}
{name: 'final_energy_difference', maxOverride: new Quantity(0.1, 'electron_volt'), ...rangeQuantity},
{name: 'final_displacement_maximum', maxOverride: new Quantity(1, 'angstrom'), ...rangeQuantity},
{name: 'final_force_maximum', maxOverride: new Quantity(1E-6, 'newton'), ...rangeQuantity}
]
)
......
......@@ -110,10 +110,12 @@ const Search = React.memo(({
<Box marginBottom={2}>
<SearchBar className={styles.searchBar} />
</Box>
<Box marginBottom={2}>
<Box marginBottom={2} position="relative" zIndex={0}>
<StatisticsGrid/>
</Box>
<SearchResults />
<Box position="relative" zIndex={1}>
<SearchResults />
</Box>
<div className={clsx(styles.shadow, isMenuOpen && styles.shadowVisible)}></div>
</Box>
</div>
......
......@@ -689,9 +689,61 @@ export const SearchContext = React.memo(({
const [pagination, setPagination] = useRecoilState(paginationState)
const updateQueryString = useUpdateQueryString()
// All of the heavier pre-processing, checking, etc. should be done in this
// function, as it is the final one that gets called after the debounce
// interval.
/**
* This function is used to sync up API calls so that they update the search
* context state in the same order as they were originally issued.
*
* As we cannot guarantee the order in which the API calls finish, we push all
* calls into a queue. The queue makes sure that API calls get resolved in the
* original order not matter how long the actual call takes.
*/
const resolve = useCallback(prop => {
const {response, timestamp, queryChanged, paginationChanged, search, aggsToUpdate, resource, callback} = prop
const data = response.response
let next = apiQueue.current[0]
if (next !== timestamp) {
apiMap.current[timestamp] = prop
return
}
// Update the aggregations if new aggregation data is received. The old
// aggregation data is preserved and new information is updated.
if (!isEmpty(data.aggregations)) {
const newAggs = toGUIAgg(data.aggregations, aggsToUpdate, resource)
callback && callback(newAggs)
updateAggsResponse(newAggs)
} else {
callback && callback(null)
}
// Update the query results if new data is received.
if (queryChanged || paginationChanged) {
const isExtend = search.pagination.page_after_value
paginationResponse.current = data.pagination
setResults(old => {
const newResults = old ? {...old} : {}
isExtend ? newResults.data = [...newResults.data, ...data.data] : newResults.data = data.data
newResults.pagination = combinePagination(search.pagination, data.pagination)
newResults.setPagination = setPagination
return newResults
})
}
// Remove this query from queue and see if next can be resolved.
apiQueue.current.shift()
setApiData(response)
const nextTimestamp = apiQueue.current[0]
const nextResolve = apiMap.current[nextTimestamp]
if (nextResolve) {
resolve(nextResolve)
}
}, [setApiData, setPagination, setResults, updateAggsResponse])
/**
* Function that preprocesses API call requests and finally performs the
* actual API call.
*
* All of the heavier pre-processing, checking, etc. should be done in this
* function, as it is the final one that gets called after the debounce
* interval.
*/
const apiCall = useCallback((query, aggs, pagination, queryChanged, paginationChanged, updateAggs, refresh = false, callback = undefined) => {
// Create the final search object.
const aggsToUpdate = Object.keys(aggs).filter(key => aggs[key].update)
......@@ -750,48 +802,6 @@ export const SearchContext = React.memo(({
oldQuery.current = query
oldPagination.current = pagination
// As we cannot guarantee the order in which the API calls finish, we push
// all calls into a queue. The API calls are always made instantly, but the
// queue makes sure that API calls get resolved in the original order not
// matter how long the actual call takes.
function resolve(prop) {
const {response, timestamp, queryChanged, paginationChanged, search, resource, callback} = prop
const data = response.response
let next = apiQueue.current[0]
if (next !== timestamp) {
apiMap.current[timestamp] = prop
return
}
// Update the aggregations if new aggregation data is received. The old
// aggregation data is preserved and new information is updated.
if (!isEmpty(data.aggregations)) {
const newAggs = toGUIAgg(data.aggregations, aggsToUpdate, resource)
callback && callback(newAggs)
updateAggsResponse(newAggs)
} else {
callback && callback(null)
}
// Update the query results if new data is received.
if (queryChanged || paginationChanged) {
const isExtend = search.pagination.page_after_value
paginationResponse.current = data.pagination
setResults(old => {
const newResults = old ? {...old} : {}
isExtend ? newResults.data = [...newResults.data, ...data.data] : newResults.data = data.data
newResults.pagination = combinePagination(search.pagination, data.pagination)
newResults.setPagination = setPagination
return newResults
})
}
// Remove this query from queue and see if next can be resolved.
apiQueue.current.shift()
setApiData(response)
const nextTimestamp = apiQueue.current[0]
const nextResolve = apiMap.current[nextTimestamp]
if (nextResolve) {
resolve(nextResolve)
}
}
const timestamp = Date.now()
apiQueue.current.push(timestamp)
api.query(resource, search, {loadingIndicator: true, returnRequest: true})
......@@ -802,6 +812,7 @@ export const SearchContext = React.memo(({
queryChanged,
paginationChanged,
search,
aggsToUpdate,
resource,
callback
})
......@@ -810,14 +821,17 @@ export const SearchContext = React.memo(({
raiseError(error)
callback && callback(undefined, error)
})
}, [filterDefaults, resource, api, raiseError, updateAggsResponse, setResults, setApiData, setPagination])
}, [filterDefaults, resource, api, raiseError, resolve])
// This is a debounced version of apiCall.
const apiCallDebounced = useCallback(debounce(apiCall, 400), [])
// Intermediate function that ensures that:
// - Calls are debounced when necessary
// - API calls are made only if necessary
/**
* Intermediate function that should primarily be used when trying to perform
* an API call. Ensures that ensures that:
* - Calls are debounced when necessary
* - API calls are made only if necessary
*/
const apiCallInterMediate = useCallback((query, aggs, pagination, refresh = false, callback = undefined) => {
if (disableUpdate.current) {
disableUpdate.current = false
......
......@@ -26,7 +26,6 @@ import {
import PropTypes from 'prop-types'
import { isNil } from 'lodash'
import clsx from 'clsx'
import searchQuantities from '../../../searchQuantities'
import { useSearchContext } from '../SearchContext'
const useStyles = makeStyles(theme => ({
......@@ -50,14 +49,14 @@ const InputCheckbox = React.memo(({
}) => {
const theme = useTheme()
const styles = useStyles({classes: classes, theme: theme})
const { filterData, useFilterState, useFilterLocked } = useSearchContext()
const {filterData, useFilterState, useFilterLocked} = useSearchContext()
const [filter, setFilter] = useFilterState(quantity)
const locked = useFilterLocked(quantity)
// Determine the description and units
const def = searchQuantities[quantity]
const desc = isNil(description) ? (def?.description || '') : description
const title = isNil(label) ? def?.name : label
const def = filterData[quantity]
const descFinal = description || def?.description || ''
const labelFinal = label || def?.label
const disabled = locked
const handleChange = useCallback((event, value) => {
......@@ -65,7 +64,7 @@ const InputCheckbox = React.memo(({
}, [setFilter])
return <div className={clsx(className, styles.root)} data-testid={testID}>
<Tooltip title={desc}>
<Tooltip title={descFinal}>
<FormControlLabel
control={<Checkbox
color="primary"
......@@ -73,7 +72,7 @@ const InputCheckbox = React.memo(({
checked={isNil(filter) ? (isNil(initialValue) ? filterData[quantity].default : initialValue) : filter}
onChange={handleChange}
/>}
label={<Typography>{title}</Typography>}
label={<Typography>{labelFinal}</Typography>}
/>
</Tooltip>
</div>
......@@ -103,7 +102,6 @@ const useInputCheckboxValueStyles = makeStyles(theme => ({
}))
export const InputCheckboxValue = React.memo(({
quantity,
label,
description,
value,
className,
......@@ -112,13 +110,13 @@ export const InputCheckboxValue = React.memo(({
}) => {
const theme = useTheme()
const styles = useInputCheckboxValueStyles({classes: classes, theme: theme})
const { useFilterState, useFilterLocked } = useSearchContext()
const {filterData, useFilterState, useFilterLocked} = useSearchContext()
const [filter, setFilter] = useFilterState(quantity)
const locked = useFilterLocked(quantity)
// Determine the description and units
const def = searchQuantities[quantity]
const desc = isNil(description) ? (def?.description || '') : description
const def = filterData[quantity]
const descFinal = description || def?.description || ''
const disabled = locked
const handleChange = useCallback(() => {
......@@ -130,7 +128,7 @@ export const InputCheckboxValue = React.memo(({
}, [setFilter, value])
return <div className={clsx(className, styles.root)} data-testid={testID}>
<Tooltip title={desc}>
<Tooltip title={descFinal}>
<Checkbox
disabled={disabled}
color="primary"
......@@ -145,7 +143,6 @@ export const InputCheckboxValue = React.memo(({
InputCheckboxValue.propTypes = {
quantity: PropTypes.string.isRequired,
label: PropTypes.string,
description: PropTypes.string,
value: PropTypes.any,
className: PropTypes.string,
......
......@@ -107,7 +107,11 @@ const InputField = React.memo(({
if (isArray(metainfoOptions) && metainfoOptions.length > 0) {
const opt = {}
for (const name of metainfoOptions) {
opt[name] = {label: name}
// We do not display the option for 'not processed': it is more of a
// debug value
if (name !== 'not processed') {
opt[name] = {label: name}
}
}
return opt
}
......@@ -267,7 +271,7 @@ const InputField = React.memo(({
reservedHeight = itemHeight + actionHeight
}
const total = agg ? Math.max(...agg.data.map(option => option.count)) : 0
const max = agg ? Math.max(...agg.data.map(option => option.count)) : 0
const items = visibleOptions && <div
className={styles.grid}
style={{gridTemplateRows: `repeat(${nRows}, 1fr)`}}
......@@ -281,7 +285,7 @@ const InputField = React.memo(({
disabled={value.disabled}
onChange={handleChange}
variant="checkbox"
total={total}
max={max}
count={value.count}
scale={scale}
/>
......
......@@ -83,7 +83,7 @@ const InputItem = React.memo(({
disabled,
tooltip,
variant,
total,
max,
count,
scale,
disableStatistics,
......@@ -114,7 +114,7 @@ const InputItem = React.memo(({
const labelComponent = <div className={styles.container}>
{(isStatisticsEnabled && !disableStatistics) && <StatisticsBar
className={styles.bar}
max={total}
max={max}
value={count}
scale={scale}
selected={selected}
......@@ -171,7 +171,7 @@ InputItem.propTypes = {
disabled: PropTypes.bool, // Whether the option should be disabled
tooltip: PropTypes.string, // Tooltip that is shown for label
variant: PropTypes.oneOf(['radio', 'checkbox']), // The type of item to display
total: PropTypes.number, // Total number for statistics
max: PropTypes.number, // Maximum for statistics
count: PropTypes.number, // Count of these values for statistics
scale: PropTypes.number, // Scaling of the statistics
disableStatistics: PropTypes.bool, // Use to disable statistics for this item
......
......@@ -22,7 +22,6 @@ import PropTypes from 'prop-types'
import { isNil } from 'lodash'
import { useResizeDetector } from 'react-resize-detector'
import clsx from 'clsx'
import searchQuantities from '../../../searchQuantities'
import InputHeader from './InputHeader'
import InputTooltip from './InputTooltip'
import InputItem, { inputItemHeight } from './InputItem'
......@@ -90,7 +89,7 @@ const InputList = React.memo(({
'data-testid': testID
}) => {
const theme = useTheme()
const {useAgg, useFilterState, useFilterLocked} = useSearchContext()
const {filterData, useAgg, useFilterState, useFilterLocked} = useSearchContext()
const styles = useStyles({classes: classes, theme: theme})
const [scale, setScale] = useState(initialScale)
const [filter, setFilter] = useFilterState(quantity)
......@@ -98,12 +97,12 @@ const InputList = React.memo(({
const { height, ref } = useResizeDetector()
const aggSize = useMemo(() => Math.floor(height / inputItemHeight), [height])
const agg = useAgg(quantity, !isNil(height) && visible, aggSize, aggId)
const total = agg ? Math.max(...agg.data.map(option => option.count)) : 0
const max = agg ? Math.max(...agg.data.map(option => option.count)) : 0
// Determine the description and units
const def = searchQuantities[quantity]
const desc = description || def?.description || ''
const title = label || def?.name
const def = filterData[quantity]
const descFinal = description || def?.description || ''
const labelFinal = label || def?.label
const handleChange = useCallback((event, key, selected) => {
setFilter(old => {
......@@ -129,7 +128,7 @@ const InputList = React.memo(({
key={option.value}
value={option.value}
selected={filter ? filter.has(option.value) : false}
total={total}
max={max}
onChange={handleChange}
variant="checkbox"
count={option.count}
......@@ -143,14 +142,14 @@ const InputList = React.memo(({
component = <InputUnavailable/>
}
return [component, index]
}, [agg, aggSize, filter, handleChange, scale, locked, total])
}, [agg, aggSize, filter, handleChange, scale, locked, max])
return <InputTooltip locked={locked}>
<div className={clsx(className, styles.root)} data-testid={testID}>
<InputHeader
quantity={quantity}
label={title}
description={desc}
label={labelFinal}
description={descFinal}
scale={scale}
onChangeScale={setScale}
draggable={draggable}
......
......@@ -31,7 +31,6 @@ import InputHeader from './InputHeader'
import AspectRatio from '../../visualization/AspectRatio'
import { makeStyles } from '@material-ui/core/styles'
import { useSearchContext } from '../SearchContext'
import searchQuantities from '../../../searchQuantities'
import { approxInteger } from '../../../utils'
// A fixed 2D, 10x18 array for the element data.
......@@ -53,8 +52,7 @@ const useElementStyles = makeStyles(theme => ({
bottom: 1,
left: 1,
right: 1,
position: 'absolute',
backgroundColor: theme.palette.secondary.veryLight
position: 'absolute'
},
fit: {
top: 0,
......@@ -137,7 +135,7 @@ const Element = React.memo(({
selected,
disabled,
onClick,
total,
max,
count,
scale,
localFilter
......@@ -150,10 +148,10 @@ const Element = React.memo(({
const scaler = useMemo(() => scalePow()
.exponent(scale)
.domain([0, 1])
.range([0, 1])
.range([0.1, 1]) // Note that the range should not start from 0
, [scale])
const finalCount = useMemo(() => approxInteger(count || 0), [count])
const finalScale = useMemo(() => scaler(count / total) || 0, [count, total, scaler])
const finalScale = useMemo(() => scaler(count / max) || 0, [count, max, scaler])
// Dynamically calculated styles. The background color is formed by animating
// opacity: opacity animation can be GPU-accelerated by the browser unlike
......@@ -161,7 +159,7 @@ const Element = React.memo(({
const useDynamicStyles = makeStyles((theme) => {
return {
bg: { opacity: isStatisticsEnabled
? (isNil(count) || isNil(total))
? (isNil(count) || isNil(max))
? 0
: finalScale
: 0.4 },
......@@ -238,7 +236,7 @@ Element.propTypes = {
onClick: PropTypes.func,
selected: PropTypes.bool,
disabled: PropTypes.bool,
total: PropTypes.number,
max: PropTypes.number,
count: PropTypes.number,