diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx index 98e9d7d6a..975df9603 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -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 = { - 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(null); const [selectedWagonId, setSelectedWagonId] = useState(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 + + + + + + + + {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