Logistics

Container Tracker

A container journey rail with milestone stops and a live vessel marker positioned by fractional progress.

Install

npx shadcn@latest add @paragon/container-tracker

Also installs: world-map, tooltip

container-tracker.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { WorldMap, project } from "@/registry/paragon/ui/world-map";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export interface ContainerMilestone {
  label: string;
  /** Short location or timestamp caption. */
  detail?: string;
  /** Optional geographic coordinates — drawn as a stop on the route map. */
  lng?: number;
  lat?: number;
}

export interface ContainerTrackerProps extends React.ComponentProps<"div"> {
  /** Container / booking number. */
  containerId: string;
  milestones: readonly ContainerMilestone[];
  /** Fractional progress 0–1 of the vessel along the rail. */
  progress: number;
  /** Index of the most recently completed milestone (inclusive). */
  currentIndex: number;
  /** Render the geographic route map when milestones carry coordinates. */
  showMap?: boolean;
  /** Suppresses the rail draw-in animation. */
  static?: boolean;
}

const RAIL_X0 = 10;
const RAIL_W = 540;

/** Catmull-Rom → cubic Bézier smoothing through projected waypoints. */
function smoothPath(pts: { x: number; y: number }[]): string {
  if (pts.length === 0) return "";
  if (pts.length === 1) return `M${pts[0].x} ${pts[0].y}`;
  let d = `M${pts[0].x} ${pts[0].y}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i - 1] ?? pts[i];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[i + 2] ?? p2;
    const c1x = p1.x + (p2.x - p0.x) / 6;
    const c1y = p1.y + (p2.y - p0.y) / 6;
    const c2x = p2.x - (p3.x - p1.x) / 6;
    const c2y = p2.y - (p3.y - p1.y) / 6;
    d += ` C${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
  }
  return d;
}

/**
 * A container journey rendered as a horizontal rail with milestone stops and a
 * live vessel marker positioned by fractional progress. When milestones carry
 * lng/lat, a geographic route is drawn on the accurate world-map: the traveled
 * portion animates via a stroke-dashoffset reveal and the vessel rides the
 * computed path. The rail's traveled portion grows via scaleX and the vessel
 * translates along a single computed coordinate space. Transform/clip only,
 * reduced-motion-safe. Deterministic: position comes from props, never Date.now.
 */
export function ContainerTracker({
  containerId,
  milestones,
  progress,
  currentIndex,
  showMap = true,
  static: isStatic = false,
  className,
  ...props
}: ContainerTrackerProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;
  const [mounted, setMounted] = React.useState(false);
  const pathRef = React.useRef<SVGPathElement>(null);
  const [pathLen, setPathLen] = React.useState(0);

  React.useEffect(() => {
    const id = requestAnimationFrame(() => setMounted(true));
    return () => cancelAnimationFrame(id);
  }, []);

  const pct = Math.min(Math.max(progress, 0), 1) * 100;
  const frac = pct / 100;
  const reveal = animate && !mounted ? 0 : pct;

  const geoStops = milestones.filter(
    (m) => typeof m.lng === "number" && typeof m.lat === "number",
  );
  const hasMap = showMap && geoStops.length >= 2;

  const projected = React.useMemo(
    () => geoStops.map((m) => project(m.lng as number, m.lat as number)),
    [geoStops],
  );
  const routeD = React.useMemo(() => smoothPath(projected), [projected]);

  // Vessel point on the projected route at the current fraction.
  React.useLayoutEffect(() => {
    if (pathRef.current) setPathLen(pathRef.current.getTotalLength());
  }, [routeD]);
  const vessel = React.useMemo(() => {
    if (!pathRef.current || pathLen === 0) return null;
    return pathRef.current.getPointAtLength(frac * pathLen);
  }, [frac, pathLen]);

  return (
    <TooltipProvider>
      <div
        data-slot="container-tracker"
        className={cn(
          "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="flex items-center justify-between gap-3">
          <p className="min-w-0 truncate font-mono text-sm font-medium tabular-nums">
            {containerId}
          </p>
          <span className="shrink-0 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground">
            {Math.round(pct)}% complete
          </span>
        </div>

        {hasMap && (
          <div className="relative mt-4 overflow-hidden rounded-lg bg-secondary/40 shadow-border">
            <WorldMap tooltip={false} />
            {/* Route overlay shares the world-map viewBox exactly, so every
                waypoint lands on the correct landmass. */}
            <svg
              viewBox="0 0 1010 666"
              aria-hidden
              className="pointer-events-none absolute inset-0 h-full w-full"
              preserveAspectRatio="none"
            >
              <path
                ref={pathRef}
                d={routeD}
                fill="none"
                stroke="var(--color-border)"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
              {pathLen > 0 && (
                <path
                  d={routeD}
                  fill="none"
                  stroke="var(--color-primary)"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  vectorEffect="non-scaling-stroke"
                  className="transition-[stroke-dashoffset] duration-1000 ease-out motion-reduce:transition-none"
                  style={{
                    strokeDasharray: pathLen,
                    strokeDashoffset: pathLen * (1 - (mounted || !animate ? frac : 0)),
                  }}
                />
              )}
              {projected.map((p, i) => {
                const reached = i <= currentIndex;
                return (
                  <circle
                    key={i}
                    cx={p.x}
                    cy={p.y}
                    r={3.5}
                    stroke="var(--color-card)"
                    strokeWidth={2}
                    vectorEffect="non-scaling-stroke"
                    className={cn(
                      "transition-colors duration-300",
                      reached ? "fill-primary" : "fill-border",
                    )}
                  />
                );
              })}
              {vessel && (
                <g
                  className="transition-transform duration-1000 ease-out motion-reduce:transition-none"
                  style={{
                    transform: `translate(${vessel.x}px, ${vessel.y}px)`,
                  }}
                >
                  <circle
                    r={5}
                    className="fill-foreground"
                    stroke="var(--color-card)"
                    strokeWidth={2}
                    vectorEffect="non-scaling-stroke"
                  />
                </g>
              )}
            </svg>
          </div>
        )}

        {/* Linear milestone rail — every coordinate computed from the shared scale. */}
        <svg
          aria-hidden
          viewBox="0 0 560 32"
          className="mt-6 w-full"
          preserveAspectRatio="none"
        >
          <rect
            x={RAIL_X0}
            y={14}
            width={RAIL_W}
            height={4}
            rx={2}
            className="fill-secondary"
          />
          <rect
            x={RAIL_X0}
            y={14}
            width={RAIL_W}
            height={4}
            rx={2}
            className="origin-left fill-primary transition-transform duration-700 ease-out motion-reduce:transition-none"
            style={{
              transformBox: "fill-box",
              transform: `scaleX(${reveal / 100})`,
            }}
          />
          {milestones.map((_, i) => {
            const x =
              RAIL_X0 +
              (milestones.length > 1 ? i / (milestones.length - 1) : 0) * RAIL_W;
            const reached = i <= currentIndex;
            return (
              <circle
                key={i}
                cx={x}
                cy={16}
                r={4}
                className={cn(
                  "stroke-card transition-colors duration-300 [stroke-width:3px]",
                  reached ? "fill-primary" : "fill-border",
                )}
              />
            );
          })}
          <g
            className="transition-transform duration-700 ease-out motion-reduce:transition-none"
            style={{ transform: `translateX(${(reveal / 100) * RAIL_W}px)` }}
          >
            <circle cx={RAIL_X0} cy={16} r={8} className="fill-foreground" />
            <path
              d="M6 17h8l-1 2.5H7L6 17Zm1-.5v-3l3 1 3-1v3H7Z"
              className="fill-background"
              transform="translate(2 -1)"
            />
          </g>
        </svg>

        <ol className="mt-6 grid grid-cols-2 gap-x-3 gap-y-4 sm:grid-cols-4">
          {milestones.map((m, i) => {
            const reached = i <= currentIndex;
            const isCurrent = i === currentIndex;
            return (
              <li key={`${m.label}-${i}`} className="min-w-0">
                <Tooltip>
                  <TooltipTrigger asChild>
                    <button
                      type="button"
                      className="group flex w-full min-w-0 flex-col items-start rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
                    >
                      <span
                        className={cn(
                          "flex w-full min-w-0 items-center gap-1 text-xs font-medium",
                          reached ? "text-foreground" : "text-muted-foreground",
                        )}
                      >
                        {isCurrent && (
                          <span
                            aria-hidden
                            className="size-1.5 shrink-0 rounded-full bg-primary"
                          />
                        )}
                        <span className="truncate">{m.label}</span>
                      </span>
                      {m.detail && (
                        <span className="mt-0.5 w-full truncate text-[11px] tabular-nums text-muted-foreground">
                          {m.detail}
                        </span>
                      )}
                    </button>
                  </TooltipTrigger>
                  <TooltipContent>
                    {m.label}
                    {m.detail ? ` — ${m.detail}` : ""}
                  </TooltipContent>
                </Tooltip>
              </li>
            );
          })}
        </ol>
      </div>
    </TooltipProvider>
  );
}