diff --git a/.babelrc b/.babelrc index ada5bdb8..4132556f 100644 --- a/.babelrc +++ b/.babelrc @@ -3,4 +3,4 @@ "plugins": [ "@babel/transform-runtime" ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index ee801994..65a76524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "^16.14.0", "react-aria-menubutton": "^7.0.1", "react-collapsible": "^2.8.3", + "react-compound-slider": "^3.4.0", "react-data-table-component": "^6.11.6", "react-dom": "^16.14.0", "react-graph-vis": "^1.0.5", @@ -17873,6 +17874,11 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -26946,6 +26952,27 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-compound-slider": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/react-compound-slider/-/react-compound-slider-3.4.0.tgz", + "integrity": "sha512-KSje/rB0xSvvcb7YV0+82hkiXTV5ljSS7axKrNiXLf9AEO+rrr1Xq4MJWA+6v030YNNo/RoSoEB6D6fnoy+8ng==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "d3-array": "^2.8.0", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.9" + } + }, + "node_modules/react-compound-slider/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, "node_modules/react-data-table-component": { "version": "6.11.8", "resolved": "https://registry.npmjs.org/react-data-table-component/-/react-data-table-component-6.11.8.tgz", @@ -32515,6 +32542,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", @@ -47065,6 +47100,11 @@ "side-channel": "^1.0.4" } }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -54006,6 +54046,26 @@ "dev": true, "requires": {} }, + "react-compound-slider": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/react-compound-slider/-/react-compound-slider-3.4.0.tgz", + "integrity": "sha512-KSje/rB0xSvvcb7YV0+82hkiXTV5ljSS7axKrNiXLf9AEO+rrr1Xq4MJWA+6v030YNNo/RoSoEB6D6fnoy+8ng==", + "requires": { + "@babel/runtime": "^7.12.5", + "d3-array": "^2.8.0", + "warning": "^4.0.3" + }, + "dependencies": { + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + } + } + }, "react-data-table-component": { "version": "6.11.8", "resolved": "https://registry.npmjs.org/react-data-table-component/-/react-data-table-component-6.11.8.tgz", @@ -58322,6 +58382,14 @@ "makeerror": "1.0.12" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", diff --git a/package.json b/package.json index fd63f4be..cb5441a2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react": "^16.14.0", "react-aria-menubutton": "^7.0.1", "react-collapsible": "^2.8.3", + "react-compound-slider": "^3.4.0", "react-data-table-component": "^6.11.6", "react-dom": "^16.14.0", "react-graph-vis": "^1.0.5", diff --git a/src/app.tsx b/src/app.tsx index 55c43f38..06ef2cf8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -22,6 +22,7 @@ import { Sandbox } from './pages/Sandbox'; import { SynthesisExplorer } from './pages/SynthesisExplorer'; import { MPContribsSearch } from './pages/MPContribsSearch'; import { CatalystExplorer } from './pages/CatalystExplorer'; +import { TilingVisualization } from './pages/TilingVisualization'; import { Navbar } from './components/navigation/Navbar'; import periodicTableImage from './assets/images/periodictable.png'; import { MofExplorer } from './pages/MofExplorer'; @@ -45,6 +46,7 @@ ReactDOM.render( Phase Diagram Contributions Crystal Structure + Tiling Visualization Sandbox
@@ -85,6 +87,9 @@ ReactDOM.render( + + + diff --git a/src/components/crystal-toolkit/CrystalToolkitScene/CrystalToolkitScene.tsx b/src/components/crystal-toolkit/CrystalToolkitScene/CrystalToolkitScene.tsx index f1858774..ed442b73 100644 --- a/src/components/crystal-toolkit/CrystalToolkitScene/CrystalToolkitScene.tsx +++ b/src/components/crystal-toolkit/CrystalToolkitScene/CrystalToolkitScene.tsx @@ -107,6 +107,14 @@ export interface CrystalToolkitSceneProps { */ settings?: any; + /** + * the dynamic tiling, to be updated in scene. + */ + tiling?: number[]; + /** + * the maximum size of the tiling, controls render size + */ + maxTiling?: number; /** * Hide/show nodes in scene by its name (key), value is 1 to show the node * and 0 to hide it. @@ -371,6 +379,8 @@ export const CrystalToolkitScene: React.FC = ({ props.settings, props.inletSize, props.inletPadding, + props.tiling, + props.maxTiling, (objects) => { if (props.onObjectClicked) { props.onObjectClicked(objects); @@ -433,6 +443,9 @@ export const CrystalToolkitScene: React.FC = ({ () => scene.current!.updateInsetSettings(props.inletSize!, props.inletPadding!, props.axisView), [props.inletSize, props.inletPadding, props.axisView] ); + useEffect(() => { + scene.current!.updateTiles(props.tiling); + }, [props.tiling]); useEffect(() => { scene.current!.resizeRendererToDisplaySize(); diff --git a/src/components/crystal-toolkit/scene/Scene.ts b/src/components/crystal-toolkit/scene/Scene.ts index b8103242..8a8b2dcb 100644 --- a/src/components/crystal-toolkit/scene/Scene.ts +++ b/src/components/crystal-toolkit/scene/Scene.ts @@ -32,7 +32,6 @@ import '../CrystalToolkitScene/CrystalToolkitScene.less'; import { CameraState } from '../CameraContextProvider/camera-reducer'; const POINTER_CLASS = 'show-pointer'; -let D; export default class Scene { private settings; private renderer!: THREE.WebGLRenderer | SVGRenderer; @@ -69,6 +68,9 @@ export default class Scene { private clock = new THREE.Clock(); private animationHelper: AnimationHelper; + private tiling: any; + private maxTiling: any; + private arrayOfTileRoots: any; private cacheMountBBox(mountNode: Element) { this.cachedMountNodeSize = { width: mountNode.clientWidth, height: mountNode.clientHeight }; @@ -108,6 +110,36 @@ export default class Scene { mountNode.appendChild(this.renderer.domElement); } + /* + this function returns a 3-dimensional empty array based on the tiling array + e.g. _getTiles([1, 1, 1] === [ [ [[], []], [[], []] ], [ [[], []], [[], []] ] ] + all of the arrays are unique instances, not copies + this allows us to create an array that can store the contents of each tiles and + be accessed with the tile indices. For example: + arr = _getTiles([2, 2, 2]) + arr[0][1][2].push(scene) + scene = arr[0][1][2][0] + */ + private static getEmptyTilesArray(tiling: number[]) { + let grid = []; + for (let x = 0; x <= tiling[2]; x++) { + let arrX = []; + for (let y = 0; y <= tiling[1]; y++) { + let arrY = []; + for (let z = 0; z <= tiling[0]; z++) { + let arrZ = []; + // @ts-ignore + arrY.push(arrZ); + } + // @ts-ignore + arrX.push(arrY); + } + // @ts-ignore + grid.push(arrX); + } + return grid; + } + private configureLabelRenderer(mountNode: Element) { const labelRenderer = new CSS2DRenderer(); this.labelRenderer = labelRenderer; @@ -242,7 +274,7 @@ export default class Scene { private onClickImplementation(p, e) { let needRedraw = false; - //TODO(chab) make it more readale + //TODO(chab) make it more readable if (p && p.object) { const { object, point } = p; if (object?.sceneObject) { @@ -361,11 +393,20 @@ export default class Scene { settings, size, padding, + tiling, + maxTiling, clickCallback, private dispatch: (p: Vector3, r: Quaternion, zoom: number) => void, private debugDOMElement?, cameraState?: CameraState ) { + this.tiling = tiling; + this.maxTiling = maxTiling; + this.arrayOfTileRoots = Scene.getEmptyTilesArray([ + this.maxTiling, + this.maxTiling, + this.maxTiling + ]); this.settings = Object.assign(defaults, settings); this.objectBuilder = new ThreeBuilder(this.settings); this.cameraState = cameraState; @@ -403,6 +444,23 @@ export default class Scene { this.renderInlet(); } + /* + loop through the arrayOfTileRoots and set the visibility of each object. + In particular, set threeObject.visible = true if the x, y, and z indices + are all less than the x, y, and z indices in tiling. + */ + updateTiles(tiling) { + const [xCut, yCut, zCut] = tiling; + this.arrayOfTileRoots.forEach((arrX, x) => { + arrX.forEach((arrY, y) => { + arrY.forEach((arrZ, z) => { + arrZ[0].visible = x <= xCut && y <= yCut && z <= zCut; + }); + }); + }); + this.renderScene(); + } + public resizeRendererToDisplaySize() { const canvas = this.renderer.domElement as HTMLCanvasElement; this.cacheMountBBox(canvas.parentElement as Element); @@ -418,7 +476,7 @@ export default class Scene { } addToScene(sceneJson: SceneJsonObject, bypassRendering = false) { - // we need to clarify the current semantics + // we need to clarify the current semantics // currently, it will remove the old scene if the name is the same, // otherwise it will keep it // it will then zoom on the content of the added scene @@ -445,15 +503,73 @@ export default class Scene { console.log('The scene is a new scene:', sceneJson.name); } - const rootObject = new THREE.Object3D(); - rootObject.name = sceneJson.name!; - sceneJson.visible && (rootObject.visible = sceneJson.visible); - const objectToAnimate = new Set(); - // recursively visit the scene, starting with the root object - const traverse_scene = (o: SceneJsonObject, parent: THREE.Object3D, currentId: string) => { + + /* + this function returns an array of tiles based on the tiling array + e.g. _getTiles([0, 1, 1] === [[0,0,0], [0,0,1], [0,1,1], [0,1,0]] + */ + const _getTiles = (tiling: number[]) => { + // enumerate all tiles needed for a given tiling size + let tiles: number[][] = []; + for (let x: number = 0; x <= tiling[0]; x++) { + for (let y: number = 0; y <= tiling[1]; y++) { + for (let z: number = 0; z <= tiling[2]; z++) { + tiles.push([x, y, z]); + } + } + } + return tiles; + }; + + const emptyLattice = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0] + ]; + + /* + This function traverses through all the tiles and renders the SceneJsonObject once for each. + Each new scene is a child of root and is offset according to the SceneJsonObject.lattice. + Each scene is added to the arrayOfTilesRoots, it can be accessed by indexing through the + arrayOfTileRoots. e.g. scene = arrayOfTileRoots[x][y][z][0]. + */ + const traverseTiles = (o: SceneJsonObject, root: THREE.Object3D, tiles: number[][]) => { + // @ts-ignore + let lattice = o.lattice ? o.lattice : emptyLattice; + + for (const tile of tiles) { + const tileRootObject = new THREE.Object3D(); + tileRootObject.name = sceneJson.name!; + sceneJson.visible && (tileRootObject.visible = sceneJson.visible); + root.add(tileRootObject); + const [x, y, z] = tile; + this.arrayOfTileRoots[x][y][z].push(tileRootObject); + + let tileOffsets: number[][] = lattice.map((vector: number[], index: number) => { + return vector.map((x: number) => x * tile[index]); + }); + traverseScene(sceneJson, tileRootObject, tileOffsets, ''); + } + }; + + /* + This is the core rendering loop of the Scene class. This function recursively + traverses through the scene graph and render all objects that can be converted + into threeObject's. + It will apply the tileOffset to the rendered scenes, translating them relative + to the parent threeObject. + */ + const traverseScene = ( + o: SceneJsonObject, + parent: THREE.Object3D, + tileOffsets: number[][], + currentId: string + ) => { + // create root and add to o.contents!.forEach((childObject, idx) => { if (childObject.type) { + // render the threeObject according to childObject.type and end recursion const object = this.makeObject(childObject); parent.add(object); this.threeUUIDTojsonObject[object.uuid] = childObject; @@ -463,11 +579,23 @@ export default class Scene { objectToAnimate.add(`${currentId}--${idx}`); } } else { + // create threeObject, save id, and add to arrayOfTileRoots const threeObject = new THREE.Object3D(); threeObject.name = childObject.name!; this.computeIdToThree[`${currentId}--${threeObject.name}`] = threeObject; childObject.id = `${currentId}--${threeObject.name}`; threeObject.visible = childObject.visible === undefined ? true : !!childObject.visible; + + // translate tile according to lattice vectors + for (let offset of tileOffsets) { + if (threeObject.name !== 'unit_cell') { + const tilingTranslation = new THREE.Matrix4(); + tilingTranslation.makeTranslation(...(offset as ThreePosition)); + threeObject.applyMatrix4(tilingTranslation); + } + } + + // translate tile to scene origin if (childObject.origin) { const translation = new THREE.Matrix4(); // note(chab) have a typedefinition for the JSON @@ -477,7 +605,9 @@ export default class Scene { if (!this.settings.extractAxis || threeObject.name !== 'axes') { parent.add(threeObject); } - traverse_scene(childObject, threeObject, `${currentId}--${threeObject.name}`); + + // recurse through Scene graph + traverseScene(childObject, threeObject, tileOffsets, `${currentId}--${threeObject.name}`); if (threeObject.name === 'axes') { this.axis = threeObject.clone(); this.axisJson = { ...childObject }; @@ -486,9 +616,21 @@ export default class Scene { }); }; - traverse_scene(sceneJson, rootObject, ''); + // set up the threeObjects and containers + const rootObject = new THREE.Object3D(); + + // set up the threeObjects and containers + rootObject.name = 'root'; + rootObject.visible = true; + const maxTilingArray = [this.maxTiling, this.maxTiling, this.maxTiling]; + // get list of tiles needed + let tiles = _getTiles(maxTilingArray); + // render all tiles + traverseTiles(sceneJson, rootObject, tiles); + // hide/show tiles as appropriate + this.updateTiles(this.tiling); + // can cause memory leak - //console.log('rootObject', rootObject, rootObject); this.scene.add(rootObject); this.setupCamera(rootObject); diff --git a/src/components/crystal-toolkit/scene/animation-slider.tsx b/src/components/crystal-toolkit/scene/animation-slider.tsx new file mode 100644 index 00000000..4c8b7e31 --- /dev/null +++ b/src/components/crystal-toolkit/scene/animation-slider.tsx @@ -0,0 +1,323 @@ +import React, { Component, Fragment } from 'react'; +import { Slider, Rail, Handles, Tracks, Ticks } from 'react-compound-slider'; +import PropTypes from 'prop-types'; + +const sliderStyle: any = { + position: 'relative', + margin: 'auto', + width: '80%', + touchAction: 'none' +}; + +const defaultDomain: any = [0, 100]; +const defaultValues: any = [0]; + +class SimpleSlider extends Component { + constructor(props: any) { + super(props); + } + + state = { + values: defaultValues.slice(), + update: defaultValues.slice() + }; + + onUpdate = (update) => { + this.props.onUpdate(update[0]); + this.setState({ update }); + }; + + onChange = (values) => { + this.props.onChange(values[0]); + this.setState({ values }); + }; + + render() { + const { + state: { values, update } + } = this; + + return ( +
+ + {({ getRailProps }) => } + + {({ handles, getHandleProps }) => ( +
+ {handles.map((handle) => ( + + ))} +
+ )} +
+ + {({ tracks, getTrackProps }) => ( +
+ {tracks.map(({ id, source, target }) => ( + + ))} +
+ )} +
+ + {({ ticks }) => ( +
+ {ticks.map((tick) => ( + + ))} +
+ )} +
+
+
+ ); + } +} + +// ******************************************************* +// RAIL +// ******************************************************* +const railOuterStyle = { + position: 'absolute', + width: '100%', + height: 42, + transform: 'translate(0%, -50%)', + borderRadius: 7, + cursor: 'pointer' + // border: '1px solid white', +}; + +const railInnerStyle: any = { + position: 'absolute', + width: '100%', + height: 14, + transform: 'translate(0%, -50%)', + borderRadius: 7, + pointerEvents: 'none', + backgroundColor: 'rgb(155,155,155)' +}; + +export function SliderRail({ getRailProps }) { + return ( + +
+
+ + ); +} + +SliderRail.propTypes = { + getRailProps: PropTypes.func.isRequired +}; + +// ******************************************************* +// HANDLE COMPONENT +// ******************************************************* +export function Handle({ + domain: [min, max], + handle: { id, value, percent }, + disabled, + getHandleProps +}) { + return ( + +
+
+ + ); +} + +Handle.propTypes = { + domain: PropTypes.array.isRequired, + handle: PropTypes.shape({ + id: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + percent: PropTypes.number.isRequired + }).isRequired, + getHandleProps: PropTypes.func.isRequired, + disabled: PropTypes.bool +}; + +Handle.defaultProps = { + disabled: false +}; + +// ******************************************************* +// KEYBOARD HANDLE COMPONENT +// Uses a button to allow keyboard events +// ******************************************************* +export function KeyboardHandle({ + domain: [min, max], + handle: { id, value, percent }, + disabled, + getHandleProps +}) { + return ( +