configer the stamp

This commit is contained in:
Marshal
2026-08-02 21:09:37 +00:00
parent 950bcc0912
commit 94c64c2390
7 changed files with 650 additions and 3 deletions

View File

@@ -0,0 +1,536 @@
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
import { Maximize2, Minimize2, 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;
}
const CONTAINER_PALETTE = [0xc0392b, 0x2471a3, 0x1e8449, 0xd4ac0d, 0x884ea0, 0xca6f1e, 0x117a65, 0x6e2c00];
const hashColor = (key: string) => {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return CONTAINER_PALETTE[Math.abs(h) % 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: 0x4a5568,
bulk: 0x7b4a2d,
tank: 0x8a8f98,
flat: 0x3d4852,
};
const M = 1; // 1 unit = 1 meter
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);
}
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) {
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(2200, 800),
new THREE.MeshStandardMaterial({ color: 0x101418, 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: { code: string } | undefined, wheels: THREE.Mesh[]): 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;
g.userData.locoCode = loco?.code;
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: 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: 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);
}
}
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 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 [isFullscreen, setIsFullscreen] = useState(false);
const wagons = schedule.trainSet?.wagons ?? [];
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
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);
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
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);
// 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" }];
for (const loco of locos) {
const lg = buildLocomotive(loco, wheels);
lg.position.x = cursor - lg.userData.length / 2;
cursor -= lg.userData.length + gap;
train.add(lg);
}
const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo));
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);
}
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);
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.enableDamping = true;
// 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;
if (highlighted) setEmissive(highlighted, false);
highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null;
if (highlighted) setEmissive(highlighted, true);
setSelectedWagonId(wagonId ?? null);
};
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();
const speed = 8; // 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) {
train.position.x += speed * dt;
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;
}
controls.update();
renderer.render(scene, camera);
};
animate();
return () => {
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, 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) onClose();
};
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
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={moving ? "green" : "gray"}>
{moving ? "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: 16, zIndex: 10 }}>
<Text size="xs" c="gray.5">
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)} />
) : null}
</Box>
);
}