import { memo, 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; tareWeightTons?: number | null; slotLoadType?: string | null; wagonType?: { code?: string | null } | null; wagonTypeCode?: string | null; /** Pinned physical wagon — slots sharing one (cross-leg TEU) draw as ONE car. */ physicalWagonId?: 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; tareWeightTons: 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 const round1 = (n: number) => Math.round(n * 10) / 10; 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, tareWeightTons: Number(w.tareWeightTons) || 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 ( ); } const LocomotiveCar = memo(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)", ]; const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) { // GROSS on both sides: cargo + tare vs rated payload + tare. const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons); const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons); const utilization = maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 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}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${ wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : "" }`; // container blocks: one per container number, up to 4 — cross-leg TEU // sharing can put two 20ft pairs (riding different legs) on one wagon. // 1–2 sit side by side; 3–4 form a 2×2 grid (two rows, up/down). const blocks = wagon.containerNumbers.slice(0, 4); const twoRows = blocks.length > 2; return ( {/* top accent strip */} {/* header */} #{wagon.sequenceNo} {wagon.isEmpty ? ( EMPTY ) : ( {wagon.isBulk ? : } {wagon.isBulk ? "BULK" : "CONT"} )} {/* body */} {wagon.isEmpty ? ( Available ) : wagon.isBulk ? ( {grossTons}/{maxGrossTons}T ) : ( 1 ? "1fr 1fr" : "1fr", gap: 3, }} > {(blocks.length ? blocks : ["—"]).map((cn, i) => ( {/* corrugation lines — dropped in two-row mode, no room */} {!twoRows ? ( ) : null} {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 ? ( {grossTons}T ) : null} ); }); /** Railway track: two rails over evenly-spaced sleepers. */ function TrackBed() { return ( {/* sleepers */} {/* rails */} ); } export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({ locomotive, locomotives, wagons, freightType, trainNumber, totalLengthMeters, }: { locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null; /** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */ locomotives?: Array<{ 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 locos = useMemo( () => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []), [locomotives, locomotive], ); // One car per PHYSICAL wagon: cross-leg TEU sharing pins two plan slots // (e.g. intercity + export on disjoint legs) onto the same wagon — merge // their cargo into one drawn car. Tare counts once; cargo and containers // combine. Slots without a pinned wagon stay their own car. const merged = useMemo(() => { const groups: DiagramWagonInput[][] = []; const byPhysical = new Map(); for (const w of wagons) { const existing = w.physicalWagonId ? byPhysical.get(w.physicalWagonId) : undefined; if (existing) { existing.push(w); continue; } const group = [w]; groups.push(group); if (w.physicalWagonId) byPhysical.set(w.physicalWagonId, group); } return groups.map((group) => group.length === 1 ? group[0]! : { ...group[0]!, assignedWeightTons: group.reduce( (s, w) => s + (Number(w.assignedWeightTons) || 0), 0, ), allocations: group.flatMap((w) => w.allocations ?? []), }, ); }, [wagons]); const normalized = useMemo( () => merged.map((w) => normalizeWagon(w, freightType)), [merged, 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); // Every coupled wagon's tare is hauled — empty ones included — so the // locomotive pull limit is measured against gross (tare + cargo), the same // ceiling the allocation engine spends from. const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0); const grossWeight = totalWeight + totalTare; // Engine rule (combinedLocomotiveLimits): coupled locomotives pull // TOGETHER, so their pull limits SUM. const pullLimits = locos .map((l) => Number(l.maxPullWeightTons)) .filter((v) => Number.isFinite(v) && v > 0); const pullLimit = pullLimits.length ? pullLimits.reduce((sum, v) => sum + v, 0) : null; return { total: normalized.length, assigned, empty: normalized.length - assigned, totalWeight: Math.round(totalWeight * 100) / 100, totalTare: Math.round(totalTare * 100) / 100, grossWeight: Math.round(grossWeight * 100) / 100, totalCapacity, pullLimit, pullUtil: pullLimit ? Math.min(100, Math.round((grossWeight / pullLimit) * 100)) : null, }; }, [normalized, locos]); // cars-per-row from measured width; each locomotive counts as one car const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH)); const cars = useMemo( () => [ ...locos.map((l) => ({ kind: "loco" as const, l })), ...normalized.map((w) => ({ kind: "wagon" as const, w })), ], [locos, normalized], ); const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]); if (!locos.length && !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 cargo of {stats.totalCapacity}T capacity {stats.totalTare > 0 ? ( {stats.grossWeight}T gross incl. {stats.totalTare}T tare ) : null} {/* Locomotive pull gauge */} {stats.pullUtil != null ? ( Locomotive load ·{" "} {stats.totalTare > 0 ? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)` : `${stats.totalWeight}T of ${stats.pullLimit}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" ? ( ) : ( )} ))} {/* serpentine turn connector to the next row */} {!isLast ? ( ) : null} ); })} ); }); function LegendDot({ color, label }: { color: string; label: string }) { return ( {label} ); }