diff --git a/gui/package.json b/gui/package.json index ae986f8ee0b36b4c2d0eecb58711a13564eb9f8f..0451037b993934a4088ab77f730a6932e61827fe 100644 --- a/gui/package.json +++ b/gui/package.json @@ -29,6 +29,7 @@ "pace": "^0.0.4", "pace-js": "^1.0.2", "piwik-react-router": "^0.12.1", + "plotly.js-cartesian-dist-min": "^1.54.7", "qs": "^6.8.0", "react": "^16.13.1", "react-app-polyfill": "^1.0.1", diff --git a/gui/public/index.html b/gui/public/index.html index a111cb993ab9d0a4aec7de0bf0d75733ffe48fcc..bd3ef7cee4c21a5923cf6fabb8e800f1150e33c4 100644 --- a/gui/public/index.html +++ b/gui/public/index.html @@ -5,6 +5,9 @@ window.paceOptions = { restartOnPushState: true } + // This needs to be defined for Plotly.js graphs, see + // https://github.com/plotly/plotly.js/blob/master/dist/README.md#partial-bundles + window.PlotlyConfig = {MathJaxConfig: 'local'} </script> <script src="https://unpkg.com/pace-js@1.0.2/pace.min.js"></script> <link href="%PUBLIC_URL%/pace.css" rel="stylesheet" /> @@ -38,7 +41,6 @@ <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script> <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> - <title>NOMAD</title> </head> <body> diff --git a/gui/src/components/api.js b/gui/src/components/api.js index f3fccbfe7a1e1877d3bd89dd3c4dbd560da90a07..cad16c0b45b9735328d6e8d2c69016cd838939d3 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -299,6 +299,86 @@ class Api { .finally(this.onFinishLoading) } + async encyclopediaBasic(materialId) { + this.onStartLoading() + return this.swagger() + .then(client => client.apis.encyclopedia.get_material({ + material_id: materialId + })) + .catch(handleApiError) + .then(response => { + const result = response.body || response.text || response.data + if (typeof result === 'string') { + try { + return JSON.parse(result) + } catch (e) { + try { + return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) + } catch (e) { + return result + } + } + } else { + return result + } + }) + .finally(this.onFinishLoading) + } + + async encyclopediaCalculations(materialId) { + this.onStartLoading() + return this.swagger() + .then(client => client.apis.encyclopedia.get_calculations({ + material_id: materialId + })) + .catch(handleApiError) + .then(response => { + const result = response.body || response.text || response.data + if (typeof result === 'string') { + try { + return JSON.parse(result) + } catch (e) { + try { + return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) + } catch (e) { + return result + } + } + } else { + return result + } + }) + .finally(this.onFinishLoading) + } + + async encyclopediaCalculation(materialId, calcId, payload) { + this.onStartLoading() + return this.swagger() + .then(client => client.apis.encyclopedia.get_calculation({ + material_id: materialId, + calc_id: calcId, + payload: payload + })) + .catch(handleApiError) + .then(response => { + const result = response.body || response.text || response.data + if (typeof result === 'string') { + try { + return JSON.parse(result) + } catch (e) { + try { + return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) + } catch (e) { + return result + } + } + } else { + return result + } + }) + .finally(this.onFinishLoading) + } + async calcProcLog(uploadId, calcId) { this.onStartLoading() return this.swagger() diff --git a/gui/src/components/archive/ArchiveBrowser.js b/gui/src/components/archive/ArchiveBrowser.js index ab129ac57154202b651566c217b70dde7eff4b86..600cc6a83227db976fda06725d66f609a6e30417 100644 --- a/gui/src/components/archive/ArchiveBrowser.js +++ b/gui/src/components/archive/ArchiveBrowser.js @@ -10,6 +10,8 @@ import { resolveRef, rootSections } from './metainfo' import { Title, metainfoAdaptorFactory, DefinitionLabel } from './MetainfoBrowser' import { Matrix, Number } from './visualizations' import Structure from '../visualization/Structure' +import BandStructure from '../visualization/BandStructure' +import DOS from '../visualization/DOS' import { StructureViewer } from '@lauri-codes/materia' import Markdown from '../Markdown' import { convert } from '../../utils' @@ -305,6 +307,25 @@ QuantityValue.propTypes = ({ * title. */ function Overview({section, def}) { + // Styles + const useStyles = makeStyles( + { + bands: { + width: '30rem', + height: '30rem' + }, + dos: { + width: '20', + height: '20' + }, + bz: { + width: '20', + height: '20' + } + } + ) + const style = useStyles() + // Structure visualization for section_system if (def.name === 'section_system') { let url = window.location.href @@ -338,7 +359,25 @@ function Overview({section, def}) { visualizedSystem.sectionPath = sectionPath visualizedSystem.index = index - return <Structure viewer={viewer} system={system} positionsOnly={positionsOnly}></Structure> + return <Structure + viewer={viewer} + system={system} + positionsOnly={positionsOnly} + ></Structure> + // Band structure plot for section_k_band or section_k_band_normalized + } else if (def.name === 'section_k_band' || def.name === 'section_k_band_normalized') { + return <BandStructure + className={style.bands} + data={section} + aspectRatio={1} + ></BandStructure> + // DOS plot for section_dos + } else if (def.name === 'section_dos') { + return <DOS + className={style.dos} + data={section} + aspectRatio={1 / 2} + ></DOS> } return null } diff --git a/gui/src/components/material/ElectronicStructureOverview.js b/gui/src/components/material/ElectronicStructureOverview.js new file mode 100644 index 0000000000000000000000000000000000000000..790779ce55684f8668aa5ee6895c8c71e74469b6 --- /dev/null +++ b/gui/src/components/material/ElectronicStructureOverview.js @@ -0,0 +1,139 @@ +import React, { useEffect, useState, useCallback } from 'react' +import PropTypes from 'prop-types' +import { + Box, + Card, + CardHeader, + CardContent +} from '@material-ui/core' +import DOS from '../visualization/DOS' +import BandStructure from '../visualization/BandStructure' +import BrillouinZone from '../visualization/BrillouinZone' +import { makeStyles } from '@material-ui/core/styles' +import { withApi } from '../api' + +function ElectronicStructureOverview({data, range, className, classes, api, raiseError}) { + const [dos, setDos] = useState() + const [dosLayout, setDosLayout] = useState({yaxis: {range: range}}) + const [bs, setBs] = useState() + const [bsLayout, setBsLayout] = useState({yaxis: {range: range}}) + + // Styles + const useStyles = makeStyles((theme) => { + return { + row: { + display: 'flex', + flexDirection: 'row', + width: '100%' + }, + bz: { + flex: '1 1 25%' + }, + bands: { + flex: '1 1 50%' + }, + dos: { + flex: '1 1 25%' + } + } + }) + const style = useStyles(classes) + + // Load the data parallelly from API on first render + useEffect(() => { + if (data === undefined) { + return + } + // Check what data is available and request each in parallel + let representatives = data.calculations.representatives + let promises = [] + let requestedProperties = ['electronic_dos', 'electronic_band_structure'] + let availableProperties = [] + for (let property of requestedProperties) { + if (representatives.hasOwnProperty(property)) { + promises.push( + api.encyclopediaCalculation( + data.basic.material_id, + representatives[property], + {'properties': [property]} + ) + ) + availableProperties.push(property) + } + } + Promise.allSettled(promises).then((results) => { + for (let i = 0; i < availableProperties.length; ++i) { + let property = availableProperties[i] + let result = results[i].value[property] + if (property === 'electronic_dos') { + setDos(result) + } + if (property === 'electronic_band_structure') { + setBs(result) + } + } + }).catch(error => { + console.log(error) + if (error.name === 'DoesNotExist') { + raiseError(error) + } + }) + }, [data, api, raiseError]) + + // Synchronize panning between BS/DOS plots + const handleBSRelayouting = useCallback((event) => { + let update = { + yaxis: { + range: [event['yaxis.range[0]'], event['yaxis.range[1]']] + } + } + setDosLayout(update) + }, []) + const handleDOSRelayouting = useCallback((event) => { + let update = { + yaxis: { + range: [event['yaxis.range[0]'], event['yaxis.range[1]']] + } + } + setBsLayout(update) + }, []) + + return ( + <Card> + <CardHeader title="Electronic structure" /> + <CardContent> + <Box className={style.row}> + <BrillouinZone data={bs} className={style.bz} aspectRatio={1 / 2}></BrillouinZone> + <BandStructure + data={bs} + layout={bsLayout} + className={style.bands} + aspectRatio={1} + onRelayouting={handleBSRelayouting} + ></BandStructure> + <DOS + data={dos} + layout={dosLayout} + className={style.dos} + aspectRatio={1 / 2} + onRelayouting={handleDOSRelayouting} + ></DOS> + </Box> + </CardContent> + </Card> + ) +} + +ElectronicStructureOverview.propTypes = { + data: PropTypes.object, + range: PropTypes.array, + className: PropTypes.string, + classes: PropTypes.object, + api: PropTypes.object, + raiseError: PropTypes.func +} +ElectronicStructureOverview.defaultProps = { + range: [-10, 20] +} + +export default withApi(false, true)(ElectronicStructureOverview) diff --git a/gui/src/components/material/MaterialPage.js b/gui/src/components/material/MaterialPage.js new file mode 100644 index 0000000000000000000000000000000000000000..81d427d7951b8d778e2565da9e76ffa596f53175 --- /dev/null +++ b/gui/src/components/material/MaterialPage.js @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { Box } from '@material-ui/core' +import ElectronicStructureOverview from './ElectronicStructureOverview' +import { useRouteMatch, Route } from 'react-router-dom' +import { withApi } from '../api' + +const MaterialPageContent = withApi(false, true)(({fixed, api, materialId, raiseError}) => { + const props = fixed ? {maxWidth: 1200} : {} + const [data, setData] = useState() + + // Load the data parallelly from API on first render + useEffect(() => { + Promise.all([ + api.encyclopediaBasic(materialId), + api.encyclopediaCalculations(materialId) + ]).then((results) => { + setData({ + basic: results[0], + calculations: results[1] + }) + }).catch(error => { + if (error.name === 'DoesNotExist') { + raiseError(error) + } + }) + }, [api, materialId, raiseError]) + + return <Box padding={3} margin="auto" {...props}> + <ElectronicStructureOverview data={data}></ElectronicStructureOverview> + </Box> +}) +MaterialPageContent.propTypes = ({ + materialId: PropTypes.string, + api: PropTypes.func, + raiseError: PropTypes.func, + fixed: PropTypes.bool +}) +function MaterialPage() { + const { path } = useRouteMatch() + + return ( + <Route + path={`${path}/:materialId?/:tab?`} + render={({match: {params: {materialId, tab = 'overview'}}}) => { + if (materialId) { + return ( + <React.Fragment> + <MaterialPageContent fixed={true} materialId={materialId}> + </MaterialPageContent> + </React.Fragment> + ) + } else { + return '' + } + }} + /> + ) +} + +export default MaterialPage diff --git a/gui/src/components/search/MaterialsList.js b/gui/src/components/search/MaterialsList.js index 0160e662d1c9992dfcb9835f77df597e648b1e7e..94a2409c504f9bbacf2f902cfdd797f2adcad30d 100644 --- a/gui/src/components/search/MaterialsList.js +++ b/gui/src/components/search/MaterialsList.js @@ -75,6 +75,11 @@ export default function MaterialsList(props) { paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` } + /* const handleViewMaterial = useCallback((event, materialId) => { + event.stopPropagation() + history.push(`/material/${materialId}/overview`) + }, [history]) */ + const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}> <Toolbar className={classes.scrollBar}> <span className={classes.scrollSpacer}> </span> @@ -92,6 +97,9 @@ export default function MaterialsList(props) { <IconButton href={`${appBase}/encyclopedia/#/material/${entry.encyclopedia.material.material_id}`}> <DetailsIcon /> </IconButton> + {/* <IconButton onClick={event => handleViewMaterial(event, entry.encyclopedia.material.material_id)}> + <DetailsIcon /> + </IconButton> */} </Tooltip> return <DataTable diff --git a/gui/src/components/visualization/BandStructure.js b/gui/src/components/visualization/BandStructure.js new file mode 100644 index 0000000000000000000000000000000000000000..4edf22e2ebc5679367fc6717b178efef28e81f07 --- /dev/null +++ b/gui/src/components/visualization/BandStructure.js @@ -0,0 +1,244 @@ +import React, {useState, useEffect, useMemo} from 'react' +import PropTypes from 'prop-types' +import { makeStyles, useTheme } from '@material-ui/core/styles' +import clsx from 'clsx' +import { + Box +} from '@material-ui/core' +import Plot from '../visualization/Plot' +import { convert, distance, mergeObjects } from '../../utils' + +export default function BandStructure({data, layout, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) { + const [finalData, setFinalData] = useState(undefined) + const [pathSegments, setPathSegments] = useState(undefined) + + // Styles + const useStyles = makeStyles( + { + root: { + } + } + ) + const style = useStyles(classes) + const theme = useTheme() + + // Determine the final plotted data based on the received data. Will work with + // normalized and unnormalized data. + useEffect(() => { + if (data === undefined) { + return + } + + // Determine if data is normalized + const norm = data.section_k_band_segment_normalized === undefined ? '' : '_normalized' + const segmentName = 'section_k_band_segment' + norm + const energyName = 'band_energies' + norm + const kpointName = 'band_k_points' + norm + + let plotData = [] + let nChannels = data[segmentName][0][energyName].length + let nBands = data[segmentName][0][energyName][0][0].length + + // Calculate distances if missing + let tempSegments = [] + if (data[segmentName][0].k_path_distances === undefined) { + let length = 0 + for (let segment of data[segmentName]) { + const k_path_distances = [] + const nKPoints = segment[energyName][0].length + let start = segment[kpointName][0] + let end = segment[kpointName].slice(-1)[0] + let segmentLength = distance(start, end) + for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) { + const kPoint = segment[kpointName][iKPoint] + const dist = distance(start, kPoint) + k_path_distances.push(length + dist) + } + length += segmentLength + segment.k_path_distances = k_path_distances + tempSegments.push(k_path_distances) + } + } else { + for (let segment of data[segmentName]) { + tempSegments.push(segment.k_path_distances) + } + } + setPathSegments(tempSegments) + + // Path + let path = [] + for (let segment of data[segmentName]) { + path = path.concat(segment.k_path_distances) + tempSegments.push(segment.k_path_distances) + } + + // Second spin channel + if (nChannels === 2) { + let bands = [] + for (let iBand = 0; iBand < nBands; ++iBand) { + bands.push([]) + } + for (let segment of data[segmentName]) { + for (let iBand = 0; iBand < nBands; ++iBand) { + let nKPoints = segment[energyName][0].length + for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) { + bands[iBand].push(segment[energyName][0][iKPoint][iBand]) + } + } + } + + // Create plot data entry for each band + for (let band of bands) { + band = convert(band, 'joule', 'eV') + plotData.push( + { + x: path, + y: band, + type: 'scatter', + mode: 'lines', + line: { + color: theme.palette.secondary.main, + width: 2 + } + } + ) + } + } + + // First spin channel + let bands = [] + for (let iBand = 0; iBand < nBands; ++iBand) { + bands.push([]) + } + for (let segment of data[segmentName]) { + for (let iBand = 0; iBand < nBands; ++iBand) { + let nKPoints = segment[energyName][0].length + for (let iKPoint = 0; iKPoint < nKPoints; ++iKPoint) { + bands[iBand].push(segment[energyName][0][iKPoint][iBand]) + } + } + path = path.concat(segment.k_path_distances) + } + + // Create plot data entry for each band + for (let band of bands) { + band = convert(band, 'joule', 'eV') + plotData.push( + { + x: path, + y: band, + type: 'scatter', + mode: 'lines', + line: { + color: theme.palette.primary.main, + width: 2 + } + } + ) + } + + setFinalData(plotData) + }, [data, theme.palette.primary.main, theme.palette.secondary.main]) + + // Merge custom layout with default layout + const tmpLayout = useMemo(() => { + let defaultLayout = { + xaxis: { + tickfont: { + size: 14 + } + }, + yaxis: { + title: { + text: 'Energy (eV)' + } + } + } + mergeObjects(layout, defaultLayout) + return defaultLayout + }, [layout]) + + // Compute layout that depends on data. + const computedLayout = useMemo(() => { + if (data === undefined || pathSegments === undefined) { + return {} + } + // Set new layout that contains the segment labels + const norm = data.section_k_band_segment_normalized === undefined ? '' : '_normalized' + const segmentName = 'section_k_band_segment' + norm + const labelName = 'band_segm_labels' + norm + let labels = [] + let labelKPoints = [] + for (let iSegment = 0; iSegment < data[segmentName].length; ++iSegment) { + let segment = data[segmentName][iSegment] + if (iSegment === 0) { + // If label is not defined, use empty string + const startLabel = segment[labelName] ? segment[labelName][0] : '' + labels.push(startLabel) + labelKPoints.push(pathSegments[iSegment][0]) + } + const endLabel = segment[labelName] ? segment[labelName][1] : '' + labels.push(endLabel) + labelKPoints.push(pathSegments[iSegment].slice(-1)[0]) + } + let shapes = [] + for (let iShape = 1; iShape < labelKPoints.length - 1; ++iShape) { + let labelKPoint = labelKPoints[iShape] + shapes.push({ + type: 'line', + x0: labelKPoint, + y0: 0, + x1: labelKPoint, + y1: 1, + yref: 'paper', + line: { + color: '#999', + width: 1, + dash: '10px,10px' + } + }) + } + let ticks = { + shapes: shapes, + xaxis: { + tickmode: 'array', + tickvals: labelKPoints, + ticktext: labels + } + } + return ticks + }, [data, pathSegments]) + + // Merge the given layout and layout computed from data + const finalLayout = useMemo(() => { + return mergeObjects(computedLayout, tmpLayout, 'shallow') + }, [computedLayout, tmpLayout]) + + return ( + <Box className={clsx(style.root, className)}> + <Plot + data={finalData} + layout={finalLayout} + aspectRatio={aspectRatio} + floatTitle={'Band structure'} + onRelayout={onRelayout} + onAfterPlot={onAfterPlot} + onRedraw={onRedraw} + onRelayouting={onRelayouting} + > + </Plot> + </Box> + ) +} + +BandStructure.propTypes = { + data: PropTypes.object, // section_band_structure or section_band_structure_normalized + layout: PropTypes.object, + aspectRatio: PropTypes.number, + classes: PropTypes.object, + className: PropTypes.string, + onAfterPlot: PropTypes.func, + onRedraw: PropTypes.func, + onRelayout: PropTypes.func, + onRelayouting: PropTypes.func +} diff --git a/gui/src/components/visualization/BrillouinZone.js b/gui/src/components/visualization/BrillouinZone.js new file mode 100644 index 0000000000000000000000000000000000000000..b0e39e65de355a0614ee1eceaad712bde0a1bfb3 --- /dev/null +++ b/gui/src/components/visualization/BrillouinZone.js @@ -0,0 +1,342 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import clsx from 'clsx' +import PropTypes from 'prop-types' +import { makeStyles } from '@material-ui/core/styles' +import { + Box, + Checkbox, + Menu, + MenuItem, + IconButton, + Tooltip, + Typography, + FormControlLabel +} from '@material-ui/core' +import { + MoreVert, + Fullscreen, + FullscreenExit, + CameraAlt, + Replay +} from '@material-ui/icons' +import { BrillouinZoneViewer } from '@lauri-codes/materia' +import Floatable from './Floatable' + +export default function BrillouinZone({className, classes, options, viewer, system, positionsOnly, sizeLimit, captureName, aspectRatio}) { + // States + const [anchorEl, setAnchorEl] = React.useState(null) + const [fullscreen, setFullscreen] = useState(false) + const [showBonds, setShowBonds] = useState(true) + const [showLatticeConstants, setShowLatticeConstants] = useState(true) + const [showCell, setShowCell] = useState(true) + const [error, setError] = useState(null) + + // Variables + const open = Boolean(anchorEl) + const refViewer = useRef(null) + const refCanvas = useRef(null) + + // Styles + const useStyles = makeStyles((theme) => { + return { + root: { + }, + container: { + display: 'flex', + width: '100%', + height: '100%', + flexDirection: 'column', + backgroundColor: 'white' + }, + header: { + display: 'flex', + flexDirection: 'row', + zIndex: 1 + }, + spacer: { + flex: 1 + }, + viewerCanvas: { + flex: 1, + zIndex: 0, + minHeight: 0, // added min-height: 0 to allow the item to shrink to fit inside the container. + marginBottom: theme.spacing(2), + display: error === null ? 'block' : 'none' + }, + errorContainer: { + flex: 1, + zIndex: 0, + minHeight: 0, // added min-height: 0 to allow the item to shrink to fit inside the container. + marginBottom: theme.spacing(2), + alignItems: 'center', + justifyContent: 'center', + display: error === null ? 'none' : 'flex' + }, + errorMessage: { + flex: '0 0 70%', + color: '#aaa', + textAlign: 'center' + }, + iconButton: { + backgroundColor: 'white' + } + } + }) + let style = useStyles(classes) + + // In order to properly detect changes in a reference, a reference callback is + // used. This is the recommended way to monitor reference changes as a simple + // useRef is not guaranteed to update: + // https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node + const measuredRef = useCallback(node => { + refCanvas.current = node + if (node === null) { + return + } + if (refViewer.current === null) { + return + } + refViewer.current.changeHostElement(node, true, true) + }, []) + + // Run only on first render to initialize the viewer. See the viewer + // documentation for details on the meaning of different options: + // https://lauri-codes.github.io/materia/viewers/structureviewer + useEffect(() => { + let viewerOptions + if (options === undefined) { + viewerOptions = { + view: { + autoResize: true, + autoFit: true, + fitMargin: 0.5 + }, + bonds: { + enabled: true + }, + latticeConstants: { + size: 0.7, + font: 'Titillium Web,sans-serif', + a: {color: '#f44336'}, + b: {color: '#4caf50'}, + c: {color: '#5c6bc0'} + }, + controls: { + enableZoom: true, + enablePan: true, + enableRotate: true + }, + renderer: { + backgroundColor: ['#ffffff', 1], + shadows: { + enabled: false + } + } + } + } else { + viewerOptions = options + } + + if (viewer === undefined) { + refViewer.current = new BrillouinZoneViewer(undefined, viewerOptions) + } else { + refViewer.current = viewer + refViewer.current.setOptions(viewerOptions, false, false) + } + if (refCanvas.current !== null) { + refViewer.current.changeHostElement(refCanvas.current, false, false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Called only on first render to load the given structure. + useEffect(() => { + if (system === undefined) { + return + } + + if (positionsOnly) { + refViewer.current.setPositions(system.positions) + return + } + + let nAtoms = system.species.length + if (nAtoms >= sizeLimit) { + setError('Visualization is disabled due to large system size.') + return + } + + // Systems with cell are centered on the cell center and orientation is defined + // by the cell vectors. + let cell = system.cell + if (cell !== undefined) { + refViewer.current.setOptions({layout: { + viewCenter: 'COC', + viewRotation: { + align: { + top: 'c', + right: 'b' + }, + rotations: [ + [0, 1, 0, 60], + [1, 0, 0, 30] + ] + } + }}) + // Systems without cell are centered on the center of positions + } else { + refViewer.current.setOptions({layout: { + viewCenter: 'COP', + viewRotation: { + rotations: [ + [0, 1, 0, 60], + [1, 0, 0, 30] + ] + } + }}) + } + refViewer.current.load(system) + refViewer.current.fitToCanvas() + refViewer.current.saveReset() + refViewer.current.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Viewer settings + useEffect(() => { + refViewer.current.setOptions({bonds: {enabled: showBonds}}) + }, [showBonds]) + + useEffect(() => { + refViewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}}) + }, [showLatticeConstants]) + + useEffect(() => { + refViewer.current.setOptions({cell: {enabled: showCell}}) + }, [showCell]) + + // Memoized callbacks + const openMenu = useCallback((event) => { + setAnchorEl(event.currentTarget) + }, []) + + const closeMenu = useCallback(() => { + setAnchorEl(null) + }, []) + + const toggleFullscreen = useCallback(() => { + setFullscreen(!fullscreen) + }, [fullscreen]) + + const takeScreencapture = useCallback(() => { + refViewer.current.takeScreenShot(captureName) + }, [captureName]) + + const handleReset = useCallback(() => { + refViewer.current.reset() + refViewer.current.fitToCanvas() + refViewer.current.render() + }, []) + + const content = <Box className={style.container}> + <div className={style.header}> + {fullscreen && <Typography variant="h6">Structure</Typography>} + <div className={style.spacer}></div> + <Tooltip title="Reset view"> + <IconButton className={style.iconButton} onClick={handleReset} disabled={error}> + <Replay /> + </IconButton> + </Tooltip> + <Tooltip + title="Toggle fullscreen"> + <IconButton className={style.iconButton} onClick={toggleFullscreen} disabled={error}> + {fullscreen ? <FullscreenExit /> : <Fullscreen />} + </IconButton> + </Tooltip> + <Tooltip title="Capture image"> + <IconButton className={style.iconButton} onClick={takeScreencapture} disabled={error}> + <CameraAlt /> + </IconButton> + </Tooltip> + <Tooltip title="Options"> + <IconButton className={style.iconButton} onClick={openMenu} disabled={error}> + <MoreVert /> + </IconButton> + </Tooltip> + <Menu + id='settings-menu' + anchorEl={anchorEl} + getContentAnchorEl={null} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + keepMounted + open={open} + onClose={closeMenu} + > + <MenuItem key='show-bonds'> + <FormControlLabel + control={ + <Checkbox + checked={showBonds} + onChange={(event) => { setShowBonds(!showBonds) }} + color='primary' + /> + } + label='Show bonds' + /> + </MenuItem> + <MenuItem key='show-axis'> + <FormControlLabel + control={ + <Checkbox + checked={showLatticeConstants} + onChange={(event) => { setShowLatticeConstants(!showLatticeConstants) }} + color='primary' + /> + } + label='Show lattice constants' + /> + </MenuItem> + <MenuItem key='show-cell'> + <FormControlLabel + control={ + <Checkbox + checked={showCell} + onChange={(event) => { setShowCell(!showCell) }} + color='primary' + /> + } + label='Show simulation cell' + /> + </MenuItem> + </Menu> + </div> + <div className={style.viewerCanvas} ref={measuredRef}></div> + <div className={style.errorContainer}><div className={style.errorMessage}>{error}</div></div> + </Box> + + return ( + <Box className={clsx(style.root, className)} > + <Floatable float={fullscreen} onFloat={toggleFullscreen} aspectRatio={aspectRatio}> + {content} + </Floatable> + </Box> + ) +} + +BrillouinZone.propTypes = { + viewer: PropTypes.object, // Optional shared viewer instance. + system: PropTypes.object, // The system to display as section_system + options: PropTypes.object, // Viewer options + captureName: PropTypes.string, // Name of the file that the user can download + aspectRatio: PropTypes.number, // Fixed aspect ratio for the viewer canvas + sizeLimit: PropTypes.number, // Maximum number of atoms to attempt to display + positionsOnly: PropTypes.bool, // Whether to update only positions. This is much faster than loading the entire structure. + classes: PropTypes.object, + className: PropTypes.string +} +BrillouinZone.defaultProps = { + aspectRatio: 4 / 3, + captureName: 'structure', + sizeLimit: 300 +} diff --git a/gui/src/components/visualization/DOS.js b/gui/src/components/visualization/DOS.js new file mode 100644 index 0000000000000000000000000000000000000000..ae8a2aaaeb8d4f069eeb902811f7d94e1595313a --- /dev/null +++ b/gui/src/components/visualization/DOS.js @@ -0,0 +1,127 @@ +import React, {useState, useEffect, useMemo} from 'react' +import PropTypes from 'prop-types' +import { makeStyles, useTheme } from '@material-ui/core/styles' +import clsx from 'clsx' +import { + Box +} from '@material-ui/core' +import Plot from '../visualization/Plot' +import { convert, mergeObjects } from '../../utils' + +export default function DOS({data, layout, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) { + const [finalData, setFinalData] = useState(undefined) + + // Merge custom layout with default layout + const tmpLayout = useMemo(() => { + let defaultLayout = { + yaxis: { + title: { + text: 'Energy (eV)' + } + } + } + return mergeObjects(layout, defaultLayout) + }, [layout]) + + // Styles + const useStyles = makeStyles( + { + root: { + } + } + ) + const style = useStyles(classes) + const theme = useTheme() + + // The plotted data is loaded only after the first render as a side effect to + // avoid freezing the UI + useEffect(() => { + if (data === undefined) { + return + } + const norm = data.dos_energies_normalized === undefined ? '' : '_normalized' + const energyName = 'dos_energies' + norm + const valueName = 'dos_values' + norm + const plotData = [] + if (data !== undefined) { + let nChannels = data[valueName].length + let energies = convert(data[energyName], 'joule', 'eV') + if (nChannels === 2) { + plotData.push( + { + x: data[valueName][1], + y: energies, + type: 'scatter', + mode: 'lines', + line: { + color: theme.palette.secondary.main, + width: 2 + } + } + ) + } + plotData.push( + { + x: data[valueName][0], + y: energies, + type: 'scatter', + mode: 'lines', + line: { + color: theme.palette.primary.main, + width: 2 + } + } + ) + } + setFinalData(plotData) + }, [data, theme.palette.primary.main, theme.palette.secondary.main]) + + // Compute layout that depends on data. + const computedLayout = useMemo(() => { + if (data === undefined) { + return {} + } + const norm = data.dos_energies_normalized !== undefined + let defaultLayout = { + xaxis: { + title: { + text: norm ? 'states/eV/m<sup>3</sup>/atom' : 'states/eV/cell' + } + } + } + return defaultLayout + }, [data]) + + // Merge the given layout and layout computed from data + const finalLayout = useMemo(() => { + return mergeObjects(computedLayout, tmpLayout) + }, [computedLayout, tmpLayout]) + + return ( + <Box className={clsx(style.root, className)}> + <Plot + data={finalData} + layout={finalLayout} + aspectRatio={aspectRatio} + floatTitle="Density of states" + onRelayout={onRelayout} + onAfterPlot={onAfterPlot} + onRedraw={onRedraw} + onRelayouting={onRelayouting} + > + </Plot> + </Box> + ) +} + +DOS.propTypes = { + data: PropTypes.object, // section_dos + layout: PropTypes.object, + aspectRatio: PropTypes.number, + classes: PropTypes.object, + className: PropTypes.string, + onAfterPlot: PropTypes.func, + onRedraw: PropTypes.func, + onRelayout: PropTypes.func, + onRelayouting: PropTypes.func +} diff --git a/gui/src/components/visualization/Floatable.js b/gui/src/components/visualization/Floatable.js new file mode 100644 index 0000000000000000000000000000000000000000..3e04403dd1e9084cce5da4730ff8cef22bfb5a11 --- /dev/null +++ b/gui/src/components/visualization/Floatable.js @@ -0,0 +1,140 @@ +import React from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { + Box, + Button, + Dialog, + DialogContent, + DialogActions +} from '@material-ui/core' +import PropTypes from 'prop-types' +import clsx from 'clsx' + +/** + * Component that wraps it's children in a container that can be 'floated', + * i.e. displayed on an html element that is positioned relative to the + * viewport and is above all other elements. + */ +export default function Floatable({className, classes, float, children, aspectRatio, onFloat}) { + // Styles + const useStyles = makeStyles((theme) => { + // Calculate the fullscreen size + const actionsHeight = 52.5 // Size of the actions element that is shown when floating + const padding = 20 // Padding arount the floating object + const maxWidth = 1280 // Maximum width for the floating window + const margin = 0.1 * (window.innerHeight) + actionsHeight + const windowHeight = window.innerHeight - margin + const windowWidth = Math.min(window.innerWidth, maxWidth) - margin + const windowRatio = windowWidth / windowHeight + let width + let height + if (windowRatio > aspectRatio) { + width = (windowHeight) * aspectRatio + 2 * padding + 'px' + height = windowHeight + actionsHeight + 2 * padding + } else { + width = windowWidth + 2 * padding + height = (windowWidth) / aspectRatio + actionsHeight + 2 * padding + 'px' + } + + return { + root: { + }, + containerOuter: { + width: '100%', + height: 0, + paddingBottom: `${100 / aspectRatio}%`, // CSS hack for fixed aspect ratio + position: 'relative', + boxSizing: 'border-box' + }, + containerInner: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + display: 'flex', + flexDirection: 'column', + boxSizing: 'border-box' + }, + dialogContent: { + boxSizing: 'border-box', + padding: padding + }, + 'dialogContent:first-child': { + paddingTop: `${padding} !important` + }, + dialogActions: { + height: actionsHeight, + boxSizing: 'border-box' + }, + dialogRoot: { + width: width, + height: height, + margin: 0, + padding: 0, + maxWidth: 'none', + maxHeight: 'none', + boxSizing: 'border-box' + } + } + }) + const style = useStyles(classes) + + return ( + <Box className={clsx(style.root, className)}> + <Box className={style.containerOuter}> + <Box className={style.containerInner}> + {float ? '' : children} + </Box> + </Box> + <Dialog fullWidth={false} maxWidth={false} open={float} + classes={{paper: style.dialogRoot}} + > + <DialogContent className={[style.dialogContent, style['dialogContent:first-child']].join('_')}> + <Box className={style.containerOuter}> + <Box className={style.containerInner}> + {float ? children : ''} + </Box> + </Box> + </DialogContent> + <DialogActions className={style.dialogActions}> + <Button onClick={() => onFloat(float)}> + Close + </Button> + </DialogActions> + </Dialog> + </Box> + ) +} + +Floatable.propTypes = { + float: PropTypes.bool.isRequired, + /** + * Fixed aspect ratio that is enforced for this component. + */ + aspectRatio: PropTypes.number.isRequired, + /** + * Callback that is called whenever this component requests a change in the + * float property. The callback accepts one parameter: 'float' that is a + * boolean indicating the current float status. + */ + onFloat: PropTypes.any, + /** + * Child components + */ + children: PropTypes.any, + /** + * CSS class for the root element. + */ + className: PropTypes.string, + /** + * CSS classes for this component. + */ + classes: PropTypes.object + /** + * Controls the float status. + */ +} +Floatable.defaultProps = { + float: false +} diff --git a/gui/src/components/visualization/Plot.js b/gui/src/components/visualization/Plot.js new file mode 100644 index 0000000000000000000000000000000000000000..1800e34f57054697fd6dc3dce5c97f9ddb638038 --- /dev/null +++ b/gui/src/components/visualization/Plot.js @@ -0,0 +1,275 @@ +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import PropTypes from 'prop-types' +import { makeStyles } from '@material-ui/core/styles' +import { cloneDeep } from 'lodash' + +import { + IconButton, + Tooltip, + Typography +} from '@material-ui/core' +import { + MoreVert, + Fullscreen, + FullscreenExit, + CameraAlt, + Replay +} from '@material-ui/icons' +import Floatable from './Floatable' +import Plotly from 'plotly.js-cartesian-dist-min' +import clsx from 'clsx' +import { mergeObjects } from '../../utils' + +export default function Plot({data, layout, config, menu, floatTitle, capture, aspectRatio, className, classes, onRelayout, onAfterPlot, onRedraw, onRelayouting}) { + // States + const [float, setFloat] = useState(false) + const [initialLayout, setInitialLayout] = useState() + const [captureSettings, setCaptureSettings] = useState() + const firstUpdate = useRef(true) + const dataInitialized = useRef(false) + + React.useEffect(() => { + let defaultCapture = { + format: 'svg', + width: 1280, + height: 960 / aspectRatio, + filename: 'plot' + } + let settings = mergeObjects(capture, defaultCapture) + setCaptureSettings(settings) + }, [capture, aspectRatio]) + + // Styles + const useStyles = makeStyles((theme) => { + return { + header: { + paddingRight: 20, + display: 'flex', + flexDirection: 'row', + zIndex: 1 + }, + root: { + }, + spacer: { + flex: 1 + }, + iconButton: { + backgroundColor: 'white' + } + } + }) + + const style = useStyles(classes) + + // Set the final menu + const finalMenu = useMemo(() => { + let defaultMenu = { + reset: { + visible: true, + disabled: false + }, + fullscreen: { + visible: true, + disabled: false + }, + capture: { + visible: true, + disabled: false + }, + dropdown: { + visible: false, + disabled: false, + items: undefined + } + } + return mergeObjects(menu, defaultMenu) + }, [menu]) + + // Set the final layout + const finalLayout = useMemo(() => { + let defaultLayout = { + dragmode: 'pan', + hovermode: false, + showlegend: false, + autosize: true, + margin: { + l: 60, + r: 20, + t: 20, + b: 50 + }, + xaxis: { + linecolor: '#333', + linewidth: 1, + mirror: true, + ticks: 'outside', + showline: true, + fixedrange: true, + title: { + font: { + family: 'Titillium Web,sans-serif', + size: 16, + color: '#333' + }, + tickfont: { + family: 'Titillium Web,sans-serif', + size: 14, + color: '#333' + } + } + }, + yaxis: { + linecolor: '#333', + linewidth: 1, + mirror: true, + ticks: 'outside', + showline: true, + title: { + font: { + family: 'Titillium Web,sans-serif', + size: 16, + color: '#333' + } + }, + tickfont: { + family: 'Titillium Web,sans-serif', + size: 14, + color: '#333' + } + } + } + return mergeObjects(layout, defaultLayout) + }, [layout]) + + // Set the final config + const finalConfig = useMemo(() => { + let defaultConfig = { + scrollZoom: true, + displayModeBar: false + } + return mergeObjects(config, defaultConfig) + }, [config]) + + // Initialize the plot object on first render + useEffect(() => { + Plotly.newPlot(canvasRef.current, data, finalLayout, finalConfig) + if (firstUpdate.current) { + firstUpdate.current = false + } + if (data) { + setInitialLayout(cloneDeep(finalLayout)) + dataInitialized.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // In order to properly detect changes in a reference, a reference callback is + // used. This is the recommended way to monitor reference changes as a simple + // useRef is not guaranteed to update: + // https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node + const canvasRef = useCallback(node => { + canvasRef.current = node + if (node === null) { + return + } + Plotly.react(canvasRef.current, data, finalLayout, finalConfig) + }, [data, finalLayout, finalConfig]) + + // Update data + useEffect(() => { + if (firstUpdate.current) { + return + } + Plotly.react(canvasRef.current, data, finalLayout, finalConfig) + if (data) { + if (!dataInitialized.current) { + setInitialLayout(cloneDeep(finalLayout)) + dataInitialized.current = true + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, finalConfig]) + + // Update layout + useEffect(() => { + if (firstUpdate.current) { + return + } + Plotly.relayout(canvasRef.current, finalLayout) + }, [canvasRef, finalLayout]) + + // For resetting the view. We need to pass a deep clone of the initialLayout, + // as Plotly will modify the given object in-place + const handleReset = useCallback(() => { + if (initialLayout) { + Plotly.relayout(canvasRef.current, cloneDeep(initialLayout)) + } + }, [canvasRef, initialLayout]) + + // Handles plot capturing + const handleCapture = useCallback(() => { + Plotly.downloadImage(canvasRef.current, captureSettings) + }, [canvasRef, captureSettings]) + + return ( + <Floatable className={clsx(className, style.root)} float={float} onFloat={() => setFloat(!float)} aspectRatio={aspectRatio}> + <div className={style.header}> + {float && <Typography variant="h6">{floatTitle}</Typography>} + <div className={style.spacer}></div> + { finalMenu.reset.visible === true + ? <Tooltip title="Reset view"> + <IconButton className={style.iconButton} onClick={handleReset} disabled={finalMenu.reset.disabled}> <Replay /> + </IconButton> + </Tooltip> + : '' + } + { finalMenu.fullscreen.visible === true + ? <Tooltip + title="Toggle fullscreen"> + <IconButton className={style.iconButton} onClick={() => setFloat(!float)} disabled={finalMenu.fullscreen.disabled}> + {float ? <FullscreenExit /> : <Fullscreen />} + </IconButton> + </Tooltip> + : '' + } + { finalMenu.capture.visible === true + ? <Tooltip title="Capture image"> + <IconButton className={style.iconButton} onClick={handleCapture} disabled={finalMenu.capture.disabled}> + <CameraAlt /> + </IconButton> + </Tooltip> + : '' + } + { finalMenu.dropdown.visible === true + ? <Tooltip title="Options"> + <IconButton className={style.iconButton} onClick={() => {}} disabled={finalMenu.dropdown.disabled}> + <MoreVert /> + </IconButton> + </Tooltip> + : '' + } + </div> + <div ref={canvasRef} style={{width: '100%', height: '100%'}}></div> + </Floatable> + ) +} + +Plot.propTypes = { + data: PropTypes.array, // Plotly.js data object + layout: PropTypes.object, // Plotly.js layout object + config: PropTypes.object, // Plotly.js config object + menu: PropTypes.object, // Menu settings + capture: PropTypes.object, // Capture settings + aspectRatio: PropTypes.number, // Fixed aspect ratio for the viewer canvas + className: PropTypes.string, + classes: PropTypes.string, + floatTitle: PropTypes.string, // The title of the plot shown in floating mode + onRelayout: PropTypes.func, + onAfterPlot: PropTypes.func, + onRedraw: PropTypes.func, + onRelayouting: PropTypes.func +} +Plot.defaultProps = { + aspectRatio: 9 / 16, + floatTitle: '' +} diff --git a/gui/src/components/visualization/Structure.js b/gui/src/components/visualization/Structure.js index cfa8ffcb59267b551c2fcad92df5afd52327aa7f..a756e7547ab73b8eecbd5f99a0d79e29dedb0331 100644 --- a/gui/src/components/visualization/Structure.js +++ b/gui/src/components/visualization/Structure.js @@ -6,13 +6,9 @@ import { Checkbox, Menu, MenuItem, - Button, IconButton, Tooltip, Typography, - Dialog, - DialogContent, - DialogActions, FormControlLabel } from '@material-ui/core' import { @@ -23,8 +19,9 @@ import { Replay } from '@material-ui/icons' import { StructureViewer } from '@lauri-codes/materia' +import Floatable from './Floatable' -export default function Structure(props) { +export default function Structure({className, classes, system, options, viewer, captureName, aspectRatio, positionsOnly, sizeLimit}) { // States const [anchorEl, setAnchorEl] = React.useState(null) const [fullscreen, setFullscreen] = useState(false) @@ -35,25 +32,12 @@ export default function Structure(props) { // Variables const open = Boolean(anchorEl) - const viewer = useRef(null) + const refViewer = useRef(null) const refCanvas = useRef(null) // Styles const useStyles = makeStyles((theme) => { return { - root: { - width: '100%', - height: 0, - paddingBottom: `${(1 / props.aspectRatio) * 100}%`, // CSS hack for fixed aspect ratio - position: 'relative' - }, - rootInner: { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0 - }, container: { display: 'flex', width: '100%', @@ -95,7 +79,7 @@ export default function Structure(props) { } } }) - const classes = useStyles(props) + const style = useStyles(classes) // In order to properly detect changes in a reference, a reference callback is // used. This is the recommended way to monitor reference changes as a simple @@ -106,19 +90,19 @@ export default function Structure(props) { if (node === null) { return } - if (viewer.current === null) { + if (refViewer.current === null) { return } - viewer.current.changeHostElement(node, true, true) + refViewer.current.changeHostElement(node, true, true) }, []) // Run only on first render to initialize the viewer. See the viewer // documentation for details on the meaning of different options: // https://lauri-codes.github.io/materia/viewers/structureviewer useEffect(() => { - let options - if (props.options === undefined) { - options = { + let viewerOptions + if (options === undefined) { + viewerOptions = { view: { autoResize: true, autoFit: true, @@ -147,42 +131,42 @@ export default function Structure(props) { } } } else { - options = props.options + viewerOptions = options } - if (props.viewer === undefined) { - viewer.current = new StructureViewer(undefined, options) + if (viewer === undefined) { + refViewer.current = new StructureViewer(undefined, viewerOptions) } else { - viewer.current = props.viewer - viewer.current.setOptions(options, false, false) + refViewer.current = viewer + refViewer.current.setOptions(options, false, false) } if (refCanvas.current !== null) { - viewer.current.changeHostElement(refCanvas.current, false, false) + refViewer.current.changeHostElement(refCanvas.current, false, false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Called only on first render to load the given structure. useEffect(() => { - if (props.system === undefined) { + if (system === undefined) { return } - if (props.positionsOnly) { - viewer.current.setPositions(props.system.positions) + if (positionsOnly) { + refViewer.current.setPositions(system.positions) return } - let nAtoms = props.system.species.length - if (nAtoms >= props.sizeLimit) { + let nAtoms = system.species.length + if (nAtoms >= sizeLimit) { setError('Visualization is disabled due to large system size.') return } // Systems with cell are centered on the cell center and orientation is defined // by the cell vectors. - let cell = props.system.cell + let cell = system.cell if (cell !== undefined) { - viewer.current.setOptions({layout: { + refViewer.current.setOptions({layout: { viewCenter: 'COC', viewRotation: { align: { @@ -197,7 +181,7 @@ export default function Structure(props) { }}) // Systems without cell are centered on the center of positions } else { - viewer.current.setOptions({layout: { + refViewer.current.setOptions({layout: { viewCenter: 'COP', viewRotation: { rotations: [ @@ -207,24 +191,24 @@ export default function Structure(props) { } }}) } - viewer.current.load(props.system) - viewer.current.fitToCanvas() - viewer.current.saveReset() - viewer.current.reset() + refViewer.current.load(system) + refViewer.current.fitToCanvas() + refViewer.current.saveReset() + refViewer.current.reset() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Viewer settings useEffect(() => { - viewer.current.setOptions({bonds: {enabled: showBonds}}) + refViewer.current.setOptions({bonds: {enabled: showBonds}}) }, [showBonds]) useEffect(() => { - viewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}}) + refViewer.current.setOptions({latticeConstants: {enabled: showLatticeConstants}}) }, [showLatticeConstants]) useEffect(() => { - viewer.current.setOptions({cell: {enabled: showCell}}) + refViewer.current.setOptions({cell: {enabled: showCell}}) }, [showCell]) // Memoized callbacks @@ -241,37 +225,37 @@ export default function Structure(props) { }, [fullscreen]) const takeScreencapture = useCallback(() => { - viewer.current.takeScreenShot(props.captureName) - }, [props.captureName]) + refViewer.current.takeScreenShot(captureName) + }, [captureName]) const handleReset = useCallback(() => { - viewer.current.reset() - viewer.current.fitToCanvas() - viewer.current.render() + refViewer.current.reset() + refViewer.current.fitToCanvas() + refViewer.current.render() }, []) - const content = <Box className={classes.container}> - <div className={classes.header}> + const content = <Box className={style.container}> + <div className={style.header}> {fullscreen && <Typography variant="h6">Structure</Typography>} - <div className={classes.spacer}></div> + <div className={style.spacer}></div> <Tooltip title="Reset view"> - <IconButton className={classes.iconButton} onClick={handleReset} disabled={error}> + <IconButton className={style.iconButton} onClick={handleReset} disabled={error}> <Replay /> </IconButton> </Tooltip> <Tooltip title="Toggle fullscreen"> - <IconButton className={classes.iconButton} onClick={toggleFullscreen} disabled={error}> + <IconButton className={style.iconButton} onClick={toggleFullscreen} disabled={error}> {fullscreen ? <FullscreenExit /> : <Fullscreen />} </IconButton> </Tooltip> <Tooltip title="Capture image"> - <IconButton className={classes.iconButton} onClick={takeScreencapture} disabled={error}> + <IconButton className={style.iconButton} onClick={takeScreencapture} disabled={error}> <CameraAlt /> </IconButton> </Tooltip> <Tooltip title="Options"> - <IconButton className={classes.iconButton} onClick={openMenu} disabled={error}> + <IconButton className={style.iconButton} onClick={openMenu} disabled={error}> <MoreVert /> </IconButton> </Tooltip> @@ -323,34 +307,20 @@ export default function Structure(props) { </MenuItem> </Menu> </div> - <div className={classes.viewerCanvas} ref={measuredRef}></div> - <div className={classes.errorContainer}><div className={classes.errorMessage}>{error}</div></div> + <div className={style.viewerCanvas} ref={measuredRef}></div> + <div className={style.errorContainer}><div className={style.errorMessage}>{error}</div></div> </Box> return ( - <Box className={classes.root}> - <Box className={classes.rootInner}> - {fullscreen ? '' : content} - </Box> - <Dialog maxWidth="lg" fullWidth open={fullscreen}> - <DialogContent> - <Box className={classes.root}> - <Box className={classes.rootInner}> - {fullscreen ? content : ''} - </Box> - </Box> - </DialogContent> - <DialogActions> - <Button onClick={() => setFullscreen(false)}> - Close - </Button> - </DialogActions> - </Dialog> - </Box> + <Floatable float={fullscreen} onFloat={toggleFullscreen} aspectRatio={aspectRatio}> + {content} + </Floatable> ) } Structure.propTypes = { + className: PropTypes.string, + classes: PropTypes.object, viewer: PropTypes.object, // Optional shared viewer instance. system: PropTypes.object, // The system to display as section_system options: PropTypes.object, // Viewer options diff --git a/gui/src/utils.js b/gui/src/utils.js index 0e0212e72b305d04bc880cd73189c56e417fbc63..25c3bcb68c881b7a1f6c537e6713d87761e2cb25 100644 --- a/gui/src/utils.js +++ b/gui/src/utils.js @@ -1,4 +1,5 @@ import { unit } from 'mathjs' +import { cloneDeep, merge } from 'lodash' export const isEquivalent = (a, b) => { // Create arrays of property names @@ -37,7 +38,7 @@ export const capitalize = (s) => { * Used to convert numeric values from one unit to another. Works on * n-dimensional arrays and implemented as a relatively simple for loop for * performance. If conversion times become an issue, it might be worthwhile to - * look at vectorization with SIMD. + * look at vectorization with WebAssembly. * * @param {*} value The values to convert * @param {*} from Original unit. @@ -75,6 +76,38 @@ export function convert(value, from, to) { return newValue } +/** + * Used to calculate the distance between two n-dimensional points, + * + * @param {*} a First point + * @param {*} b Second point + * + * @return {*} Euclidean distance between the given two points. + */ +export function distance(a, b) { + return a + .map((x, i) => Math.abs(x - b[i]) ** 2) // square the difference + .reduce((sum, now) => sum + now) ** // sum + (1 / 2) +} + +/** + * Used to merge two Javascript objects into a new third object by recursively + * overwriting and extending the target object with properties from the source + * object. + * + * @param {*} target The values to convert + * @param {*} source Original unit. + * + * @return {*} A copy of the original data with units converted. + */ +export function mergeObjects(source, target, copy = false) { + // First create a deep clone that will be used as the returned object + let cloned = cloneDeep(target) + let val = merge(cloned, source) + return val +} + export function arraysEqual(_arr1, _arr2) { if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false diff --git a/gui/yarn.lock b/gui/yarn.lock index 42128dd22622948260812f8f9a9f757b1734d124..50868f94c665877352d4e998f1dab451687ef88f 100644 --- a/gui/yarn.lock +++ b/gui/yarn.lock @@ -9041,6 +9041,11 @@ pkg-up@^2.0.0: dependencies: find-up "^2.1.0" +plotly.js-cartesian-dist-min@^1.54.7: + version "1.54.7" + resolved "https://registry.yarnpkg.com/plotly.js-cartesian-dist-min/-/plotly.js-cartesian-dist-min-1.54.7.tgz#f44fc89a3221d89486efe8d25a19efb5a35156d1" + integrity sha512-8aCk2HQ9rYQZp0ivpkq1wHtByAiPEPBFIw+jMC5elV3/WLXuZvNjwRqnXECLMQiuEilEsEZhmWisp1/QvQ06JA== + pn@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" diff --git a/nomad/app/api/encyclopedia.py b/nomad/app/api/encyclopedia.py index 48863244fe98b0ed01b882c9cf58869dba8ab186..270954d8269d8489599587595c17b93470121060 100644 --- a/nomad/app/api/encyclopedia.py +++ b/nomad/app/api/encyclopedia.py @@ -141,7 +141,7 @@ material_result = api.model("material_result", { class EncMaterialResource(Resource): @api.response(404, "The material does not exist") @api.response(200, "Metadata send", fields.Raw) - @api.doc("material/<material_id>") + @api.doc("get_material") @api.expect(material_query) @api.marshal_with(material_result, skip_none=True) @authenticate() @@ -860,7 +860,7 @@ class EncCalculationsResource(Resource): @api.response(404, "Suggestion not found") @api.response(400, "Bad request") @api.response(200, "Metadata send", fields.Raw) - @api.doc("enc_calculations") + @api.doc("get_calculations") @authenticate() def get(self, material_id): """Used to return all calculations related to the given material. Also @@ -1205,7 +1205,7 @@ class EncCalculationResource(Resource): @api.response(200, "Metadata send", fields.Raw) @api.expect(calculation_property_query, validate=False) @api.marshal_with(calculation_property_result, skip_none=True) - @api.doc("enc_calculation") + @api.doc("get_calculation") @authenticate() def post(self, material_id, calc_id): """Used to return calculation details. Some properties are not