A trailer floor packed with pallet blocks alongside volume and weight gauges that fill on mount and flag overload.
npx shadcn@latest add @paragon/load-planner"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { PackageOpen } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface LoadPallet {
label: string;
/** Footprint width in trailer floor units. */
width: number;
/** Footprint length (depth) in trailer floor units. */
length: number;
/** Weight in the same unit as `weightCapacity` (e.g. lb). */
weight?: number;
/** Tint intensity 0–1 (heavier = darker). Derived from weight if omitted. */
intensity?: number;
}
export interface LoadPlannerProps extends React.ComponentProps<"div"> {
pallets: LoadPallet[];
/** Trailer floor width in the same units as pallet footprints. */
floorWidth?: number;
/** Trailer floor length (depth) in the same units. */
floorLength?: number;
/** Axle weight limit; drives the weight gauge. */
weightCapacity?: number;
/** Suppresses gauge fill + block enter animation. */
static?: boolean;
}
interface PlacedPallet extends LoadPallet {
x: number;
y: number;
}
/**
* Shelf/next-fit bin packing into a fixed floor width. Deterministic: pallets
* are placed left→right on the current shelf; when the next pallet overflows
* the floor width, a new shelf opens below at the tallest block on the previous
* shelf. Returns placements plus the packed length (how deep the load reaches).
*/
function packPallets(
pallets: LoadPallet[],
floorWidth: number,
): { placed: PlacedPallet[]; packedLength: number } {
const placed: PlacedPallet[] = [];
let shelfY = 0;
let cursorX = 0;
let shelfHeight = 0;
for (const p of pallets) {
const w = Math.min(p.width, floorWidth);
if (cursorX + w > floorWidth + 1e-6 && cursorX > 0) {
shelfY += shelfHeight;
cursorX = 0;
shelfHeight = 0;
}
placed.push({ ...p, x: cursorX, y: shelfY });
cursorX += w;
shelfHeight = Math.max(shelfHeight, p.length);
}
return { placed, packedLength: shelfY + shelfHeight };
}
function Gauge({
label,
value,
animate,
danger,
detail,
}: {
label: string;
value: number;
animate: boolean;
danger: boolean;
detail?: string;
}) {
const [drawn, setDrawn] = React.useState(!animate);
React.useEffect(() => {
if (!animate) return;
const id = requestAnimationFrame(() => setDrawn(true));
return () => cancelAnimationFrame(id);
}, [animate]);
const clamped = Math.min(Math.max(value, 0), 1);
return (
<div>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate text-muted-foreground">{label}</span>
<span className="flex shrink-0 items-center gap-1.5">
{detail && (
<span className="tabular-nums text-muted-foreground">{detail}</span>
)}
<span
className={cn(
"w-9 text-right font-medium tabular-nums",
danger ? "text-destructive" : "text-foreground",
)}
>
{Math.round(clamped * 100)}%
</span>
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full origin-left rounded-full",
danger ? "bg-destructive" : "bg-primary",
)}
style={{
transform: `scaleX(${drawn ? clamped : 0})`,
transition: animate
? "transform 600ms var(--ease-out), background-color 300ms var(--ease-out)"
: undefined,
}}
/>
</div>
</div>
);
}
/**
* A truck load plan. Every pallet is drawn as a rectangle whose position and
* size are COMPUTED by a deterministic shelf-packing algorithm inside the
* trailer floor's coordinate space (no eyeballed boxes) — so blocks never
* overlap and always tile from the front of the trailer. Each block has a hover
* tooltip; the floor renders an empty state when unloaded. Volume and weight
* gauges fill on mount via a transform-only, interruptible transition and flip
* to the destructive token past 90%. Reduced motion renders filled.
*/
export function LoadPlanner({
pallets,
floorWidth = 12,
floorLength = 24,
weightCapacity,
static: isStatic = false,
className,
...props
}: LoadPlannerProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [mounted, setMounted] = React.useState(!animate);
React.useEffect(() => {
if (!animate) return;
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}, [animate]);
const { placed, packedLength } = React.useMemo(
() => packPallets(pallets, floorWidth),
[pallets, floorWidth],
);
// Volume = footprint area used / floor area (capped by the deepest shelf).
const usedArea = React.useMemo(
() => placed.reduce((sum, p) => sum + p.width * p.length, 0),
[placed],
);
const volumeUsed = Math.min(1, usedArea / (floorWidth * floorLength));
const totalWeight = React.useMemo(
() => placed.reduce((sum, p) => sum + (p.weight ?? 0), 0),
[placed],
);
const weightUsed = weightCapacity
? Math.min(1, totalWeight / weightCapacity)
: 0;
// SVG coordinate space matches the floor grid exactly. Padding keeps strokes
// from clipping at the edges.
const PAD = 2;
const CELL = 22;
const vbW = floorWidth * CELL + PAD * 2;
const vbH = floorLength * CELL + PAD * 2;
const weightFmt = React.useMemo(
() => new Intl.NumberFormat("en-US"),
[],
);
return (
<TooltipProvider>
<div
data-slot="load-planner"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">Trailer load plan</p>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{placed.length} {placed.length === 1 ? "pallet" : "pallets"}
</span>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-dashed border-border bg-secondary/30 p-2">
{placed.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="flex size-9 items-center justify-center rounded-full bg-secondary text-muted-foreground">
<PackageOpen aria-hidden className="size-4" />
</span>
<p className="text-sm font-medium">Empty trailer</p>
<p className="max-w-[28ch] text-xs text-pretty text-muted-foreground">
Add pallets to plan the load and fill the axle gauges.
</p>
</div>
) : (
<svg
viewBox={`0 0 ${vbW} ${vbH}`}
role="img"
aria-label={`Trailer floor packed with ${placed.length} pallets`}
className="h-auto w-full"
>
{/* Front-of-trailer guide line. */}
<line
x1={PAD}
y1={PAD}
x2={vbW - PAD}
y2={PAD}
stroke="var(--color-border)"
strokeWidth={1}
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
{placed.map((p, i) => {
const x = PAD + p.x * CELL;
const y = PAD + p.y * CELL;
const w = Math.min(p.width, floorWidth) * CELL;
const h = p.length * CELL;
const intensity =
p.intensity ??
(weightCapacity && p.weight
? Math.min(1, p.weight / (weightCapacity / 4))
: 0.5);
const fill = `color-mix(in oklch, var(--color-primary) ${Math.round(
14 + intensity * 44,
)}%, var(--color-card))`;
return (
<Tooltip key={`${p.label}-${i}`}>
<TooltipTrigger asChild>
<g
tabIndex={0}
role="button"
aria-label={`${p.label}, ${p.width}×${p.length}${
p.weight ? `, ${weightFmt.format(p.weight)} lb` : ""
}`}
className="origin-center cursor-default outline-none transition-[opacity] duration-150 ease-out hover:opacity-80 focus-visible:opacity-80"
style={{
opacity: mounted ? 1 : 0,
transform: mounted ? "none" : "translateY(6px)",
transition: animate
? `opacity 300ms var(--ease-out) ${i * 40}ms, transform 300ms var(--ease-out) ${i * 40}ms`
: undefined,
}}
>
<rect
x={x + 1}
y={y + 1}
width={Math.max(0, w - 2)}
height={Math.max(0, h - 2)}
rx={3}
fill={fill}
stroke="var(--color-border)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<text
x={x + w / 2}
y={y + h / 2}
textAnchor="middle"
dominantBaseline="central"
className="pointer-events-none fill-foreground/80 text-[9px] font-medium"
style={{ fontSize: 9 }}
>
{p.label}
</text>
</g>
</TooltipTrigger>
<TooltipContent>
{p.label} · {p.width}×{p.length}
{p.weight ? ` · ${weightFmt.format(p.weight)} lb` : ""}
</TooltipContent>
</Tooltip>
);
})}
</svg>
)}
</div>
<div className="mt-4 flex flex-col gap-3">
<Gauge
label="Floor area used"
value={volumeUsed}
animate={animate}
danger={volumeUsed >= 0.9}
detail={packedLength > floorLength ? "over depth" : undefined}
/>
{weightCapacity != null && (
<Gauge
label="Weight vs axle limit"
value={weightUsed}
animate={animate}
danger={weightUsed >= 0.9}
detail={`${weightFmt.format(totalWeight)} lb`}
/>
)}
</div>
</div>
</TooltipProvider>
);
}