Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
Marshal 40904049cf feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
2026-08-18 13:17:55 +00:00

844 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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: 13,
height: 13,
borderRadius: "50%",
background: dark
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
border: "2px solid var(--mantine-color-gray-4)",
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
}}
/>
))}
</Group>
);
}
function Coupler() {
return (
<Box style={{ width: 16, height: 78, 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))",
boxShadow: "0 1px 1px rgba(0,0,0,0.15)",
}}
/>
</Box>
);
}
const LocomotiveCar = memo(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: 78,
borderRadius: "14px 26px 10px 10px",
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
border: "1px solid rgba(0,0,0,0.1)",
overflow: "hidden",
padding: "10px 10px 8px",
color: "white",
}}
>
{/* roofline */}
<Box
style={{
position: "absolute",
top: 0,
left: 0,
right: 14,
height: 5,
background: "rgba(0,0,0,0.22)",
borderRadius: "14px 0 0 0",
}}
/>
{/* roof vents */}
<Box style={{ position: "absolute", top: 1, left: 16, display: "flex", gap: 5 }}>
{[0, 1, 2].map((i) => (
<Box
key={i}
style={{
width: 10,
height: 3,
borderRadius: 2,
background: "rgba(255,255,255,0.35)",
}}
/>
))}
</Box>
{/* cab windows */}
<Box style={{ position: "absolute", top: 10, right: 10, display: "flex", gap: 4 }}>
<Box
style={{
width: 15,
height: 13,
borderRadius: "3px 6px 3px 3px",
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
border: "1px solid rgba(0,0,0,0.15)",
}}
/>
<Box
style={{
width: 12,
height: 13,
borderRadius: 3,
background: "linear-gradient(135deg, rgba(232,251,255,0.8), rgba(158,217,232,0.6))",
border: "1px solid rgba(0,0,0,0.12)",
}}
/>
</Box>
{/* headlight */}
<Box
style={{
position: "absolute",
bottom: 14,
right: 5,
width: 8,
height: 8,
borderRadius: "50%",
background: "#fde68a",
boxShadow: "0 0 10px 3px rgba(253,230,138,0.9)",
}}
/>
{/* hazard stripe on the nose */}
<Box
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 6,
background:
"repeating-linear-gradient(45deg, #fbbf24 0 7px, #1f2937 7px 14px)",
opacity: 0.9,
}}
/>
<Group gap={6} wrap="nowrap" align="center">
<TrainFront size={18} />
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
{code}
</Text>
</Group>
<Text size="9px" mt={1} style={{ opacity: 0.85 }} lineClamp={1}>
{name ?? "Locomotive"}
</Text>
{maxPullWeightTons ? (
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
<Gauge size={10} />
<Text size="9px" fw={700}>
{maxPullWeightTons}T pull
</Text>
</Group>
) : null}
</Box>
<Wheels count={3} dark />
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
HEAD
</Text>
</Box>
</Tooltip>
);
});
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.
// 12 sit side by side; 34 form a 2×2 grid (two rows, up/down).
const blocks = wagon.containerNumbers.slice(0, 4);
const twoRows = blocks.length > 2;
return (
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
<Box style={{ width: 134, flexShrink: 0 }}>
<Box
style={{
position: "relative",
height: 78,
borderRadius: 12,
background: wagon.isEmpty
? "var(--mantine-color-gray-0)"
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
border: wagon.isEmpty
? "1.5px dashed var(--mantine-color-gray-4)"
: "1px solid var(--mantine-color-gray-3)",
boxShadow: wagon.isEmpty ? "none" : "0 3px 10px rgba(15,41,27,0.08)",
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={700} style={{ letterSpacing: 0.5 }}>
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`} style={{ letterSpacing: 0.4 }}>
{wagon.isBulk ? "BULK" : "CONT"}
</Text>
</Group>
)}
</Group>
{/* body */}
<Box style={{ flex: 1, padding: "4px 8px 4px", 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-6), var(--mantine-color-orange-4))",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35)",
}}
/>
</Box>
<Text size="9px" c="dimmed" ta="center" fw={600}>
{grossTons}/{maxGrossTons}T
</Text>
</Stack>
) : (
<Box
style={{
width: "100%",
display: "grid",
gridTemplateColumns: blocks.length > 1 ? "1fr 1fr" : "1fr",
gap: 3,
}}
>
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
<Box
key={i}
style={{
minWidth: 0,
height: twoRows ? 14 : 28,
borderRadius: twoRows ? 4 : 5,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0 3px",
}}
>
{/* corrugation lines — dropped in two-row mode, no room */}
{!twoRows ? (
<Box
style={{
width: "80%",
height: 2,
marginBottom: 2,
background:
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
borderRadius: 1,
}}
/>
) : null}
<Text size={twoRows ? "7px" : "8px"} fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{cn}
</Text>
</Box>
))}
</Box>
)}
</Box>
{/* utilization hairline */}
{!wagon.isEmpty && !wagon.isBulk ? (
<Box style={{ height: 3, background: "var(--mantine-color-gray-1)" }}>
<Box
style={{
width: `${utilization}%`,
height: "100%",
background:
utilization >= 100
? "var(--mantine-color-red-5)"
: `var(--mantine-color-${accent}-5)`,
}}
/>
</Box>
) : null}
{/* footer */}
<Box
style={{
borderTop: "1px solid var(--mantine-color-gray-1)",
padding: "2px 8px",
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
}}
>
<Group justify="space-between" wrap="nowrap" gap={4}>
<Text size="8px" c="dimmed" fw={600} truncate>
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
</Text>
{!wagon.isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{grossTons}T
</Text>
) : null}
</Group>
</Box>
</Box>
<Wheels count={2} />
</Box>
</Tooltip>
);
});
/** Railway track: two rails over evenly-spaced sleepers. */
function TrackBed() {
return (
<Box
style={{
position: "absolute",
left: 4,
right: 4,
bottom: 4,
height: 10,
}}
>
{/* sleepers */}
<Box
style={{
position: "absolute",
inset: 0,
background:
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
opacity: 0.6,
borderRadius: 2,
}}
/>
{/* rails */}
<Box
style={{
position: "absolute",
left: 0,
right: 0,
top: 1,
height: 2,
borderRadius: 1,
background: "var(--mantine-color-gray-6)",
}}
/>
<Box
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 1,
height: 2,
borderRadius: 1,
background: "var(--mantine-color-gray-6)",
}}
/>
</Box>
);
}
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<string, DiagramWagonInput[]>();
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 (
<Paper
radius="lg"
p="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
}}
>
<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: 40,
height: 40,
borderRadius: 11,
background: freightBrand.gradient,
boxShadow: freightBrand.shadowSm,
color: "white",
}}
>
<TrainFront size={21} />
</Box>
<Stack gap={0}>
<Text fw={800} style={{ letterSpacing: 0.2 }}>
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="md">
<Group
gap={6}
style={{
padding: "4px 12px",
borderRadius: 999,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={700} c="dark.4">
{stats.totalWeight}T cargo
</Text>
<Text size="xs" c="dimmed">
of {stats.totalCapacity}T capacity
</Text>
</Group>
{stats.totalTare > 0 ? (
<Group
gap={6}
style={{
padding: "4px 12px",
borderRadius: 999,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={700} c="dark.4">
{stats.grossWeight}T gross
</Text>
<Text size="xs" c="dimmed">
incl. {stats.totalTare}T tare
</Text>
</Group>
) : null}
<Group gap="xs">
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
<LegendDot color="gray" label="Empty" />
</Group>
</Group>
</Group>
{/* Locomotive pull gauge */}
{stats.pullUtil != null ? (
<Box
p="sm"
style={{
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-edr-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group justify="space-between" mb={6}>
<Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="edr-green.8">
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`}
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
{stats.pullUtil}%
</Text>
</Group>
<Progress
value={stats.pullUtil}
size="md"
radius="xl"
striped={stats.pullUtil > 95}
animated={stats.pullUtil > 95}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-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: 10 }}>
<TrackBed />
<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" ? (
<LocomotiveCar
code={car.l.code ?? "LOCO"}
name={car.l.name}
maxPullWeightTons={car.l.maxPullWeightTons}
/>
) : (
<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>
);
}