aae43e9421
Major build system migration replacing react-scripts with Vite. Upgrades React to v19, Redux Toolkit to v2, Three.js to 0.184, and replaces axios with ky. Removes IE11 support, test infrastructure, and polyfills. Updates TypeScript config to project references and bumps version to 3.0.0.
193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
import ky from 'ky';
|
|
import type React from 'react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import * as THREE from 'three';
|
|
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls';
|
|
import { useAppDispatch } from '../../app/hooks';
|
|
import DataUtils from '../data/DataUtils';
|
|
import Loading from '../loading/Loading';
|
|
import NotFound from '../not-found/NotFound';
|
|
import { setTitle } from '../page-title/pageTitleSlice';
|
|
import styles from './DataViewer.module.css';
|
|
|
|
class Viewer3D {
|
|
private mount: HTMLDivElement;
|
|
private frameId: number | null = null;
|
|
private scene: THREE.Scene | null = null;
|
|
private camera: THREE.PerspectiveCamera | null = null;
|
|
private controls: TrackballControls | null = null;
|
|
private renderer: THREE.WebGLRenderer | null = null;
|
|
|
|
constructor(mount: HTMLDivElement) {
|
|
this.mount = mount;
|
|
this.start = this.start.bind(this);
|
|
this.stop = this.stop.bind(this);
|
|
this.animate = this.animate.bind(this);
|
|
}
|
|
|
|
init(data: string) {
|
|
const lines = data.split('\n');
|
|
const head = lines
|
|
.shift()
|
|
?.trim()
|
|
.split('\t')
|
|
.map((line) => {
|
|
return parseInt(line.replace(/^#/g, ''), 10) || 0;
|
|
});
|
|
if (typeof head === 'undefined' || head.length !== 3 || head.includes(0)) {
|
|
return false;
|
|
}
|
|
const width = this.mount.clientWidth;
|
|
const height = this.mount.clientHeight;
|
|
const fov = 7.5 + (head[0] * 2.5) / 100.0;
|
|
this.camera = new THREE.PerspectiveCamera(fov, width / height, 1, 10000);
|
|
this.camera.position.x = 1300;
|
|
this.camera.position.y = 1300;
|
|
this.camera.position.z = 1100;
|
|
this.camera.up.set(0, 0, 1);
|
|
this.controls = new TrackballControls(this.camera, this.mount);
|
|
const obj3d = new THREE.Object3D();
|
|
this.scene = new THREE.Scene();
|
|
this.scene.add(obj3d);
|
|
const DrawCube = (width: number, height: number, depth: number) => {
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth * 2), new THREE.MeshBasicMaterial());
|
|
const cube = new THREE.BoxHelper(mesh, 0x000000);
|
|
return cube;
|
|
};
|
|
obj3d.add(DrawCube(head[0], head[1], head[2]));
|
|
const material = {
|
|
e: new THREE.MeshBasicMaterial({ color: 0x0000ff }),
|
|
i: new THREE.MeshBasicMaterial({ color: 0xff0000 }),
|
|
g: new THREE.MeshBasicMaterial({ color: 0x00ff00 }),
|
|
p: new THREE.MeshBasicMaterial({ color: 0xffff00 }),
|
|
s: new THREE.MeshBasicMaterial({ color: 0xff00ff }),
|
|
ps: new THREE.MeshBasicMaterial({ color: 0x00ffff }),
|
|
};
|
|
const geometory = new THREE.SphereGeometry(4, 10, 10);
|
|
lines.forEach((line) => {
|
|
const point = line.trim().split('\t');
|
|
if (point.length === 4 && Object.hasOwn(material, point[0])) {
|
|
const particle = new THREE.Mesh(geometory, material[point[0] as 'e' | 'i' | 'g' | 'p' | 's' | 'ps']);
|
|
particle.position.x = parseFloat(point[1]) - head[0] / 2.0;
|
|
particle.position.y = parseFloat(point[2]) - head[1] / 2.0;
|
|
particle.position.z = head[2] - parseFloat(point[3]) * 2.0;
|
|
obj3d.add(particle);
|
|
}
|
|
});
|
|
try {
|
|
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
this.renderer.setSize(width, height);
|
|
this.renderer.setClearColor(0xffffff);
|
|
this.mount.appendChild(this.renderer.domElement);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
start() {
|
|
this.frameId = requestAnimationFrame(this.animate);
|
|
}
|
|
|
|
stop() {
|
|
if (this.frameId) cancelAnimationFrame(this.frameId);
|
|
if (this.renderer && this.mount) this.mount.removeChild(this.renderer.domElement);
|
|
}
|
|
|
|
animate() {
|
|
if (this.controls) this.controls.update();
|
|
if (this.renderer) this.renderer.clear();
|
|
if (this.renderer && this.scene && this.camera) this.renderer.render(this.scene, this.camera);
|
|
this.frameId = requestAnimationFrame(this.animate);
|
|
}
|
|
}
|
|
|
|
interface DataViewerProps {
|
|
name: string;
|
|
}
|
|
|
|
const DataViewer: React.FC<DataViewerProps> = ({ name: initialName }) => {
|
|
const dispatch = useAppDispatch();
|
|
const [name, setName] = useState(initialName);
|
|
const [data, setData] = useState('');
|
|
const mountRef = useRef<HTMLDivElement>(null);
|
|
const viewerRef = useRef<Viewer3D | null>(null);
|
|
const isActiveRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
isActiveRef.current = true;
|
|
if (!DataUtils.exists(name)) {
|
|
if (name !== '') {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional validation reset
|
|
setName('');
|
|
}
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
ky.get(`/data/${name}.dat`, { signal: controller.signal })
|
|
.text()
|
|
.then((data) => {
|
|
if (isActiveRef.current) {
|
|
dispatch(setTitle(`View 3D: ${name}`));
|
|
setData(data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (isActiveRef.current && !controller.signal.aborted) {
|
|
setName('');
|
|
}
|
|
});
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [name, dispatch]);
|
|
|
|
useEffect(() => {
|
|
if (mountRef.current && data !== '') {
|
|
// Stop and clean up the old viewer if switching datasets
|
|
if (viewerRef.current) {
|
|
viewerRef.current.stop();
|
|
viewerRef.current = null;
|
|
}
|
|
const viewer = new Viewer3D(mountRef.current);
|
|
if (viewer.init(data)) {
|
|
viewerRef.current = viewer;
|
|
viewerRef.current.start();
|
|
} else {
|
|
setName('@@NOWEBGL@@');
|
|
}
|
|
}
|
|
}, [data]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
isActiveRef.current = false;
|
|
if (viewerRef.current) {
|
|
viewerRef.current.stop();
|
|
viewerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
switch (name) {
|
|
case '':
|
|
return <NotFound />;
|
|
case '@@NOWEBGL@@':
|
|
return <div className={styles.no_webgl}>It does not appear your computer supports WebGL.</div>;
|
|
}
|
|
if (data === '') {
|
|
return <Loading />;
|
|
}
|
|
return (
|
|
<section>
|
|
<div className={styles.back}>
|
|
<Link to={`/data/${name}`}>Back to {name}</Link>
|
|
</div>
|
|
<div className={styles.viewer} ref={mountRef} />
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default DataViewer;
|