per-user trade-direction access scope

This commit is contained in:
Marshal
2026-08-02 22:29:58 +00:00
parent c055abe8c1
commit f4fd469643
47 changed files with 1451 additions and 107 deletions

View File

@@ -1,5 +1,5 @@
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
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";
@@ -319,7 +319,11 @@ function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) {
}
}
function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[]): THREE.Group {
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 });
@@ -345,7 +349,12 @@ function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[
g.add(spot, spot.target);
addWheels(g, len, wheels);
g.userData.length = len;
g.userData.locoCode = loco?.code;
const locoId = loco?.id ?? loco?.code ?? "loco";
g.userData.locoId = locoId;
g.traverse((o) => {
o.userData.locoId = locoId;
});
pickables.push(g);
return g;
}
@@ -457,6 +466,119 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
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 (
@@ -547,6 +669,8 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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;
@@ -556,7 +680,9 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
} | null>(null);
const wagons = schedule.trainSet?.wagons ?? [];
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
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),
@@ -572,10 +698,16 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
: 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;
@@ -619,11 +751,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
: 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);
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 }> = [];
@@ -650,6 +784,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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;
@@ -692,6 +827,20 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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();
@@ -714,11 +863,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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) : null;
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);
@@ -732,20 +891,27 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
let raf = 0;
let last = performance.now();
const speed = 8; // m/s visual speed when dispatched
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;
if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120;
// 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 * 3.2);
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
@@ -772,12 +938,14 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
});
};
// rebuild scene only when schedule identity/status changes
}, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps
}, [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) onClose();
if (e.key !== "Escape" || document.fullscreenElement) return;
if (driveMode) setDriveMode(false);
else onClose();
};
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
@@ -785,7 +953,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
}, [onClose, driveMode]);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
@@ -807,8 +975,12 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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 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
@@ -839,13 +1011,26 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
size="compact-sm"
variant="default"
leftSection={<TrainFront size={14} />}
onClick={() => cameraApiRef.current?.front()}
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} />
@@ -885,6 +1070,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
</Group>
{selectedWagon ? (
<WagonDetailPanel wagon={selectedWagon} schedule={schedule} onClose={() => setSelectedWagonId(null)} />
) : selectedLocoId ? (
<LocoDetailPanel
locoId={selectedLocoId}
schedule={schedule}
onClose={() => setSelectedLocoId(null)}
onDrive={() => setDriveMode(true)}
/>
) : null}
</Box>
);