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(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 ( 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}> {Array.from({ length: count }).map((_, i) => ( ))} ); } function Coupler() { return ( ); } function LocomotiveCar({ code, name, maxPullWeightTons, }: { code: string; name?: string | null; maxPullWeightTons?: number | null; }) { return ( {/* roofline */} {/* roof vents */} {[0, 1, 2].map((i) => ( ))} {/* cab windows */} {/* headlight */} {/* hazard stripe on the nose */} {code} {name ?? "Locomotive"} {maxPullWeightTons ? ( {maxPullWeightTons}T pull ) : null} HEAD ); } const CONTAINER_GRADIENTS = [ "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", ]; const CONTAINER_BORDERS = [ "var(--mantine-color-cyan-8)", "var(--mantine-color-blue-8)", ]; 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}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`; // container blocks: one per container number (cap visual at 2 = TEU per wagon) const blocks = wagon.containerNumbers.slice(0, 2); return ( {/* top accent strip */} {/* header */} #{wagon.sequenceNo} {wagon.isEmpty ? ( EMPTY ) : ( {wagon.isBulk ? : } {wagon.isBulk ? "BULK" : "CONT"} )} {/* body */} {wagon.isEmpty ? ( Available ) : wagon.isBulk ? ( {wagon.assignedWeightTons}/{wagon.capacityTons}T ) : ( {(blocks.length ? blocks : ["—"]).map((cn, i) => ( {/* corrugation lines */} {cn} ))} )} {/* utilization hairline */} {!wagon.isEmpty && !wagon.isBulk ? ( = 100 ? "var(--mantine-color-red-5)" : `var(--mantine-color-${accent}-5)`, }} /> ) : null} {/* footer */} {wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"} {!wagon.isEmpty ? ( {wagon.assignedWeightTons}T ) : null} ); } /** Railway track: two rails over evenly-spaced sleepers. */ function TrackBed() { return ( {/* sleepers */} {/* rails */} ); } 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 ( {/* Header */} Train composition {trainNumber ? `${trainNumber} · ` : ""} {stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty {totalLengthMeters ? ` · ${totalLengthMeters}m` : ""} {stats.totalWeight}T of {stats.totalCapacity}T capacity {/* Locomotive pull gauge */} {stats.pullUtil != null ? ( Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T 95 ? "red.7" : "edr-green.7"}> {stats.pullUtil}% 95} animated={stats.pullUtil > 95} color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-green"} /> ) : null} {/* The train */} {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 ( {row.map((car, carIndex) => ( {carIndex > 0 ? : null} {car.kind === "loco" ? ( locomotive ? ( ) : null ) : ( )} ))} {/* serpentine turn connector to the next row */} {!isLast ? ( ) : null} ); })} ); } function LegendDot({ color, label }: { color: string; label: string }) { return ( {label} ); }