mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
1084 lines
42 KiB
TypeScript
1084 lines
42 KiB
TypeScript
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
|
|
import { ChevronLeft, ChevronRight, Gauge, 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";
|
|
|
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
|
|
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
|
|
|
interface Train3DVisualizationProps {
|
|
schedule: TrainScheduleDetail;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// shipping-line style colors — no purple
|
|
const CONTAINER_PALETTE = [0xb63a2b, 0x1f6fb2, 0x1e8449, 0xd9a213, 0xd35f1e, 0x11707f, 0x7a3b1e, 0x35506e];
|
|
|
|
// wagon body color keyed by wagon type — same type = same color across the train
|
|
const WAGON_TYPE_PALETTE = [0x2e6f8e, 0xb0722d, 0x5c7f3b, 0x8e3b3b, 0x3b6e63, 0x8a6d2f, 0x505a7d, 0x6d5540];
|
|
const wagonTypeColor = (w: Wagon, fallback: number) =>
|
|
w.wagonType?.code ? WAGON_TYPE_PALETTE[Math.abs(hash32(w.wagonType.code)) % WAGON_TYPE_PALETTE.length] : fallback;
|
|
|
|
const hash32 = (key: string) => {
|
|
let h = 0;
|
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
|
|
return h;
|
|
};
|
|
|
|
const hashColor = (key: string) => CONTAINER_PALETTE[Math.abs(hash32(key)) % CONTAINER_PALETTE.length];
|
|
|
|
type WagonKind = "container" | "bulk" | "tank" | "flat";
|
|
|
|
const wagonKind = (w: Wagon): WagonKind => {
|
|
const code = `${w.wagonType?.code ?? ""} ${w.wagonType?.name ?? ""}`.toUpperCase();
|
|
if (/TANK|LIQUID|FUEL/.test(code)) return "tank";
|
|
if (/HOPPER|BULK|OPEN|GONDOLA/.test(code) || w.allocations.some((a) => a.bulkLoad)) return "bulk";
|
|
if (/FLAT|CONT/.test(code) || w.allocations.some((a) => a.containerItems?.length)) return "container";
|
|
return "flat";
|
|
};
|
|
|
|
const WAGON_BODY_COLOR: Record<WagonKind, number> = {
|
|
container: 0x5f7d9c,
|
|
bulk: 0xa8683f,
|
|
tank: 0xb8bec8,
|
|
flat: 0x9aa4ae,
|
|
};
|
|
|
|
const M = 1; // 1 unit = 1 meter
|
|
const GAUGE = 1.435 * M;
|
|
const WHEEL_R = 0.46 * M;
|
|
const DECK_H = 1.2 * M;
|
|
|
|
// 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 < 8; 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);
|
|
}
|
|
// high and pushed to the sides so they never sit between camera and train
|
|
cloud.position.set((rng() - 0.5) * 1800, 220 + rng() * 80, (rng() > 0.5 ? 1 : -1) * (350 + rng() * 400));
|
|
scene.add(cloud);
|
|
}
|
|
}
|
|
|
|
function buildAcacia(rng: () => number): THREE.Group {
|
|
// flat-topped acacia
|
|
const tree = new THREE.Group();
|
|
const trunkH = 2.6 + rng() * 2;
|
|
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x7a5230, roughness: 1 });
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.35, trunkH, 7), trunkMat);
|
|
trunk.position.y = trunkH / 2;
|
|
trunk.rotation.z = (rng() - 0.5) * 0.25;
|
|
tree.add(trunk);
|
|
for (const tilt of [-0.6, 0.6]) {
|
|
const branch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, trunkH * 0.6, 6), trunkMat);
|
|
branch.position.set(tilt * 0.8, trunkH * 0.85, (rng() - 0.5) * 0.6);
|
|
branch.rotation.z = tilt;
|
|
tree.add(branch);
|
|
}
|
|
const canopyMat = new THREE.MeshStandardMaterial({
|
|
color: rng() > 0.5 ? 0x2f7d32 : 0x3c8d40,
|
|
roughness: 1,
|
|
});
|
|
const canopy = new THREE.Mesh(new THREE.SphereGeometry(2.6 + rng() * 1.6, 10, 8), canopyMat);
|
|
canopy.position.y = trunkH + 0.9;
|
|
canopy.scale.set(1.2, 0.32, 1.2); // flat umbrella top
|
|
tree.add(canopy);
|
|
return tree;
|
|
}
|
|
|
|
function buildShrub(rng: () => number): THREE.Mesh {
|
|
const dry = [0x3c7d33, 0x4f9e3f, 0x2e6b28, 0x5da84a];
|
|
const shrub = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.5 + rng() * 0.8, 7, 6),
|
|
new THREE.MeshStandardMaterial({ color: dry[Math.floor(rng() * dry.length)], roughness: 1 }),
|
|
);
|
|
shrub.scale.y = 0.55;
|
|
shrub.position.y = 0.25;
|
|
return shrub;
|
|
}
|
|
|
|
function buildRock(rng: () => number): THREE.Mesh {
|
|
const rock = new THREE.Mesh(
|
|
new THREE.DodecahedronGeometry(0.5 + rng() * 1.6, 0),
|
|
new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xa08d76 : 0x8d7f6d, roughness: 1 }),
|
|
);
|
|
rock.scale.set(1 + rng(), 0.55 + rng() * 0.4, 1 + rng());
|
|
rock.position.y = 0.3;
|
|
rock.rotation.y = rng() * Math.PI;
|
|
return rock;
|
|
}
|
|
|
|
function buildCamel(rng: () => number): THREE.Group {
|
|
const g = new THREE.Group();
|
|
const mat = new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xc49a5f : 0xb28850, roughness: 1 });
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.95, 0.85), mat);
|
|
body.position.y = 1.55;
|
|
g.add(body);
|
|
const hump = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8), mat);
|
|
hump.position.set(-0.15, 2.25, 0);
|
|
hump.scale.set(1.15, 0.9, 0.85);
|
|
g.add(hump);
|
|
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.3, 0.3), mat);
|
|
neck.position.set(1.05, 2.35, 0);
|
|
neck.rotation.z = -0.25;
|
|
g.add(neck);
|
|
const head = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.32, 0.3), mat);
|
|
head.position.set(1.45, 2.95, 0);
|
|
g.add(head);
|
|
const legGeo = new THREE.BoxGeometry(0.17, 1.15, 0.17);
|
|
for (const [x, z] of [[-0.8, -0.28], [-0.8, 0.28], [0.8, -0.28], [0.8, 0.28]]) {
|
|
const leg = new THREE.Mesh(legGeo, mat);
|
|
leg.position.set(x, 0.58, z);
|
|
g.add(leg);
|
|
}
|
|
g.rotation.y = rng() * Math.PI * 2;
|
|
return g;
|
|
}
|
|
|
|
function buildGoat(rng: () => number): THREE.Group {
|
|
const g = new THREE.Group();
|
|
const colors = [0xe8e4da, 0x4a3b2d, 0x9c8a72];
|
|
const mat = new THREE.MeshStandardMaterial({ color: colors[Math.floor(rng() * colors.length)], roughness: 1 });
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.5, 0.4), mat);
|
|
body.position.y = 0.62;
|
|
g.add(body);
|
|
const head = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.3, 0.26), mat);
|
|
head.position.set(0.55, 0.85, 0);
|
|
g.add(head);
|
|
const legGeo = new THREE.BoxGeometry(0.09, 0.4, 0.09);
|
|
for (const [x, z] of [[-0.32, -0.13], [-0.32, 0.13], [0.32, -0.13], [0.32, 0.13]]) {
|
|
const leg = new THREE.Mesh(legGeo, mat);
|
|
leg.position.set(x, 0.2, z);
|
|
g.add(leg);
|
|
}
|
|
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 variation: darker meadow patches
|
|
const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 });
|
|
const patchGeo = new THREE.CircleGeometry(4, 8);
|
|
const patches = new THREE.InstancedMesh(patchGeo, patchMat, 160);
|
|
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 < 160; i++) {
|
|
const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 220);
|
|
v.set((rng() - 0.5) * spread * 1.4, 0.02, z);
|
|
s.setScalar(0.6 + rng() * 2.4);
|
|
m4.compose(v, q, s);
|
|
patches.setMatrixAt(i, m4);
|
|
}
|
|
scene.add(patches);
|
|
// low green hills on the horizon sides
|
|
const duneMat = new THREE.MeshStandardMaterial({ color: 0x4a8a38, roughness: 1 });
|
|
for (let i = 0; i < 12; i++) {
|
|
const dune = new THREE.Mesh(new THREE.SphereGeometry(30 + rng() * 50, 12, 8), duneMat);
|
|
dune.scale.set(1.6 + rng(), 0.12 + rng() * 0.08, 1 + rng());
|
|
dune.position.set((rng() - 0.5) * spread * 1.6, 0, (rng() > 0.5 ? 1 : -1) * (180 + rng() * 200));
|
|
scene.add(dune);
|
|
}
|
|
// sparse acacias, dry shrubs, rocks
|
|
for (let i = 0; i < 34; i++) {
|
|
const tree = buildAcacia(rng);
|
|
tree.position.set((rng() - 0.5) * spread * 1.2, 0, (rng() > 0.5 ? 1 : -1) * (18 + rng() * 170));
|
|
tree.scale.setScalar(0.9 + rng() * 1.1);
|
|
scene.add(tree);
|
|
}
|
|
for (let i = 0; i < 120; i++) {
|
|
const shrub = buildShrub(rng);
|
|
shrub.position.set((rng() - 0.5) * spread * 1.3, 0.15, (rng() > 0.5 ? 1 : -1) * (8 + rng() * 200));
|
|
scene.add(shrub);
|
|
}
|
|
for (let i = 0; i < 40; i++) {
|
|
const rock = buildRock(rng);
|
|
rock.position.x = (rng() - 0.5) * spread * 1.3;
|
|
rock.position.z = (rng() > 0.5 ? 1 : -1) * (10 + rng() * 190);
|
|
scene.add(rock);
|
|
}
|
|
// camel caravans + goat herds
|
|
for (let h = 0; h < 5; h++) {
|
|
const cx = (rng() - 0.5) * spread;
|
|
const cz = (rng() > 0.5 ? 1 : -1) * (30 + rng() * 130);
|
|
const count = 2 + Math.floor(rng() * 3);
|
|
for (let a = 0; a < count; a++) {
|
|
const camel = buildCamel(rng);
|
|
camel.position.set(cx + a * 4 + (rng() - 0.5) * 2, 0, cz + (rng() - 0.5) * 6);
|
|
scene.add(camel);
|
|
}
|
|
}
|
|
for (let h = 0; h < 4; h++) {
|
|
const cx = (rng() - 0.5) * spread;
|
|
const cz = (rng() > 0.5 ? 1 : -1) * (20 + rng() * 120);
|
|
const count = 3 + Math.floor(rng() * 4);
|
|
for (let a = 0; a < count; a++) {
|
|
const goat = buildGoat(rng);
|
|
goat.position.set(cx + (rng() - 0.5) * 10, 0, cz + (rng() - 0.5) * 8);
|
|
scene.add(goat);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildTrack(scene: THREE.Scene, length: number) {
|
|
const railMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, metalness: 0.9, roughness: 0.35 });
|
|
const railGeo = new THREE.BoxGeometry(length, 0.18 * M, 0.08 * M);
|
|
for (const z of [-GAUGE / 2, GAUGE / 2]) {
|
|
const rail = new THREE.Mesh(railGeo, railMat);
|
|
rail.position.set(0, 0.28 * M, z);
|
|
scene.add(rail);
|
|
}
|
|
const sleeperGeo = new THREE.BoxGeometry(0.24 * M, 0.14 * M, 2.4 * M);
|
|
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3b2f26, roughness: 1 });
|
|
const count = Math.floor(length / 0.65);
|
|
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
|
|
const m4 = new THREE.Matrix4();
|
|
for (let i = 0; i < count; i++) {
|
|
m4.setPosition(-length / 2 + i * 0.65, 0.12 * M, 0);
|
|
sleepers.setMatrixAt(i, m4);
|
|
}
|
|
scene.add(sleepers);
|
|
// ballast bed
|
|
const ballast = new THREE.Mesh(
|
|
new THREE.BoxGeometry(length, 0.1 * M, 4.2 * M),
|
|
new THREE.MeshStandardMaterial({ color: 0x565a5f, roughness: 1 }),
|
|
);
|
|
ballast.position.y = 0.03;
|
|
scene.add(ballast);
|
|
// ground
|
|
const ground = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(4000, 1600),
|
|
new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }),
|
|
);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
ground.position.y = -0.02;
|
|
scene.add(ground);
|
|
}
|
|
|
|
function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) {
|
|
const geo = new THREE.CylinderGeometry(WHEEL_R, WHEEL_R, 0.25, 20);
|
|
const mat = new THREE.MeshStandardMaterial({ color: 0x1c1f24, metalness: 0.7, roughness: 0.4 });
|
|
const bogieOffsets = [-length / 2 + 1.8, length / 2 - 1.8];
|
|
for (const x of bogieOffsets) {
|
|
for (const dx of [-0.9, 0.9]) {
|
|
for (const z of [-GAUGE / 2, GAUGE / 2]) {
|
|
const wheel = new THREE.Mesh(geo, mat);
|
|
wheel.rotation.x = Math.PI / 2;
|
|
wheel.position.set(x + dx, WHEEL_R + 0.05, z);
|
|
group.add(wheel);
|
|
wheels.push(wheel);
|
|
}
|
|
}
|
|
const bogie = new THREE.Mesh(
|
|
new THREE.BoxGeometry(2.6, 0.4, GAUGE + 0.4),
|
|
new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.8 }),
|
|
);
|
|
bogie.position.set(x, WHEEL_R + 0.35, 0);
|
|
group.add(bogie);
|
|
}
|
|
}
|
|
|
|
function buildLocomotive(
|
|
loco: { id?: string; code: string } | undefined,
|
|
wheels: THREE.Mesh[],
|
|
pickables: THREE.Object3D[],
|
|
): THREE.Group {
|
|
const g = new THREE.Group();
|
|
const len = 20 * M;
|
|
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1e6f42, metalness: 0.4, roughness: 0.5 });
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(len, 3.2, 3), bodyMat);
|
|
body.position.y = DECK_H + 1.6;
|
|
g.add(body);
|
|
const cab = new THREE.Mesh(new THREE.BoxGeometry(4.4, 1.1, 3.05), new THREE.MeshStandardMaterial({ color: 0xf2a516, metalness: 0.3, roughness: 0.5 }));
|
|
cab.position.set(len / 2 - 2.6, DECK_H + 3.75, 0);
|
|
g.add(cab);
|
|
const nose = new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.2, 2.6), bodyMat);
|
|
nose.position.set(len / 2 + 0.6, DECK_H + 1.1, 0);
|
|
g.add(nose);
|
|
// headlight
|
|
const light = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.22, 12, 12),
|
|
new THREE.MeshStandardMaterial({ color: 0xfff6c9, emissive: 0xfff2a8, emissiveIntensity: 2 }),
|
|
);
|
|
light.position.set(len / 2 + 1.4, DECK_H + 1.6, 0);
|
|
g.add(light);
|
|
const spot = new THREE.SpotLight(0xfff2c0, 60, 90, 0.4, 0.6);
|
|
spot.position.copy(light.position);
|
|
spot.target.position.set(len / 2 + 40, 0.5, 0);
|
|
g.add(spot, spot.target);
|
|
addWheels(g, len, wheels);
|
|
g.userData.length = len;
|
|
const locoId = loco?.id ?? loco?.code ?? "loco";
|
|
g.userData.locoId = locoId;
|
|
g.traverse((o) => {
|
|
o.userData.locoId = locoId;
|
|
});
|
|
pickables.push(g);
|
|
return g;
|
|
}
|
|
|
|
function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3D[]): THREE.Group {
|
|
const g = new THREE.Group();
|
|
const len = Math.max(8, wagon.lengthMeters || 14) * M;
|
|
const kind = wagonKind(wagon);
|
|
const bodyMat = new THREE.MeshStandardMaterial({
|
|
color: wagonTypeColor(wagon, WAGON_BODY_COLOR[kind]),
|
|
metalness: 0.35,
|
|
roughness: 0.6,
|
|
});
|
|
|
|
// frame/deck common to all
|
|
const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat);
|
|
deck.position.y = DECK_H;
|
|
g.add(deck);
|
|
|
|
if (kind === "container") {
|
|
const items = wagon.allocations.flatMap((a) =>
|
|
(a.containerItems ?? []).map((c) => ({ ...c, bookingRef: a.bookingReference ?? a.bookingId })),
|
|
);
|
|
const n = Math.max(items.length, 0);
|
|
const slotLen = n > 1 ? len / n - 0.3 : Math.min(12.2, len - 1.5);
|
|
items.forEach((c, i) => {
|
|
const cx = n > 1 ? -len / 2 + (i + 0.5) * (len / n) : 0;
|
|
const box = new THREE.Mesh(
|
|
new THREE.BoxGeometry(slotLen, 2.6, 2.44),
|
|
new THREE.MeshStandardMaterial({ color: hashColor(c.bookingRef), metalness: 0.2, roughness: 0.55 }),
|
|
);
|
|
box.position.set(cx, DECK_H + 0.35 / 2 + 1.3, 0);
|
|
// corrugation hint: thin ribs
|
|
const rib = new THREE.Mesh(
|
|
new THREE.BoxGeometry(slotLen * 0.98, 2.4, 2.5),
|
|
new THREE.MeshStandardMaterial({ color: 0x000000, transparent: true, opacity: 0.12 }),
|
|
);
|
|
rib.position.copy(box.position);
|
|
g.add(box, rib);
|
|
});
|
|
} else if (kind === "bulk") {
|
|
// open hopper walls
|
|
const wallMat = new THREE.MeshStandardMaterial({
|
|
color: wagonTypeColor(wagon, 0x8a5a34),
|
|
metalness: 0.25,
|
|
roughness: 0.7,
|
|
});
|
|
const wallH = 2.2;
|
|
const side = new THREE.BoxGeometry(len, wallH, 0.12);
|
|
for (const z of [-1.45, 1.45]) {
|
|
const wall = new THREE.Mesh(side, wallMat);
|
|
wall.position.set(0, DECK_H + wallH / 2 + 0.17, z);
|
|
g.add(wall);
|
|
}
|
|
const end = new THREE.BoxGeometry(0.12, wallH, 3);
|
|
for (const x of [-len / 2 + 0.06, len / 2 - 0.06]) {
|
|
const wall = new THREE.Mesh(end, wallMat);
|
|
wall.position.set(x, DECK_H + wallH / 2 + 0.17, 0);
|
|
g.add(wall);
|
|
}
|
|
const loaded = wagon.allocations.some((a) => a.bulkLoad) || wagon.assignedWeightTons > 0;
|
|
if (loaded) {
|
|
// cargo mound: squashed bumpy cylinder rows
|
|
const cargoMat = new THREE.MeshStandardMaterial({ color: 0x5c4a33, roughness: 1 });
|
|
const mounds = Math.max(2, Math.floor(len / 4));
|
|
for (let i = 0; i < mounds; i++) {
|
|
const mound = new THREE.Mesh(new THREE.SphereGeometry(1.5, 12, 8), cargoMat);
|
|
mound.scale.set((len / mounds) * 0.42, 0.55, 0.9);
|
|
mound.position.set(-len / 2 + (i + 0.5) * (len / mounds), DECK_H + wallH * 0.85, 0);
|
|
g.add(mound);
|
|
}
|
|
}
|
|
} else if (kind === "tank") {
|
|
const tank = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(1.4, 1.4, len - 1.6, 24),
|
|
new THREE.MeshStandardMaterial({ color: 0xb9bec7, metalness: 0.6, roughness: 0.3 }),
|
|
);
|
|
tank.rotation.z = Math.PI / 2;
|
|
tank.position.y = DECK_H + 1.6;
|
|
g.add(tank);
|
|
const dome = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.5, 16), bodyMat);
|
|
dome.position.y = DECK_H + 3.1;
|
|
g.add(dome);
|
|
} else {
|
|
// empty flat: low side rails
|
|
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 0.1), bodyMat);
|
|
for (const z of [-1.45, 1.45]) {
|
|
const r = rail.clone();
|
|
r.position.set(0, DECK_H + 0.35, z);
|
|
g.add(r);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
g.traverse((o) => {
|
|
o.userData.wagonId = wagon.id;
|
|
});
|
|
pickables.push(g);
|
|
return g;
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
return (
|
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
|
<Text size="xs" c="gray.5">{label}</Text>
|
|
<Text size="xs" c="gray.1" fw={600} ta="right">{value}</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function LocoDetailPanel({
|
|
locoId,
|
|
schedule,
|
|
onClose,
|
|
onDrive,
|
|
}: {
|
|
locoId: string;
|
|
schedule: TrainScheduleDetail;
|
|
onClose: () => void;
|
|
onDrive: () => void;
|
|
}) {
|
|
const locos = schedule.trainSet?.locomotives?.length
|
|
? schedule.trainSet.locomotives
|
|
: schedule.trainSet?.locomotive
|
|
? [schedule.trainSet.locomotive]
|
|
: [];
|
|
const loco = locos.find((l) => l.id === locoId || l.code === locoId);
|
|
const ts = schedule.trainSet;
|
|
return (
|
|
<Paper
|
|
radius="lg"
|
|
p="md"
|
|
style={{
|
|
position: "absolute",
|
|
top: 60,
|
|
right: 16,
|
|
width: 340,
|
|
maxHeight: "calc(100% - 76px)",
|
|
background: "rgba(13, 17, 23, 0.92)",
|
|
border: "1px solid rgba(255,255,255,0.15)",
|
|
color: "#e6edf3",
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text fw={700}>Locomotive {loco?.code ?? ""}</Text>
|
|
<CloseButton variant="transparent" c="gray.4" onClick={onClose} />
|
|
</Group>
|
|
<Button fullWidth color="orange" size="xs" mb="sm" leftSection={<Gauge size={14} />} onClick={onDrive}>
|
|
Drive this locomotive
|
|
</Button>
|
|
<ScrollArea.Autosize mah="70vh">
|
|
<Stack gap="xs">
|
|
{loco ? (
|
|
<>
|
|
{loco.name ? <Text size="sm" c="gray.3">{loco.name}</Text> : null}
|
|
<Group gap="xs">
|
|
<Badge color="yellow" variant="light">{loco.status}</Badge>
|
|
</Group>
|
|
<InfoRow label="Max pull weight" value={`${loco.maxPullWeightTons} t`} />
|
|
{loco.maxTrainLengthMeters ? (
|
|
<InfoRow label="Max train length" value={`${loco.maxTrainLengthMeters} m`} />
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
|
|
)}
|
|
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
|
|
<InfoRow label="Voyage / reference" value={schedule.reference} />
|
|
<InfoRow label="Train number" value={schedule.trainNumber} />
|
|
<InfoRow
|
|
label="Train"
|
|
value={schedule.train ? `${schedule.train.code}${schedule.train.trainName ? ` · ${schedule.train.trainName}` : ""}` : null}
|
|
/>
|
|
<InfoRow label="Status" value={String(schedule.status)} />
|
|
<InfoRow label="Direction" value={schedule.direction} />
|
|
<InfoRow label="Route" value={schedule.route?.name} />
|
|
<InfoRow
|
|
label="From → To"
|
|
value={
|
|
schedule.originStation || schedule.destinationStation
|
|
? `${schedule.originStation?.label ?? schedule.originStation?.code ?? "?"} → ${schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? "?"}`
|
|
: null
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="Departure"
|
|
value={schedule.scheduledDepartureDate ? new Date(schedule.scheduledDepartureDate).toLocaleString() : null}
|
|
/>
|
|
<InfoRow
|
|
label="Arrival"
|
|
value={schedule.scheduledArrivalDate ? new Date(schedule.scheduledArrivalDate).toLocaleString() : null}
|
|
/>
|
|
{ts ? (
|
|
<>
|
|
<Divider color="rgba(255,255,255,0.1)" label="Consist" labelPosition="left" />
|
|
<InfoRow label="Wagons" value={ts.wagonCount} />
|
|
<InfoRow label="Total weight" value={`${ts.totalWeightTons} t`} />
|
|
<InfoRow label="Total length" value={`${ts.totalLengthMeters} m`} />
|
|
{ts.heaviestLeg ? (
|
|
<InfoRow
|
|
label="Heaviest leg"
|
|
value={`${ts.heaviestLeg.grossWeightTons} t · ${ts.heaviestLeg.lengthMeters} m`}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
</ScrollArea.Autosize>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) {
|
|
const bookings = schedule.bookings ?? [];
|
|
return (
|
|
<Paper
|
|
radius="lg"
|
|
p="md"
|
|
style={{
|
|
position: "absolute",
|
|
top: 60,
|
|
right: 16,
|
|
width: 340,
|
|
maxHeight: "calc(100% - 76px)",
|
|
background: "rgba(13, 17, 23, 0.92)",
|
|
border: "1px solid rgba(255,255,255,0.15)",
|
|
color: "#e6edf3",
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text fw={700}>
|
|
Wagon {wagon.position ?? wagon.sequenceNo}
|
|
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
|
|
</Text>
|
|
<CloseButton variant="transparent" c="gray.4" onClick={onClose} />
|
|
</Group>
|
|
<ScrollArea.Autosize mah="70vh">
|
|
<Stack gap="xs">
|
|
<Group gap="xs">
|
|
<Badge color="yellow" variant="light">{wagon.wagonType?.code ?? "UNKNOWN"}</Badge>
|
|
<Text size="xs" c="gray.4">{wagon.wagonType?.name}</Text>
|
|
</Group>
|
|
<Text size="xs" c="gray.4">
|
|
Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t
|
|
{wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m
|
|
</Text>
|
|
{wagon.allocations.length === 0 ? (
|
|
<Text size="sm" c="gray.5">Empty wagon — no allocations.</Text>
|
|
) : (
|
|
wagon.allocations.map((alloc) => {
|
|
const booking = bookings.find((b) => b.id === alloc.bookingId);
|
|
return (
|
|
<Paper key={alloc.id} p="sm" radius="md" style={{ background: "rgba(255,255,255,0.06)" }}>
|
|
<Stack gap={6}>
|
|
<Group justify="space-between">
|
|
<Text size="sm" fw={600} ff="monospace">{alloc.bookingReference ?? "—"}</Text>
|
|
<Badge size="xs" variant="light" color="teal">{alloc.allocatedWeightTons} t</Badge>
|
|
</Group>
|
|
{booking ? (
|
|
<>
|
|
<Text size="xs" c="gray.3">Customer: {booking.customer ?? "—"}</Text>
|
|
<Text size="xs" c="gray.4">
|
|
{booking.origin ?? "?"} → {booking.destination ?? "?"} · {booking.status ?? "—"}
|
|
{booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""}
|
|
</Text>
|
|
{booking.contractReference ? (
|
|
<Text size="xs" c="gray.5" ff="monospace">Contract {booking.contractReference}</Text>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{(alloc.containerItems ?? []).length > 0 ? (
|
|
<>
|
|
<Divider color="rgba(255,255,255,0.1)" label="Containers" labelPosition="left" />
|
|
{(alloc.containerItems ?? []).map((c) => (
|
|
<Group key={c.id} justify="space-between">
|
|
<Text size="xs" ff="monospace" c="gray.2">{c.containerNumber ?? "—"}</Text>
|
|
<Text size="xs" c="gray.5">{c.grossWeightTons ?? "?"} t</Text>
|
|
</Group>
|
|
))}
|
|
</>
|
|
) : null}
|
|
{alloc.bulkLoad ? (
|
|
<Text size="xs" c="gray.3">
|
|
Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
})
|
|
)}
|
|
</Stack>
|
|
</ScrollArea.Autosize>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasHostRef = useRef<HTMLDivElement>(null);
|
|
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
|
const [selectedLocoId, setSelectedLocoId] = useState<string | null>(null);
|
|
const [driveMode, setDriveMode] = useState(false);
|
|
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 dispatched = String(schedule.status).toUpperCase() === "DISPATCHED";
|
|
// drive mode always simulates motion, even before dispatch
|
|
const moving = dispatched || driveMode;
|
|
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];
|
|
setSelectedLocoId(null);
|
|
setSelectedWagonId(wagon.id);
|
|
cameraApiRef.current?.focusWagon(wagon.id);
|
|
};
|
|
|
|
const firstLocoId =
|
|
schedule.trainSet?.locomotives?.[0]?.id ??
|
|
schedule.trainSet?.locomotive?.id ??
|
|
"LOCO";
|
|
|
|
useEffect(() => {
|
|
const host = canvasHostRef.current;
|
|
if (!host) return;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0xa7d8f0);
|
|
scene.fog = new THREE.Fog(0xd9e8f0, 900, 3200); // far desert haze — never clouds the train
|
|
|
|
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 5000);
|
|
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: 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[] = [];
|
|
const pickables: THREE.Object3D[] = [];
|
|
const train = new THREE.Group();
|
|
const gap = 1.0 * M;
|
|
let cursor = 0;
|
|
const locos = schedule.trainSet?.locomotives?.length
|
|
? schedule.trainSet.locomotives
|
|
: schedule.trainSet?.locomotive
|
|
? [schedule.trainSet.locomotive]
|
|
: [{ code: "LOCO" }];
|
|
const locoGroups: Array<{ id: string; group: THREE.Group }> = [];
|
|
for (const loco of locos) {
|
|
const lg = buildLocomotive(loco, wheels, pickables);
|
|
lg.position.x = cursor - lg.userData.length / 2;
|
|
cursor -= lg.userData.length + gap;
|
|
train.add(lg);
|
|
locoGroups.push({ id: lg.userData.locoId as string, group: 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);
|
|
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 = Math.max(600, trainLen * 1.6);
|
|
controls.enableDamping = true;
|
|
controls.zoomToCursor = true; // wheel zooms toward the point under the pointer
|
|
controls.enabled = !driveMode; // cab ride owns the camera
|
|
|
|
// 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 fitted with the HORIZONTAL fov — much closer than the naive
|
|
// vertical-fov fit, and low to the ground so the train fills the frame
|
|
const vFov = (camera.fov * Math.PI) / 180;
|
|
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
|
|
const dist = Math.max(40, (trainLen / 2 / Math.tan(hFov / 2)) * 1.08);
|
|
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(10, dist * 0.1), dist),
|
|
target: new THREE.Vector3(cx, 2.5, 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;
|
|
});
|
|
if (driveMode) {
|
|
// cab ride: camera on the driver's seat of the lead loco, looking down the line
|
|
const locoLen = frontGroup.userData.length as number;
|
|
cameraGoal = () => {
|
|
frontGroup.getWorldPosition(worldPos);
|
|
return {
|
|
pos: new THREE.Vector3(worldPos.x + locoLen / 2 - 3.2, DECK_H + 3.9, 1.05),
|
|
target: new THREE.Vector3(worldPos.x + locoLen / 2 + 140, 2.2, 0),
|
|
};
|
|
};
|
|
} else if (moving) {
|
|
// dispatched → open following the rolling train so motion is obvious
|
|
cameraApiRef.current.overview();
|
|
}
|
|
|
|
// picking
|
|
const raycaster = new THREE.Raycaster();
|
|
const pointer = new THREE.Vector2();
|
|
let highlighted: THREE.Object3D | null = null;
|
|
const setEmissive = (root: THREE.Object3D, on: boolean) => {
|
|
root.traverse((o) => {
|
|
const mesh = o as THREE.Mesh;
|
|
const mat = mesh.material as THREE.MeshStandardMaterial | undefined;
|
|
if (mat?.emissive) {
|
|
mat.emissive.setHex(on ? 0x335588 : 0x000000);
|
|
mat.emissiveIntensity = on ? 0.9 : 1;
|
|
}
|
|
});
|
|
};
|
|
const onClick = (e: MouseEvent) => {
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(pointer, camera);
|
|
const hits = raycaster.intersectObjects(pickables, true);
|
|
const wagonId = hits[0]?.object.userData.wagonId as string | undefined;
|
|
const locoId = hits[0]?.object.userData.locoId as string | undefined;
|
|
if (highlighted) setEmissive(highlighted, false);
|
|
highlighted = wagonId
|
|
? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null)
|
|
: locoId
|
|
? (locoGroups.find((l) => l.id === locoId)?.group ?? null)
|
|
: null;
|
|
if (highlighted) setEmissive(highlighted, true);
|
|
setSelectedWagonId(wagonId ?? null);
|
|
setSelectedLocoId(locoId && !wagonId ? locoId : null);
|
|
if (wagonId) cameraApiRef.current?.focusWagon(wagonId);
|
|
else if (locoId) {
|
|
const lg = locoGroups.find((l) => l.id === locoId);
|
|
if (lg) flyToObject(lg.group, 30, 9);
|
|
}
|
|
};
|
|
renderer.domElement.addEventListener("click", onClick);
|
|
|
|
const onResize = () => {
|
|
camera.aspect = host.clientWidth / host.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(host.clientWidth, host.clientHeight);
|
|
};
|
|
const resizeObserver = new ResizeObserver(onResize);
|
|
resizeObserver.observe(host);
|
|
|
|
let raf = 0;
|
|
let last = performance.now();
|
|
let elapsed = 0;
|
|
const speed = 14; // m/s visual speed when dispatched
|
|
const animate = () => {
|
|
raf = requestAnimationFrame(animate);
|
|
const now = performance.now();
|
|
const dt = Math.min((now - last) / 1000, 0.1);
|
|
last = now;
|
|
if (moving) {
|
|
elapsed += dt;
|
|
train.position.x += speed * dt;
|
|
for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R;
|
|
// subtle rail-joint sway so motion reads even up close
|
|
train.position.y = Math.sin(elapsed * 9) * 0.03;
|
|
train.rotation.z = Math.sin(elapsed * 4.5) * 0.0025;
|
|
// loop over the long track stretch; jump happens far off-screen
|
|
const half = finalTrackLen / 2 - trainLen - 40;
|
|
if (train.position.x > half + trainLen) train.position.x = -half;
|
|
}
|
|
if (cameraGoal) {
|
|
const goal = cameraGoal();
|
|
const k = Math.min(1, dt * (driveMode ? 7 : 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);
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
host.removeChild(renderer.domElement);
|
|
scene.traverse((o) => {
|
|
const mesh = o as THREE.Mesh;
|
|
mesh.geometry?.dispose?.();
|
|
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
mats.forEach((m) => m?.dispose?.());
|
|
});
|
|
};
|
|
// rebuild scene only when schedule identity/status changes
|
|
}, [schedule.id, schedule.status, moving, driveMode, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== "Escape" || document.fullscreenElement) return;
|
|
if (driveMode) setDriveMode(false);
|
|
else onClose();
|
|
};
|
|
document.addEventListener("fullscreenchange", onFsChange);
|
|
window.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("fullscreenchange", onFsChange);
|
|
window.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [onClose, driveMode]);
|
|
|
|
const toggleFullscreen = () => {
|
|
if (document.fullscreenElement) void document.exitFullscreen();
|
|
else void containerRef.current?.requestFullscreen();
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
ref={containerRef}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 400,
|
|
background: "#02040a",
|
|
}}
|
|
>
|
|
<Box ref={canvasHostRef} style={{ position: "absolute", inset: 0 }} />
|
|
<Group
|
|
gap="xs"
|
|
style={{ position: "absolute", top: 16, left: 16, zIndex: 10 }}
|
|
>
|
|
<Badge size="lg" variant="filled" color={driveMode ? "orange" : dispatched ? "green" : "gray"}>
|
|
{driveMode
|
|
? "DRIVER VIEW — SIMULATION"
|
|
: dispatched
|
|
? "DISPATCHED — TRAIN IN MOTION"
|
|
: `${schedule.status} — TRAIN STOPPED`}
|
|
</Badge>
|
|
<Badge size="lg" variant="light" color="yellow">
|
|
{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={() => {
|
|
setSelectedWagonId(null);
|
|
setSelectedLocoId(firstLocoId);
|
|
cameraApiRef.current?.front();
|
|
}}
|
|
>
|
|
Locomotive
|
|
</Button>
|
|
<Button size="compact-sm" variant="default" onClick={() => cameraApiRef.current?.rear()}>
|
|
Last wagon
|
|
</Button>
|
|
<Button
|
|
size="compact-sm"
|
|
variant={driveMode ? "filled" : "default"}
|
|
color={driveMode ? "orange" : undefined}
|
|
leftSection={<Gauge size={14} />}
|
|
onClick={() => setDriveMode((d) => !d)}
|
|
>
|
|
{driveMode ? "Exit drive" : "Drive"}
|
|
</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="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>
|
|
<Group gap="xs" style={{ position: "absolute", top: 16, right: 16, zIndex: 11 }}>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="default"
|
|
leftSection={isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
|
onClick={toggleFullscreen}
|
|
>
|
|
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
</Button>
|
|
<Button size="compact-sm" color="red" variant="light" leftSection={<X size={14} />} onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
</Group>
|
|
{selectedWagon ? (
|
|
<WagonDetailPanel wagon={selectedWagon} schedule={schedule} onClose={() => setSelectedWagonId(null)} />
|
|
) : selectedLocoId ? (
|
|
<LocoDetailPanel
|
|
locoId={selectedLocoId}
|
|
schedule={schedule}
|
|
onClose={() => setSelectedLocoId(null)}
|
|
onDrive={() => setDriveMode(true)}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|