mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
581 lines
20 KiB
TypeScript
581 lines
20 KiB
TypeScript
import { useMemo } from "react";
|
|
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
|
import { useElementSize } from "@mantine/hooks";
|
|
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
|
|
|
|
import { freightBrand } from "@/theme/freight-brand";
|
|
|
|
/**
|
|
* Visual train composition: a locomotive coupled to its wagons, drawn like a real
|
|
* consist. Long trains wrap into a serpentine (zig-zag) so the whole train stays on
|
|
* screen. Each wagon shows its load (container blocks or a bulk fill gauge), physical
|
|
* wagon number and tonnage.
|
|
*/
|
|
|
|
type DiagramWagonInput = {
|
|
sequenceNo: number;
|
|
capacityTons: number;
|
|
assignedWeightTons: number;
|
|
slotLoadType?: string | null;
|
|
wagonType?: { code?: string | null } | null;
|
|
wagonTypeCode?: string | null;
|
|
physicalWagonNumber?: string | null;
|
|
allocations?: Array<{
|
|
bookingReference?: string | null;
|
|
bookingId?: string;
|
|
loadType?: string | null;
|
|
containerItems?: Array<{ containerNumber?: string | null }> | null;
|
|
bulkLoad?: { weightTons?: number | null; cargoDescription?: string | null } | null;
|
|
}> | null;
|
|
};
|
|
|
|
type NormalizedWagon = {
|
|
sequenceNo: number;
|
|
capacityTons: number;
|
|
assignedWeightTons: number;
|
|
wagonTypeCode: string | null;
|
|
physicalWagonNumber: string | null;
|
|
isEmpty: boolean;
|
|
isBulk: boolean;
|
|
containerNumbers: string[];
|
|
bookingRefs: string[];
|
|
cargoDescription: string | null;
|
|
};
|
|
|
|
const CAR_WIDTH = 150; // car body + coupler footprint
|
|
|
|
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
|
|
const allocations = w.allocations ?? [];
|
|
const firstLoad = (
|
|
w.slotLoadType ??
|
|
allocations[0]?.loadType ??
|
|
freightType ??
|
|
""
|
|
)
|
|
.toString()
|
|
.toUpperCase();
|
|
const isBulk = firstLoad.includes("BULK");
|
|
const containerNumbers: string[] = [];
|
|
const bookingRefs: string[] = [];
|
|
let cargoDescription: string | null = null;
|
|
for (const alloc of allocations) {
|
|
if (alloc.bookingReference) bookingRefs.push(alloc.bookingReference);
|
|
for (const item of alloc.containerItems ?? []) {
|
|
containerNumbers.push(item.containerNumber?.trim() || "—");
|
|
}
|
|
if (alloc.bulkLoad?.cargoDescription) cargoDescription = alloc.bulkLoad.cargoDescription;
|
|
}
|
|
return {
|
|
sequenceNo: w.sequenceNo,
|
|
capacityTons: Number(w.capacityTons) || 0,
|
|
assignedWeightTons: Number(w.assignedWeightTons) || 0,
|
|
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
|
|
physicalWagonNumber: w.physicalWagonNumber ?? null,
|
|
isEmpty: allocations.length === 0,
|
|
isBulk,
|
|
containerNumbers,
|
|
bookingRefs,
|
|
cargoDescription,
|
|
};
|
|
}
|
|
|
|
function chunk<T>(items: T[], size: number): T[][] {
|
|
if (size <= 0) return [items];
|
|
const rows: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
|
|
return rows;
|
|
}
|
|
|
|
function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) {
|
|
return (
|
|
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<Box
|
|
key={i}
|
|
style={{
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: "50%",
|
|
background: dark ? "#0f291b" : "var(--mantine-color-gray-7)",
|
|
border: "2px solid var(--mantine-color-gray-4)",
|
|
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.25)",
|
|
}}
|
|
/>
|
|
))}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function Coupler() {
|
|
return (
|
|
<Box style={{ width: 16, height: 74, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
|
<Box
|
|
style={{
|
|
width: "100%",
|
|
height: 6,
|
|
borderRadius: 3,
|
|
background:
|
|
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function LocomotiveCar({
|
|
code,
|
|
name,
|
|
maxPullWeightTons,
|
|
}: {
|
|
code: string;
|
|
name?: string | null;
|
|
maxPullWeightTons?: number | null;
|
|
}) {
|
|
return (
|
|
<Tooltip
|
|
label={`Locomotive ${code}${name ? ` · ${name}` : ""}${
|
|
maxPullWeightTons ? ` · pulls up to ${maxPullWeightTons}T` : ""
|
|
}`}
|
|
withArrow
|
|
>
|
|
<Box style={{ width: 134, flexShrink: 0 }}>
|
|
<Box
|
|
style={{
|
|
position: "relative",
|
|
height: 74,
|
|
borderRadius: "16px 22px 10px 10px",
|
|
background: freightBrand.gradient,
|
|
boxShadow: freightBrand.shadowSm,
|
|
border: "1px solid rgba(0,0,0,0.08)",
|
|
overflow: "hidden",
|
|
padding: "8px 10px",
|
|
color: "white",
|
|
}}
|
|
>
|
|
{/* cab windows */}
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
top: 8,
|
|
right: 10,
|
|
display: "flex",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.85)" }} />
|
|
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.6)" }} />
|
|
</Box>
|
|
{/* headlight */}
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 10,
|
|
right: 6,
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
background: "#fde68a",
|
|
boxShadow: "0 0 8px 2px rgba(253,230,138,0.8)",
|
|
}}
|
|
/>
|
|
<Group gap={6} wrap="nowrap" align="center">
|
|
<TrainFront size={18} />
|
|
<Text size="sm" fw={800} style={{ letterSpacing: 0.3 }}>
|
|
{code}
|
|
</Text>
|
|
</Group>
|
|
<Text size="9px" mt={2} style={{ opacity: 0.85 }} lineClamp={1}>
|
|
{name ?? "Locomotive"}
|
|
</Text>
|
|
{maxPullWeightTons ? (
|
|
<Group gap={3} wrap="nowrap" mt={4} style={{ opacity: 0.95 }}>
|
|
<Gauge size={10} />
|
|
<Text size="9px" fw={600}>
|
|
{maxPullWeightTons}T pull
|
|
</Text>
|
|
</Group>
|
|
) : null}
|
|
</Box>
|
|
<Wheels count={3} dark />
|
|
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700}>
|
|
HEAD
|
|
</Text>
|
|
</Box>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
|
const utilization =
|
|
wagon.capacityTons > 0
|
|
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
|
|
: 0;
|
|
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
|
|
const accentVar = `var(--mantine-color-${accent}-6)`;
|
|
|
|
const tooltipLabel = wagon.isEmpty
|
|
? `Wagon #${wagon.sequenceNo} · empty / available`
|
|
: `Wagon #${wagon.sequenceNo}${wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}\n${
|
|
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
|
}${
|
|
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
|
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`;
|
|
|
|
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
|
const blocks = wagon.containerNumbers.slice(0, 2);
|
|
|
|
return (
|
|
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
|
<Box style={{ width: 134, flexShrink: 0 }}>
|
|
<Box
|
|
style={{
|
|
position: "relative",
|
|
height: 74,
|
|
borderRadius: 12,
|
|
background: wagon.isEmpty ? "var(--mantine-color-gray-0)" : "white",
|
|
border: wagon.isEmpty
|
|
? "1.5px dashed var(--mantine-color-gray-4)"
|
|
: "1px solid var(--mantine-color-gray-3)",
|
|
boxShadow: wagon.isEmpty ? "none" : "0 2px 8px rgba(15,41,27,0.06)",
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{/* top accent strip */}
|
|
<Box
|
|
style={{
|
|
height: 4,
|
|
background: wagon.isEmpty
|
|
? "var(--mantine-color-gray-3)"
|
|
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
|
}}
|
|
/>
|
|
{/* header */}
|
|
<Group justify="space-between" px={8} pt={4} wrap="nowrap">
|
|
<Text size="10px" fw={800} c="gray.7">
|
|
#{wagon.sequenceNo}
|
|
</Text>
|
|
{wagon.isEmpty ? (
|
|
<Text size="9px" c="dimmed" fw={600}>
|
|
EMPTY
|
|
</Text>
|
|
) : (
|
|
<Group gap={3} wrap="nowrap">
|
|
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
|
|
<Text size="9px" fw={700} c={`${accent}.7`}>
|
|
{wagon.isBulk ? "BULK" : "CONT"}
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
</Group>
|
|
|
|
{/* body */}
|
|
<Box style={{ flex: 1, padding: "4px 8px 6px", display: "flex", alignItems: "center" }}>
|
|
{wagon.isEmpty ? (
|
|
<Group gap={4} justify="center" style={{ width: "100%" }}>
|
|
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
|
|
<Text size="9px" c="dimmed">
|
|
Available
|
|
</Text>
|
|
</Group>
|
|
) : wagon.isBulk ? (
|
|
<Stack gap={3} style={{ width: "100%" }}>
|
|
<Box
|
|
style={{
|
|
height: 18,
|
|
borderRadius: 6,
|
|
background: "var(--mantine-color-orange-0)",
|
|
border: "1px solid var(--mantine-color-orange-2)",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
width: `${utilization}%`,
|
|
background: "linear-gradient(90deg, var(--mantine-color-orange-5), var(--mantine-color-orange-3))",
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Text size="9px" c="dimmed" ta="center">
|
|
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
|
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
|
<Box
|
|
key={i}
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
height: 26,
|
|
borderRadius: 5,
|
|
background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
|
border: "1px solid var(--mantine-color-cyan-8)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "0 3px",
|
|
}}
|
|
>
|
|
<Text size="8px" fw={700} c="white" truncate>
|
|
{cn}
|
|
</Text>
|
|
</Box>
|
|
))}
|
|
</Group>
|
|
)}
|
|
</Box>
|
|
|
|
{/* footer */}
|
|
<Box
|
|
style={{
|
|
borderTop: "1px solid var(--mantine-color-gray-1)",
|
|
padding: "2px 8px",
|
|
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
|
}}
|
|
>
|
|
<Text size="8px" c="dimmed" truncate>
|
|
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
<Wheels count={2} />
|
|
</Box>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function TrainCompositionDiagram({
|
|
locomotive,
|
|
wagons,
|
|
freightType,
|
|
trainNumber,
|
|
totalLengthMeters,
|
|
}: {
|
|
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
|
wagons: DiagramWagonInput[];
|
|
freightType?: string | null;
|
|
trainNumber?: string | null;
|
|
totalLengthMeters?: number | null;
|
|
}) {
|
|
const { ref, width } = useElementSize();
|
|
|
|
const normalized = useMemo(
|
|
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
|
[wagons, freightType],
|
|
);
|
|
|
|
const stats = useMemo(() => {
|
|
const assigned = normalized.filter((w) => !w.isEmpty).length;
|
|
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
|
|
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
|
|
return {
|
|
total: normalized.length,
|
|
assigned,
|
|
empty: normalized.length - assigned,
|
|
totalWeight: Math.round(totalWeight * 100) / 100,
|
|
totalCapacity,
|
|
pullUtil:
|
|
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
|
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
|
|
: null,
|
|
};
|
|
}, [normalized, locomotive]);
|
|
|
|
// cars-per-row from measured width; locomotive counts as one car
|
|
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
|
const cars = useMemo(
|
|
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
|
[normalized],
|
|
);
|
|
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
|
|
|
if (!locomotive && !wagons.length) return null;
|
|
|
|
return (
|
|
<Paper
|
|
radius="lg"
|
|
p="lg"
|
|
withBorder
|
|
style={{
|
|
borderColor: "var(--mantine-color-gray-2)",
|
|
background:
|
|
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)",
|
|
}}
|
|
>
|
|
<Stack gap="md">
|
|
{/* Header */}
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
|
<Group gap="sm" wrap="nowrap">
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 10,
|
|
background: freightBrand.gradient,
|
|
color: "white",
|
|
}}
|
|
>
|
|
<TrainFront size={20} />
|
|
</Box>
|
|
<Stack gap={0}>
|
|
<Text fw={700}>Train composition</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{trainNumber ? `${trainNumber} · ` : ""}
|
|
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
|
|
{totalLengthMeters ? ` · ${totalLengthMeters}m` : ""}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
|
|
<Group gap="xs">
|
|
<LegendDot color="cyan" label="Container" />
|
|
<LegendDot color="orange" label="Bulk" />
|
|
<LegendDot color="gray" label="Empty" />
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Locomotive pull gauge */}
|
|
{stats.pullUtil != null ? (
|
|
<Box
|
|
p="xs"
|
|
style={{
|
|
borderRadius: 10,
|
|
background: "var(--mantine-color-green-0)",
|
|
border: "1px solid var(--mantine-color-green-1)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb={4}>
|
|
<Text size="xs" fw={600} c="green.8">
|
|
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
|
</Text>
|
|
<Text size="xs" fw={700} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
|
{stats.pullUtil}%
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={stats.pullUtil}
|
|
size="sm"
|
|
radius="xl"
|
|
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
|
|
{/* The train */}
|
|
<Box ref={ref} style={{ width: "100%" }}>
|
|
<Stack gap={0}>
|
|
{rows.map((row, rowIndex) => {
|
|
const reversed = rowIndex % 2 === 1;
|
|
const isLast = rowIndex === rows.length - 1;
|
|
// side where this row's track ends / turns down to the next row
|
|
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
|
|
return (
|
|
<Box key={rowIndex}>
|
|
<Box style={{ position: "relative", paddingBottom: 6 }}>
|
|
{/* rail under the row */}
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
left: 4,
|
|
right: 4,
|
|
bottom: 8,
|
|
height: 4,
|
|
borderRadius: 2,
|
|
background:
|
|
"repeating-linear-gradient(90deg, var(--mantine-color-gray-5) 0 10px, var(--mantine-color-gray-3) 10px 16px)",
|
|
}}
|
|
/>
|
|
<Group
|
|
gap={0}
|
|
wrap="nowrap"
|
|
justify="flex-start"
|
|
style={{
|
|
flexDirection: reversed ? "row-reverse" : "row",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{row.map((car, carIndex) => (
|
|
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
|
{carIndex > 0 ? <Coupler /> : null}
|
|
{car.kind === "loco" ? (
|
|
locomotive ? (
|
|
<LocomotiveCar
|
|
code={locomotive.code ?? "LOCO"}
|
|
name={locomotive.name}
|
|
maxPullWeightTons={locomotive.maxPullWeightTons}
|
|
/>
|
|
) : null
|
|
) : (
|
|
<WagonCar wagon={car.w} />
|
|
)}
|
|
</Group>
|
|
))}
|
|
</Group>
|
|
</Box>
|
|
|
|
{/* serpentine turn connector to the next row */}
|
|
{!isLast ? (
|
|
<Box style={{ position: "relative", height: 16 }}>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
top: -10,
|
|
height: 26,
|
|
width: 22,
|
|
borderBottom: "4px solid var(--mantine-color-gray-4)",
|
|
...(turnSide === "right"
|
|
? {
|
|
right: 4,
|
|
borderRight: "4px solid var(--mantine-color-gray-4)",
|
|
borderBottomRightRadius: 16,
|
|
}
|
|
: {
|
|
left: 4,
|
|
borderLeft: "4px solid var(--mantine-color-gray-4)",
|
|
borderBottomLeftRadius: 16,
|
|
}),
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function LegendDot({ color, label }: { color: string; label: string }) {
|
|
return (
|
|
<Group gap={5} wrap="nowrap">
|
|
<Box
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 3,
|
|
background:
|
|
color === "gray"
|
|
? "var(--mantine-color-gray-2)"
|
|
: `var(--mantine-color-${color}-5)`,
|
|
border: color === "gray" ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
|
}}
|
|
/>
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|