Logistics

Load Planner

A trailer floor packed with pallet blocks alongside volume and weight gauges that fill on mount and flag overload.

Install

npx shadcn@latest add @paragon/load-planner

load-planner.tsx

"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>
  );
}