├── .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 |
22 | You need to enable JavaScript to run this app.
23 |
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 |
28 | Reset Camera
29 |
30 |
31 |
Layouts {' '}
32 |
setLayout('grid')}
34 | className={layout === 'grid' ? 'active' : undefined}
35 | >
36 | Grid
37 |
38 |
setLayout('spiral')}
40 | className={layout === 'spiral' ? 'active' : undefined}
41 | >
42 | Spiral
43 |
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 |
--------------------------------------------------------------------------------