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["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 = { 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 ( {label} {value} ); } 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 ( Locomotive {loco?.code ?? ""} {loco ? ( <> {loco.name ? {loco.name} : null} {loco.status} {loco.maxTrainLengthMeters ? ( ) : null} ) : ( No locomotive assigned yet. )} {ts ? ( <> {ts.heaviestLeg ? ( ) : null} ) : null} ); } function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) { const bookings = schedule.bookings ?? []; return ( Wagon {wagon.position ?? wagon.sequenceNo} {wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""} {wagon.wagonType?.code ?? "UNKNOWN"} {wagon.wagonType?.name} Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t {wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m {wagon.allocations.length === 0 ? ( Empty wagon — no allocations. ) : ( wagon.allocations.map((alloc) => { const booking = bookings.find((b) => b.id === alloc.bookingId); return ( {alloc.bookingReference ?? "—"} {alloc.allocatedWeightTons} t {booking ? ( <> Customer: {booking.customer ?? "—"} {booking.origin ?? "?"} → {booking.destination ?? "?"} · {booking.status ?? "—"} {booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""} {booking.contractReference ? ( Contract {booking.contractReference} ) : null} ) : null} {(alloc.containerItems ?? []).length > 0 ? ( <> {(alloc.containerItems ?? []).map((c) => ( {c.containerNumber ?? "—"} {c.grossWeightTons ?? "?"} t ))} ) : null} {alloc.bulkLoad ? ( Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t ) : null} ); }) )} ); } export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) { const containerRef = useRef(null); const canvasHostRef = useRef(null); const [selectedWagonId, setSelectedWagonId] = useState(null); const [selectedLocoId, setSelectedLocoId] = useState(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 ( {driveMode ? "DRIVER VIEW — SIMULATION" : dispatched ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`} {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons {selectedIndex >= 0 ? `Wagon ${orderedWagons[selectedIndex].position ?? orderedWagons[selectedIndex].sequenceNo} / ${orderedWagons.length}` : `${orderedWagons.length} wagons`} Drag to orbit · scroll to zoom · click a wagon for details {selectedWagon ? ( setSelectedWagonId(null)} /> ) : selectedLocoId ? ( setSelectedLocoId(null)} onDrive={() => setDriveMode(true)} /> ) : null} ); }