├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── public └── index.html ├── src ├── App.js ├── ThreePointVis │ ├── Controls.js │ ├── Effects.js │ ├── InstancedPoints.js │ ├── ThreePointVis.js │ └── layouts.js ├── index.js └── styles.css └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "fluid": false 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cortico 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 3D React Demo 2 | 3 | This is a demo of using React, Three.js, and react-three-fiber together to render a fancy 3D scatterplot of sorts. 4 | 5 | * [The blog post on the Cortico blog](https://medium.com/cortico/3d-data-visualization-with-react-and-three-js-7272fb6de432) 6 | * [The demo](https://y5nrg.csb.app/) 7 | 8 | If you have any questions, feel free to ask [Peter Beshai (@pbesh)](https://twitter.com/pbesh) on Twitter. 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3d-react-demo", 3 | "version": "1.0.0", 4 | "description": "Demo of doing 3D data vis with React, Three, and react-three-fiber", 5 | "homepage": "https://corticoai.github.io/3d-react-demo", 6 | "keywords": [ 7 | "3d", 8 | "react", 9 | "three", 10 | "react-three-fiber", 11 | "datavis", 12 | "dataviz" 13 | ], 14 | "main": "src/index.js", 15 | "dependencies": { 16 | "react": "16.12.0", 17 | "react-dom": "16.12.0", 18 | "react-scripts": "3.0.1", 19 | "react-spring": "8.0.27", 20 | "react-three-fiber": "4.0.12", 21 | "three": "0.112.1" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test --env=jsdom", 27 | "eject": "react-scripts eject", 28 | "deploy": "gh-pages -d build" 29 | }, 30 | "browserslist": [ 31 | ">0.2%", 32 | "not dead", 33 | "not ie <= 11", 34 | "not op_mini all" 35 | ], 36 | "devDependencies": { 37 | "gh-pages": "^2.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | 3D React Demo 18 | 19 | 20 | 21 | 24 |
25 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ThreePointVis from './ThreePointVis/ThreePointVis'; 3 | import './styles.css'; 4 | 5 | const data = new Array(10000).fill(0).map((d, id) => ({ id })); 6 | 7 | export default function App() { 8 | const [layout, setLayout] = React.useState('grid'); 9 | const [selectedPoint, setSelectedPoint] = React.useState(null); 10 | 11 | const visRef = React.useRef(); 12 | const handleResetCamera = () => { 13 | visRef.current.resetCamera(); 14 | }; 15 | 16 | return ( 17 |
18 |
19 | 26 |
27 | 30 |
31 | Layouts{' '} 32 | 38 | 44 | {selectedPoint && ( 45 |
46 | You selected {selectedPoint.id} 47 |
48 | )} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/ThreePointVis/Controls.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { extend, useThree, useFrame } from 'react-three-fiber'; 3 | import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls'; 4 | import * as THREE from 'three'; 5 | 6 | // extend THREE to include TrackballControls 7 | extend({ TrackballControls }); 8 | 9 | // key code constants 10 | const ALT_KEY = 18; 11 | const CTRL_KEY = 17; 12 | const CMD_KEY = 91; 13 | 14 | const Controls = ({}, ref) => { 15 | const controls = React.useRef(); 16 | const { camera, gl } = useThree(); 17 | 18 | useFrame(() => { 19 | // update the view as the vis is interacted with 20 | controls.current.update(); 21 | }); 22 | 23 | React.useImperativeHandle(ref, () => ({ 24 | resetCamera: () => { 25 | // reset look-at (target) and camera position 26 | controls.current.target.set(0, 0, 0); 27 | camera.position.set(0, 0, 80); 28 | 29 | // needed for trackball controls, reset the up vector 30 | camera.up.set( 31 | controls.current.up0.x, 32 | controls.current.up0.y, 33 | controls.current.up0.z 34 | ); 35 | }, 36 | })); 37 | 38 | return ( 39 | 54 | ); 55 | }; 56 | 57 | export default React.forwardRef(Controls); 58 | -------------------------------------------------------------------------------- /src/ThreePointVis/Effects.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import React, { useRef, useEffect, useMemo } from 'react'; 3 | import { extend, useThree, useFrame } from 'react-three-fiber'; 4 | import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'; 5 | import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'; 6 | import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'; 7 | import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass'; 8 | import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'; 9 | 10 | extend({ EffectComposer, ShaderPass, RenderPass, UnrealBloomPass }); 11 | 12 | export default function Effects() { 13 | const composer = useRef(); 14 | const { scene, gl, size, camera } = useThree(); 15 | const aspect = useMemo(() => new THREE.Vector2(size.width, size.height), [ 16 | size, 17 | ]); 18 | useEffect(() => void composer.current.setSize(size.width, size.height), [ 19 | size, 20 | ]); 21 | useFrame(() => composer.current.render(), 1); 22 | 23 | const bloom = { 24 | resolution: aspect, 25 | strength: 0.6, 26 | radius: 0.01, 27 | threshold: 0.4, 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | 38 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/ThreePointVis/InstancedPoints.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as THREE from 'three'; 3 | import { useAnimatedLayout } from './layouts'; 4 | import { a } from 'react-spring/three'; 5 | 6 | // re-use for instance computations 7 | const scratchObject3D = new THREE.Object3D(); 8 | 9 | function updateInstancedMeshMatrices({ mesh, data }) { 10 | if (!mesh) return; 11 | 12 | // set the transform matrix for each instance 13 | for (let i = 0; i < data.length; ++i) { 14 | const { x, y, z } = data[i]; 15 | 16 | scratchObject3D.position.set(x, y, z); 17 | scratchObject3D.rotation.set(0.5 * Math.PI, 0, 0); // cylinders face z direction 18 | scratchObject3D.updateMatrix(); 19 | mesh.setMatrixAt(i, scratchObject3D.matrix); 20 | } 21 | 22 | mesh.instanceMatrix.needsUpdate = true; 23 | } 24 | 25 | const SELECTED_COLOR = '#6f6'; 26 | const DEFAULT_COLOR = '#888'; 27 | 28 | // re-use for instance computations 29 | const scratchColor = new THREE.Color(); 30 | 31 | const usePointColors = ({ data, selectedPoint }) => { 32 | const numPoints = data.length; 33 | const colorAttrib = React.useRef(); 34 | const colorArray = React.useMemo(() => new Float32Array(numPoints * 3), [ 35 | numPoints, 36 | ]); 37 | 38 | React.useEffect(() => { 39 | for (let i = 0; i < data.length; ++i) { 40 | scratchColor.set( 41 | data[i] === selectedPoint ? SELECTED_COLOR : DEFAULT_COLOR 42 | ); 43 | scratchColor.toArray(colorArray, i * 3); 44 | } 45 | colorAttrib.current.needsUpdate = true; 46 | }, [data, selectedPoint, colorArray]); 47 | 48 | return { colorAttrib, colorArray }; 49 | }; 50 | 51 | const useMousePointInteraction = ({ data, selectedPoint, onSelectPoint }) => { 52 | // track mousedown position to skip click handlers on drags 53 | const mouseDownRef = React.useRef([0, 0]); 54 | const handlePointerDown = e => { 55 | mouseDownRef.current[0] = e.clientX; 56 | mouseDownRef.current[1] = e.clientY; 57 | }; 58 | 59 | const handleClick = event => { 60 | const { instanceId, clientX, clientY } = event; 61 | const downDistance = Math.sqrt( 62 | Math.pow(mouseDownRef.current[0] - clientX, 2) + 63 | Math.pow(mouseDownRef.current[1] - clientY, 2) 64 | ); 65 | 66 | // skip click if we dragged more than 5px distance 67 | if (downDistance > 5) { 68 | event.stopPropagation(); 69 | return; 70 | } 71 | 72 | // index is instanceId if we never change sort order 73 | const index = instanceId; 74 | const point = data[index]; 75 | 76 | console.log('got point =', point); 77 | // toggle the point 78 | if (point === selectedPoint) { 79 | onSelectPoint(null); 80 | } else { 81 | onSelectPoint(point); 82 | } 83 | }; 84 | 85 | return { handlePointerDown, handleClick }; 86 | }; 87 | 88 | const InstancedPoints = ({ data, layout, selectedPoint, onSelectPoint }) => { 89 | const meshRef = React.useRef(); 90 | const numPoints = data.length; 91 | 92 | // run the layout, animating on change 93 | const { animationProgress } = useAnimatedLayout({ 94 | data, 95 | layout, 96 | onFrame: () => { 97 | updateInstancedMeshMatrices({ mesh: meshRef.current, data }); 98 | }, 99 | }); 100 | 101 | // update instance matrices only when needed 102 | React.useEffect(() => { 103 | updateInstancedMeshMatrices({ mesh: meshRef.current, data }); 104 | }, [data, layout]); 105 | 106 | const { handleClick, handlePointerDown } = useMousePointInteraction({ 107 | data, 108 | selectedPoint, 109 | onSelectPoint, 110 | }); 111 | const { colorAttrib, colorArray } = usePointColors({ data, selectedPoint }); 112 | 113 | return ( 114 | <> 115 | 122 | 123 | 128 | 129 | 133 | 134 | {selectedPoint && ( 135 | [ 137 | selectedPoint.x, 138 | selectedPoint.y, 139 | selectedPoint.z, 140 | ])} 141 | > 142 | 149 | 156 | 157 | )} 158 | 159 | ); 160 | }; 161 | 162 | export default InstancedPoints; 163 | -------------------------------------------------------------------------------- /src/ThreePointVis/ThreePointVis.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Canvas } from 'react-three-fiber'; 3 | import Controls from './Controls'; 4 | import InstancedPoints from './InstancedPoints'; 5 | import Effects from './Effects'; 6 | 7 | const ThreePointVis = ({ data, layout, selectedPoint, onSelectPoint }, ref) => { 8 | const controlsRef = React.useRef(); 9 | React.useImperativeHandle(ref, () => ({ 10 | resetCamera: () => { 11 | return controlsRef.current.resetCamera(); 12 | }, 13 | })); 14 | 15 | return ( 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default React.forwardRef(ThreePointVis); 37 | -------------------------------------------------------------------------------- /src/ThreePointVis/layouts.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSpring } from 'react-spring/three'; 3 | 4 | function gridLayout(data) { 5 | const numPoints = data.length; 6 | const numCols = Math.ceil(Math.sqrt(numPoints)); 7 | const numRows = numCols; 8 | 9 | for (let i = 0; i < numPoints; ++i) { 10 | const datum = data[i]; 11 | const col = (i % numCols) - numCols / 2; 12 | const row = Math.floor(i / numCols) - numRows / 2; 13 | 14 | datum.x = col * 1.05; 15 | datum.y = row * 1.05; 16 | datum.z = 0; 17 | } 18 | } 19 | 20 | function spiralLayout(data) { 21 | // equidistant points on a spiral 22 | let theta = 0; 23 | for (let i = 0; i < data.length; ++i) { 24 | const datum = data[i]; 25 | const radius = Math.max(1, Math.sqrt(i + 1) * 0.8); 26 | theta += Math.asin(1 / radius) * 1; 27 | 28 | datum.x = radius * Math.cos(theta); 29 | datum.y = radius * Math.sin(theta); 30 | datum.z = 0; 31 | } 32 | } 33 | 34 | export const useLayout = ({ data, layout = 'grid' }) => { 35 | React.useEffect(() => { 36 | switch (layout) { 37 | case 'spiral': 38 | spiralLayout(data); 39 | break; 40 | case 'grid': 41 | default: { 42 | gridLayout(data); 43 | } 44 | } 45 | }, [data, layout]); 46 | }; 47 | 48 | function useSourceTargetLayout({ data, layout }) { 49 | // prep for new animation by storing source 50 | React.useEffect(() => { 51 | for (let i = 0; i < data.length; ++i) { 52 | data[i].sourceX = data[i].x || 0; 53 | data[i].sourceY = data[i].y || 0; 54 | data[i].sourceZ = data[i].z || 0; 55 | } 56 | }, [data, layout]); 57 | 58 | // run layout 59 | useLayout({ data, layout }); 60 | 61 | // store target 62 | React.useEffect(() => { 63 | for (let i = 0; i < data.length; ++i) { 64 | data[i].targetX = data[i].x; 65 | data[i].targetY = data[i].y; 66 | data[i].targetZ = data[i].z; 67 | } 68 | }, [data, layout]); 69 | } 70 | 71 | function interpolateSourceTarget(data, progress) { 72 | for (let i = 0; i < data.length; ++i) { 73 | data[i].x = (1 - progress) * data[i].sourceX + progress * data[i].targetX; 74 | data[i].y = (1 - progress) * data[i].sourceY + progress * data[i].targetY; 75 | data[i].z = (1 - progress) * data[i].sourceZ + progress * data[i].targetZ; 76 | } 77 | } 78 | 79 | export function useAnimatedLayout({ data, layout, onFrame }) { 80 | // compute layout remembering initial position as source and 81 | // end position as target 82 | useSourceTargetLayout({ data, layout }); 83 | 84 | // do the actual animation when layout changes 85 | const prevLayout = React.useRef(layout); 86 | const animProps = useSpring({ 87 | animationProgress: 1, 88 | from: { animationProgress: 0 }, 89 | reset: layout !== prevLayout.current, 90 | onFrame: ({ animationProgress }) => { 91 | // interpolate based on progress 92 | interpolateSourceTarget(data, animationProgress); 93 | // callback to indicate data has updated 94 | onFrame({ animationProgress }); 95 | }, 96 | }); 97 | prevLayout.current = layout; 98 | 99 | return animProps; 100 | } 101 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | ReactDOM.render(, rootElement); 8 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #000; 9 | } 10 | 11 | .App { 12 | height: 100vh; 13 | position: relative; 14 | padding: 16px; 15 | box-sizing: border-box; 16 | } 17 | 18 | .vis-container { 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | } 25 | 26 | .controls { 27 | position: relative; 28 | z-index: 1; 29 | background: rgba(0, 0, 0, 0.9); 30 | padding: 16px; 31 | color: #fff; 32 | width: 200px; 33 | border-radius: 16px; 34 | } 35 | 36 | .controls > button, 37 | .reset-button { 38 | padding: 10px; 39 | background: #222; 40 | color: #ccc; 41 | border: none; 42 | margin: 2px; 43 | text-transform: uppercase; 44 | letter-spacing: 1px; 45 | cursor: pointer; 46 | } 47 | 48 | .controls > button:hover, 49 | .reset-button:hover { 50 | background-color: #111; 51 | color: #fff; 52 | } 53 | 54 | .controls > button.active { 55 | font-weight: bold; 56 | background-color: #333; 57 | color: #ff0; 58 | } 59 | 60 | .selected-point { 61 | font-size: 0.85em; 62 | margin-top: 16px; 63 | } 64 | 65 | .reset-button { 66 | position: absolute; 67 | right: 16px; 68 | top: 16px; 69 | z-index: 1; 70 | } 71 | --------------------------------------------------------------------------------