mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge pull request #1030 from Tria-plc/freight_feature/usermanagement
leg-aware capacity and wagon sharing
This commit is contained in:
@@ -739,6 +739,7 @@ export function AllocateBookingWizard({
|
||||
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
||||
<TrainCompositionDiagram
|
||||
locomotive={assignedSchedule?.trainSet?.locomotive}
|
||||
locomotives={assignedSchedule?.trainSet?.locomotives}
|
||||
wagons={
|
||||
assignedSchedule?.trainSet?.wagons?.length
|
||||
? assignedSchedule.trainSet.wagons
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { memo, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
@@ -248,6 +248,96 @@ function RankedCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive-run grouping of an already-ranked lane by boarding class:
|
||||
* government first, then each booking-window cycle (windowCycleNo is 0-based,
|
||||
* so cycle 0 renders as "1st cycle window"). rankBookings sorts gov → cycle
|
||||
* asc, so consecutive runs are exactly the cycle groups.
|
||||
*/
|
||||
type CycleGroup = {
|
||||
key: string;
|
||||
color: string;
|
||||
label: string;
|
||||
sub: string | null;
|
||||
items: BatchBoardBookingDetail[];
|
||||
};
|
||||
|
||||
const CYCLE_COLORS = ["indigo", "cyan", "teal"];
|
||||
|
||||
const ordinal = (n: number) =>
|
||||
n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`;
|
||||
|
||||
function groupMeta(b: BatchBoardBookingDetail): Omit<CycleGroup, "items"> {
|
||||
if (b.isGovernment)
|
||||
return { key: "gov", color: "grape", label: "Government", sub: "boards first" };
|
||||
if (b.windowCycleNo == null)
|
||||
return {
|
||||
key: "none",
|
||||
color: "gray",
|
||||
label: "No cycle yet",
|
||||
sub: "contract not signed",
|
||||
};
|
||||
const n = b.windowCycleNo + 1;
|
||||
return {
|
||||
key: `c${b.windowCycleNo}`,
|
||||
color: CYCLE_COLORS[b.windowCycleNo % CYCLE_COLORS.length],
|
||||
label: `${ordinal(n)} cycle window`,
|
||||
sub: n === 1 ? "booked in the first window" : "boards after earlier cycles",
|
||||
};
|
||||
}
|
||||
|
||||
function groupByCycle(items: BatchBoardBookingDetail[]): CycleGroup[] {
|
||||
const groups: CycleGroup[] = [];
|
||||
for (const b of items) {
|
||||
const meta = groupMeta(b);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.key === meta.key) last.items.push(b);
|
||||
else groups.push({ ...meta, items: [b] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Tinted wrapper card holding one cycle's ranked bookings. */
|
||||
function CycleSection({
|
||||
group,
|
||||
children,
|
||||
}: {
|
||||
group: CycleGroup;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const wagons = group.items.reduce((s, b) => s + b.wagons, 0);
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: cardVar(group.color, 2),
|
||||
background: cardVar(group.color, 0),
|
||||
}}
|
||||
>
|
||||
<Group gap={8} mb={8} wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color={group.color}>
|
||||
{group.key === "gov" ? <Crown size={12} /> : <Layers size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c={`${group.color}.8`}>
|
||||
{group.label}
|
||||
</Text>
|
||||
{group.sub ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
— {group.sub}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" fw={600} c="dimmed" ml="auto" style={{ flexShrink: 0 }}>
|
||||
{group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "}
|
||||
{wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>{children}</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** The capacity cut line drawn between "in the batch" and "waiting list". */
|
||||
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
||||
const full = max != null && used >= max;
|
||||
@@ -482,19 +572,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.inBatch.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groupByCycle(lanes.inBatch).map((g) => (
|
||||
<CycleSection key={g.key} group={g}>
|
||||
{g.items.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CycleSection>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
@@ -514,19 +608,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.waiting.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groupByCycle(lanes.waiting).map((g) => (
|
||||
<CycleSection key={g.key} group={g}>
|
||||
{g.items.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CycleSection>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -288,8 +288,11 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
||||
}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
// 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 (
|
||||
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
||||
@@ -376,15 +379,21 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
<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={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 28,
|
||||
borderRadius: 5,
|
||||
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)",
|
||||
@@ -395,23 +404,25 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
padding: "0 3px",
|
||||
}}
|
||||
>
|
||||
{/* corrugation lines */}
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{/* 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>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ type DragState = { sourceWagonId: string } | null;
|
||||
interface InteractiveTrainConsistProps {
|
||||
wagons: Wagon[];
|
||||
locomotive: Locomotive | null | undefined;
|
||||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||
locomotives?: NonNullable<TrainScheduleDetail["trainSet"]>["locomotives"] | null;
|
||||
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
|
||||
getCompany: (bookingId: string | undefined) => string | null;
|
||||
selectedWagonId: string | null;
|
||||
@@ -557,6 +559,7 @@ function WagonCar({
|
||||
export const InteractiveTrainConsist = ({
|
||||
wagons,
|
||||
locomotive,
|
||||
locomotives,
|
||||
getCompany,
|
||||
selectedWagonId,
|
||||
onSelectWagon,
|
||||
@@ -565,6 +568,7 @@ export const InteractiveTrainConsist = ({
|
||||
onMoveLoad,
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
const [drag, setDrag] = useState<DragState>(null);
|
||||
const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : [];
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -589,7 +593,12 @@ export const InteractiveTrainConsist = ({
|
||||
) : null}
|
||||
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
||||
{locos.map((loco, i) => (
|
||||
<Group key={loco.code ?? i} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 ? <Coupler /> : null}
|
||||
<LocomotiveCar locomotive={loco} />
|
||||
</Group>
|
||||
))}
|
||||
{wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||
No wagons assigned
|
||||
@@ -599,7 +608,7 @@ export const InteractiveTrainConsist = ({
|
||||
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||
return (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 || locomotive ? <Coupler /> : null}
|
||||
{i > 0 || locos.length ? <Coupler /> : null}
|
||||
<WagonCar
|
||||
wagon={wagon}
|
||||
company={getCompany(bookingId)}
|
||||
|
||||
@@ -135,13 +135,27 @@ export const TrainConsistView = ({
|
||||
const weightUsed = cargoUsed + tareUsed;
|
||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
||||
|
||||
// Weakest locomotive caps the set — same rule the allocation engine applies.
|
||||
const locos = trainSet?.locomotives?.length
|
||||
? trainSet.locomotives
|
||||
: trainSet?.locomotive
|
||||
? [trainSet.locomotive]
|
||||
: [];
|
||||
const weightMax = locos.length
|
||||
? Math.min(...locos.map((l) => l.maxPullWeightTons))
|
||||
: null;
|
||||
const lengthCaps = locos
|
||||
.map((l) => l.maxTrainLengthMeters)
|
||||
.filter((v): v is number => v != null);
|
||||
const lengthMax = lengthCaps.length ? Math.min(...lengthCaps) : null;
|
||||
|
||||
return (
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<TrainStatsBar
|
||||
weightUsed={weightUsed}
|
||||
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
weightMax={weightMax}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
lengthMax={lengthMax}
|
||||
wagonCount={loadedCount}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
@@ -202,6 +216,7 @@ export const TrainConsistView = ({
|
||||
<InteractiveTrainConsist
|
||||
wagons={wagons}
|
||||
locomotive={trainSet?.locomotive}
|
||||
locomotives={trainSet?.locomotives}
|
||||
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
||||
selectedWagonId={selectedWagonId}
|
||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -923,9 +924,19 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||||
// A built train's length is its marshalled consist — always
|
||||
// the same figure the Train Builder shows.
|
||||
value: (() => {
|
||||
const length =
|
||||
data.capacity.trainLengthMeters ??
|
||||
data.capacity.allocatedLengthMeters;
|
||||
return data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(length)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(length);
|
||||
})(),
|
||||
hint: data.capacity.trainLengthMeters
|
||||
? "built consist — matches Train Builder"
|
||||
: undefined,
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
@@ -933,9 +944,24 @@ export default function BatchScheduleDetailPage() {
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
hint: "wagon tare + cargo",
|
||||
hint: (() => {
|
||||
const legs = data.capacity.legUsage;
|
||||
if (!legs?.length) return "wagon tare + cargo";
|
||||
const peak = legs.reduce((a, b) =>
|
||||
b.usedWeightTons > a.usedWeightTons ? b : a,
|
||||
);
|
||||
return `peak leg ${peak.from} → ${peak.to} · wagon tare + cargo`;
|
||||
})(),
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
label: "Wagons",
|
||||
value: data.capacity.maxWagons
|
||||
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||
: data.capacity.allocatedWagons,
|
||||
hint: "allocated wagon slots",
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
value: totalBookings,
|
||||
@@ -945,6 +971,92 @@ export default function BatchScheduleDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Per-leg load — only multi-stop corridors have distinct legs */}
|
||||
{data.capacity.legUsage && data.capacity.legUsage.length > 1 ? (
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap={8} mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||||
<Weight size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Load per leg</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Each leg carries only the bookings riding it — the heaviest
|
||||
leg is what the locomotive actually pulls.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="md" align="stretch" wrap="wrap">
|
||||
{(() => {
|
||||
const legs = data.capacity.legUsage;
|
||||
const max = data.capacity.maxWeightTons;
|
||||
const peakTons = Math.max(
|
||||
...legs.map((l) => l.usedWeightTons),
|
||||
);
|
||||
return legs.map((leg, i) => {
|
||||
const pct = max
|
||||
? Math.round((leg.usedWeightTons / max) * 100)
|
||||
: null;
|
||||
const over = pct != null && pct > 100;
|
||||
const isPeak =
|
||||
peakTons > 0 && leg.usedWeightTons === peakTons;
|
||||
return (
|
||||
<Paper
|
||||
key={i}
|
||||
radius="md"
|
||||
withBorder
|
||||
p="sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 210,
|
||||
borderColor: isPeak
|
||||
? "var(--mantine-color-yellow-4)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
background: isPeak
|
||||
? "var(--mantine-color-yellow-0)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" mb={6}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{leg.from} → {leg.to}
|
||||
</Text>
|
||||
{isPeak ? (
|
||||
<Badge size="xs" color="yellow" variant="filled">
|
||||
peak
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={over ? "red.7" : "dark.5"}>
|
||||
{fmtTons(leg.usedWeightTons)}
|
||||
{max ? ` / ${fmtTons(max)}` : ""}
|
||||
{pct != null ? ` · ${pct}%` : ""}
|
||||
</Text>
|
||||
{pct != null ? (
|
||||
<Progress
|
||||
mt={6}
|
||||
value={Math.min(100, pct)}
|
||||
color={over ? "red" : pct > 90 ? "yellow" : "edr-green"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
striped={over}
|
||||
animated={over}
|
||||
/>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
@@ -1133,6 +1245,7 @@ export default function BatchScheduleDetailPage() {
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
locomotives={scheduleDetailQuery.data.trainSet?.locomotives}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType={scheduleDetailQuery.data.freightType ?? null}
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Package,
|
||||
PackageCheck,
|
||||
Route as RouteIcon,
|
||||
Ruler,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
@@ -1076,6 +1077,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
: "No locomotives assigned",
|
||||
icon: Train,
|
||||
},
|
||||
...(schedule.trainSet?.totalLengthMeters
|
||||
? [
|
||||
{
|
||||
label: "Train length",
|
||||
// Physical consist length — the same figure Train Builder shows.
|
||||
value: `${schedule.trainSet.totalLengthMeters}m`,
|
||||
hint: "built consist — matches Train Builder",
|
||||
icon: Ruler,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Bookings",
|
||||
value: schedule.bookings?.length ?? 0,
|
||||
|
||||
@@ -363,12 +363,17 @@ export interface BatchBoardSchedule {
|
||||
allocatedLengthMeters: number;
|
||||
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxLengthMeters: number | null;
|
||||
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
|
||||
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both.
|
||||
* On a multi-stop corridor this is the HEAVIEST single edge, not the sum. */
|
||||
usedWeightTons: number;
|
||||
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
|
||||
maxWagons: number | null;
|
||||
/** Physical consist length of the built train (Train Builder), null without one. */
|
||||
trainLengthMeters: number | null;
|
||||
/** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
|
||||
legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
|
||||
Reference in New Issue
Block a user