train 3d visualizations

This commit is contained in:
Marshal
2026-08-02 21:35:45 +00:00
parent 94c64c2390
commit 163c501f8f

View File

@@ -1,5 +1,5 @@
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
import { Maximize2, Minimize2, X } from "lucide-react";
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
@@ -32,10 +32,10 @@ const wagonKind = (w: Wagon): WagonKind => {
};
const WAGON_BODY_COLOR: Record<WagonKind, number> = {
container: 0x4a5568,
bulk: 0x7b4a2d,
tank: 0x8a8f98,
flat: 0x3d4852,
container: 0x5f7d9c,
bulk: 0xa8683f,
tank: 0xb8bec8,
flat: 0x9aa4ae,
};
const M = 1; // 1 unit = 1 meter
@@ -43,22 +43,148 @@ const GAUGE = 1.435 * M;
const WHEEL_R = 0.46 * M;
const DECK_H = 1.2 * M;
function buildStars(scene: THREE.Scene) {
const geo = new THREE.BufferGeometry();
const n = 2500;
const pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
// random point on upper hemisphere, far away
const r = 900;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 0.95); // bias above horizon
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
pos[i * 3 + 1] = Math.abs(r * Math.cos(phi)) + 20;
pos[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
// deterministic pseudo-random so scenery doesn't jump between rebuilds
const makeRng = (seed: number) => () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
function makeLabelSprite(text: string, bg: string): THREE.Sprite {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 128;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.roundRect(6, 6, 500, 116, 28);
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 6;
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.font = "bold 58px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, 256, 68);
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false }));
sprite.scale.set(7, 1.75, 1);
return sprite;
}
function buildClouds(scene: THREE.Scene, rng: () => number) {
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.92 });
for (let i = 0; i < 14; i++) {
const cloud = new THREE.Group();
const puffs = 3 + Math.floor(rng() * 4);
for (let p = 0; p < puffs; p++) {
const puff = new THREE.Mesh(new THREE.SphereGeometry(8 + rng() * 10, 12, 10), mat);
puff.position.set((p - puffs / 2) * 11, rng() * 4, (rng() - 0.5) * 10);
puff.scale.y = 0.55;
cloud.add(puff);
}
cloud.position.set((rng() - 0.5) * 1400, 90 + rng() * 60, (rng() - 0.5) * 800);
scene.add(cloud);
}
}
function buildTree(rng: () => number): THREE.Group {
const tree = new THREE.Group();
const trunkH = 2 + rng() * 2;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.25, 0.4, trunkH, 8),
new THREE.MeshStandardMaterial({ color: 0x6e4a2f, roughness: 1 }),
);
trunk.position.y = trunkH / 2;
tree.add(trunk);
const greens = [0x2f7d32, 0x3c8d40, 0x4f9e3f, 0x2e6b28];
const foliageMat = new THREE.MeshStandardMaterial({ color: greens[Math.floor(rng() * greens.length)], roughness: 1 });
if (rng() > 0.5) {
// conifer: stacked cones
for (let i = 0; i < 3; i++) {
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.4 - i * 0.6, 2.6, 10), foliageMat);
cone.position.y = trunkH + i * 1.5 + 0.8;
tree.add(cone);
}
} else {
const crown = new THREE.Mesh(new THREE.SphereGeometry(2 + rng() * 1.4, 10, 8), foliageMat);
crown.position.y = trunkH + 1.6;
crown.scale.y = 0.9;
tree.add(crown);
}
return tree;
}
function buildAnimal(rng: () => number): THREE.Group {
// low-poly cow / sheep
const isCow = rng() > 0.45;
const bodyColor = isCow ? (rng() > 0.5 ? 0x8b5a3c : 0x54432f) : 0xe8e4da;
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 1 });
const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.8), bodyMat);
body.position.y = 0.95;
g.add(body);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.5, 0.45), bodyMat);
head.position.set(0.95, 1.25, 0);
g.add(head);
const legGeo = new THREE.BoxGeometry(0.16, 0.6, 0.16);
for (const [x, z] of [[-0.6, -0.25], [-0.6, 0.25], [0.6, -0.25], [0.6, 0.25]]) {
const leg = new THREE.Mesh(legGeo, bodyMat);
leg.position.set(x, 0.3, z);
g.add(leg);
}
if (isCow) {
const patch = new THREE.Mesh(
new THREE.BoxGeometry(0.7, 0.5, 0.82),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 }),
);
patch.position.set(-0.3, 1.05, 0);
g.add(patch);
}
const scale = isCow ? 1 : 0.6;
g.scale.setScalar(scale);
g.rotation.y = rng() * Math.PI * 2;
return g;
}
function buildScenery(scene: THREE.Scene, trackLen: number) {
const rng = makeRng(1234567);
buildClouds(scene, rng);
const spread = Math.max(trackLen, 600);
// grass tufts: darker green patches on the field
const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 });
const patchGeo = new THREE.CircleGeometry(3, 8);
const patches = new THREE.InstancedMesh(patchGeo, patchMat, 220);
const m4 = new THREE.Matrix4();
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
const v = new THREE.Vector3();
const s = new THREE.Vector3();
for (let i = 0; i < 220; i++) {
const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 180);
v.set((rng() - 0.5) * spread, 0.02, z);
s.setScalar(0.5 + rng() * 2);
m4.compose(v, q, s);
patches.setMatrixAt(i, m4);
}
scene.add(patches);
for (let i = 0; i < 90; i++) {
const tree = buildTree(rng);
const z = (rng() > 0.5 ? 1 : -1) * (14 + rng() * 170);
tree.position.set((rng() - 0.5) * spread, 0, z);
tree.scale.setScalar(0.8 + rng() * 1.2);
scene.add(tree);
}
// grazing herds
for (let h = 0; h < 6; h++) {
const cx = (rng() - 0.5) * spread;
const cz = (rng() > 0.5 ? 1 : -1) * (25 + rng() * 120);
const herd = 2 + Math.floor(rng() * 4);
for (let a = 0; a < herd; a++) {
const animal = buildAnimal(rng);
animal.position.set(cx + (rng() - 0.5) * 14, 0, cz + (rng() - 0.5) * 12);
scene.add(animal);
}
}
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ color: 0xffffff, size: 1.6, sizeAttenuation: false, transparent: true, opacity: 0.85 });
scene.add(new THREE.Points(geo, mat));
}
function buildTrack(scene: THREE.Scene, length: number) {
@@ -89,7 +215,7 @@ function buildTrack(scene: THREE.Scene, length: number) {
// ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2200, 800),
new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 1 }),
new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.02;
@@ -230,6 +356,15 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
}
}
// floating loaded/empty badge above the wagon
const loaded = wagon.allocations.length > 0 || wagon.assignedWeightTons > 0;
const labelText = loaded
? `#${wagon.position ?? wagon.sequenceNo} · ${Math.round(wagon.assignedWeightTons)} t`
: `#${wagon.position ?? wagon.sequenceNo} · EMPTY`;
const label = makeLabelSprite(labelText, loaded ? "rgba(22, 130, 60, 0.95)" : "rgba(120, 128, 138, 0.9)");
label.position.set(0, DECK_H + 5.4, 0);
g.add(label);
addWheels(g, len, wheels);
g.userData.length = len;
g.userData.wagonId = wagon.id;
@@ -331,35 +466,65 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
const canvasHostRef = useRef<HTMLDivElement>(null);
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const cameraApiRef = useRef<{
overview: () => void;
front: () => void;
rear: () => void;
focusWagon: (wagonId: string) => void;
} | null>(null);
const wagons = schedule.trainSet?.wagons ?? [];
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
const orderedWagons = [...wagons].sort(
(a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo),
);
const selectedIndex = orderedWagons.findIndex((w) => w.id === selectedWagonId);
const stepWagon = (dir: 1 | -1) => {
if (orderedWagons.length === 0) return;
const next =
selectedIndex < 0
? dir === 1
? 0
: orderedWagons.length - 1
: (selectedIndex + dir + orderedWagons.length) % orderedWagons.length;
const wagon = orderedWagons[next];
setSelectedWagonId(wagon.id);
cameraApiRef.current?.focusWagon(wagon.id);
};
useEffect(() => {
const host = canvasHostRef.current;
if (!host) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02040a);
scene.fog = new THREE.Fog(0x02040a, 250, 900);
scene.background = new THREE.Color(0x9ed3f5);
scene.fog = new THREE.Fog(0xbfe3f7, 350, 1100);
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(host.clientWidth, host.clientHeight);
host.appendChild(renderer.domElement);
// lights: moonlit night
scene.add(new THREE.AmbientLight(0x8899bb, 0.35));
const moon = new THREE.DirectionalLight(0xbfd0ff, 1.1);
moon.position.set(-80, 120, 60);
scene.add(moon);
const warm = new THREE.PointLight(0xffc873, 0.6, 200);
warm.position.set(0, 25, 30);
scene.add(warm);
buildStars(scene);
// lights: bright sunny day
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0));
const sun = new THREE.DirectionalLight(0xfff4d6, 2.4);
sun.position.set(-120, 180, 90);
scene.add(sun);
const fill = new THREE.DirectionalLight(0xdbeaff, 0.6);
fill.position.set(100, 60, -80);
scene.add(fill);
// visible sun disc
const sunDisc = new THREE.Mesh(
new THREE.SphereGeometry(18, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xfff2b0 }),
);
sunDisc.position.set(-500, 380, 300);
scene.add(sunDisc);
// train assembly
const wheels: THREE.Mesh[] = [];
@@ -379,26 +544,69 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
train.add(lg);
}
const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo));
const wagonGroups: Array<{ id: string; group: THREE.Group }> = [];
for (const wagon of ordered) {
const wg = buildWagon(wagon, wheels, pickables);
wg.position.x = cursor - wg.userData.length / 2;
cursor -= wg.userData.length + gap;
train.add(wg);
wagonGroups.push({ id: wagon.id, group: wg });
}
const trainLen = -cursor;
train.position.x = trainLen / 2; // center train on origin
scene.add(train);
const trackLen = Math.max(trainLen * 2.5, 400);
buildTrack(scene, moving ? Math.max(trackLen, 1600) : trackLen);
const finalTrackLen = moving ? Math.max(trackLen, 1600) : trackLen;
buildTrack(scene, finalTrackLen);
buildScenery(scene, finalTrackLen);
camera.position.set(trainLen * 0.12, 14, 42);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 2, 0);
controls.maxPolarAngle = Math.PI / 2 - 0.03;
controls.maxDistance = 500;
controls.maxDistance = Math.max(600, trainLen * 1.6);
controls.enableDamping = true;
// camera fly-to: goal recomputed each frame so it tracks a moving train
let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null;
const worldPos = new THREE.Vector3();
const flyToObject = (obj: THREE.Object3D, dist: number, height: number) => {
cameraGoal = () => {
obj.getWorldPosition(worldPos);
return {
pos: new THREE.Vector3(worldPos.x + dist * 0.35, height, worldPos.z + dist),
target: new THREE.Vector3(worldPos.x, 2.2, worldPos.z),
};
};
};
const frontGroup = train.children[0];
const rearGroup = wagonGroups[wagonGroups.length - 1]?.group ?? frontGroup;
cameraApiRef.current = {
overview: () => {
// side view far enough to fit the whole train in the 55° fov
const dist = Math.max(40, (trainLen / 2 / Math.tan((55 * Math.PI) / 360)) * 1.15);
cameraGoal = () => {
train.getWorldPosition(worldPos);
const cx = worldPos.x - trainLen / 2; // train extends in -x from its origin
return {
pos: new THREE.Vector3(cx, Math.max(20, dist * 0.25), dist),
target: new THREE.Vector3(cx, 2, 0),
};
};
},
front: () => flyToObject(frontGroup, 26, 8),
rear: () => flyToObject(rearGroup, 20, 7),
focusWagon: (wagonId: string) => {
const entry = wagonGroups.find((w) => w.id === wagonId);
if (entry) flyToObject(entry.group, 16, 6);
},
};
// user grabs the mouse → stop auto-flying, hand control back
controls.addEventListener("start", () => {
cameraGoal = null;
});
// picking
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
@@ -424,6 +632,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null;
if (highlighted) setEmissive(highlighted, true);
setSelectedWagonId(wagonId ?? null);
if (wagonId) cameraApiRef.current?.focusWagon(wagonId);
};
renderer.domElement.addEventListener("click", onClick);
@@ -448,12 +657,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R;
if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120;
}
if (cameraGoal) {
const goal = cameraGoal();
const k = Math.min(1, dt * 3.2);
camera.position.lerp(goal.pos, k);
controls.target.lerp(goal.target, k);
// static scene: release goal once settled so orbiting feels free again
if (!moving && camera.position.distanceTo(goal.pos) < 0.15) cameraGoal = null;
}
controls.update();
renderer.render(scene, camera);
};
animate();
return () => {
cameraApiRef.current = null;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
renderer.domElement.removeEventListener("click", onClick);
@@ -510,8 +728,59 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
{schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons
</Badge>
</Group>
<Group
gap="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 10,
background: "rgba(15, 23, 42, 0.75)",
borderRadius: 12,
padding: "8px 12px",
}}
>
<Button
size="compact-sm"
variant="default"
leftSection={<Train size={14} />}
onClick={() => cameraApiRef.current?.overview()}
>
Whole train
</Button>
<Button
size="compact-sm"
variant="default"
leftSection={<TrainFront size={14} />}
onClick={() => cameraApiRef.current?.front()}
>
Locomotive
</Button>
<Button size="compact-sm" variant="default" onClick={() => cameraApiRef.current?.rear()}>
Last wagon
</Button>
<Divider orientation="vertical" color="rgba(255,255,255,0.2)" />
<Button size="compact-sm" variant="default" onClick={() => stepWagon(-1)} px={8}>
<ChevronLeft size={16} />
</Button>
<Text size="sm" c="white" fw={600} style={{ minWidth: 90, textAlign: "center" }}>
{selectedIndex >= 0
? `Wagon ${orderedWagons[selectedIndex].position ?? orderedWagons[selectedIndex].sequenceNo} / ${orderedWagons.length}`
: `${orderedWagons.length} wagons`}
</Text>
<Button size="compact-sm" variant="default" onClick={() => stepWagon(1)} px={8}>
<ChevronRight size={16} />
</Button>
</Group>
<Group gap="xs" style={{ position: "absolute", bottom: 16, left: 16, zIndex: 10 }}>
<Text size="xs" c="gray.5">
<Text
size="xs"
c="white"
px="sm"
py={4}
style={{ background: "rgba(15, 23, 42, 0.6)", borderRadius: 8 }}
>
Drag to orbit · scroll to zoom · click a wagon for details
</Text>
</Group>