Logistics

Order Batching

A delivery batch grouping view with orders plotted on a mini map and optimized route lines that trace in per batch.

Install

npx shadcn@latest add @paragon/order-batching

Also installs: number-ticker, tooltip

order-batching.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Package, Route } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export interface BatchOrder {
  id: string;
  /** Normalized drop coordinates 0–1 within the mini map. */
  x: number;
  y: number;
  /** Optional stop label for the tooltip. */
  label?: string;
}

export interface OrderBatch {
  name: string;
  /** Hue token for this batch. */
  tone: "primary" | "success" | "warning";
  orders: BatchOrder[];
  /** Optimized route distance, e.g. "8.2 mi". */
  distance: string;
}

export interface OrderBatchingProps extends React.ComponentProps<"div"> {
  batches: OrderBatch[];
  /** Suppresses the route-line draw + enter animations. */
  static?: boolean;
}

const TONE: Record<OrderBatch["tone"], string> = {
  primary: "var(--color-primary)",
  success: "var(--color-success)",
  warning: "var(--color-warning)",
};

// Single consistent coordinate space for the mini map.
const VIEW_W = 260;
const VIEW_H = 150;
const PAD = 14; // inset so markers never clip the rounded edge
const INNER_W = VIEW_W - PAD * 2;
const INNER_H = VIEW_H - PAD * 2;
// Gridlines computed off the inner plot area so paths align exactly.
const GRID_COLS = 6;
const GRID_ROWS = 4;

/** Project a normalized 0–1 order coord into the padded plot area. */
function project(x: number, y: number) {
  return {
    x: PAD + Math.min(Math.max(x, 0), 1) * INNER_W,
    y: PAD + Math.min(Math.max(y, 0), 1) * INNER_H,
  };
}

/**
 * A delivery batch grouping view: orders plotted on a computed local mini map,
 * colored by batch, with an optimized route polyline per batch that traces in
 * on mount. Hovering (or focusing) a legend row highlights its route and
 * dims the others; every stop and legend row carries a tooltip. Batch rows
 * animate on add / remove / reorder via layout animation. Deterministic —
 * all coordinates arrive as props.
 */
export function OrderBatching({
  batches,
  static: isStatic = false,
  className,
  ...props
}: OrderBatchingProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;
  const [mounted, setMounted] = React.useState(false);
  const [active, setActive] = React.useState<string | null>(null);

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

  const totalOrders = batches.reduce((s, b) => s + b.orders.length, 0);
  const empty = batches.length === 0 || totalOrders === 0;

  // Precompute vertical + horizontal gridlines from the plot math.
  const vLines = Array.from({ length: GRID_COLS + 1 }, (_, i) => ({
    x: PAD + (i / GRID_COLS) * INNER_W,
  }));
  const hLines = Array.from({ length: GRID_ROWS + 1 }, (_, i) => ({
    y: PAD + (i / GRID_ROWS) * INNER_H,
  }));

  return (
    <TooltipProvider>
      <style href="paragon-order-batching" precedence="paragon">{`
        .pg-batch-stop {
          transition: r 150ms var(--ease-out);
        }
        @media (hover: hover) and (pointer: fine) {
          .pg-batch-stop:hover { r: 5px; }
        }
        .pg-batch-stop:focus-visible { r: 5px; }
        @media (prefers-reduced-motion: reduce) {
          .pg-batch-stop { transition: none; }
        }
      `}</style>
      <div
        data-slot="order-batching"
        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="min-w-0 truncate text-sm font-medium">Route batching</p>
          <span className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
            <Package aria-hidden className="size-3.5" />
            <NumberTicker
              value={totalOrders}
              static={!animate}
              className="tabular-nums"
            />
            <span>orders</span>
          </span>
        </div>

        <svg
          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
          className="mt-3 w-full rounded-lg bg-secondary/40"
          role="img"
          aria-label="Batched delivery routes"
          preserveAspectRatio="xMidYMid meet"
        >
          {/* Computed gridlines — a quiet plotting backdrop. */}
          <g stroke="var(--color-border)" strokeWidth={0.5} opacity={0.6}>
            {vLines.map((l, i) => (
              <line
                key={`v${i}`}
                x1={l.x}
                y1={PAD}
                x2={l.x}
                y2={VIEW_H - PAD}
                vectorEffect="non-scaling-stroke"
              />
            ))}
            {hLines.map((l, i) => (
              <line
                key={`h${i}`}
                x1={PAD}
                y1={l.y}
                x2={VIEW_W - PAD}
                y2={l.y}
                vectorEffect="non-scaling-stroke"
              />
            ))}
          </g>

          {empty && (
            <text
              x={VIEW_W / 2}
              y={VIEW_H / 2}
              textAnchor="middle"
              dominantBaseline="middle"
              className="fill-muted-foreground text-[9px]"
            >
              No routes to batch
            </text>
          )}

          {batches.map((batch, bi) => {
            const pts = batch.orders.map((o) => ({
              ...project(o.x, o.y),
              id: o.id,
              label: o.label,
            }));
            const d = pts
              .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
              .join(" ");
            const color = TONE[batch.tone];
            const drawn = !animate || mounted;
            const dim = active !== null && active !== batch.name;
            return (
              <g
                key={batch.name}
                style={{
                  opacity: dim ? 0.25 : 1,
                  transition:
                    "opacity 200ms var(--ease-out)",
                }}
              >
                <path
                  d={d}
                  fill="none"
                  stroke={color}
                  strokeWidth={1.75}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  pathLength={100}
                  vectorEffect="non-scaling-stroke"
                  style={{
                    strokeDasharray: 100,
                    strokeDashoffset: drawn ? 0 : 100,
                    transition: animate
                      ? `stroke-dashoffset 900ms var(--ease-out) ${bi * 120}ms`
                      : undefined,
                    opacity: active === batch.name ? 0.95 : 0.7,
                  }}
                />
                {pts.map((p) => (
                  <Tooltip key={p.id}>
                    <TooltipTrigger asChild>
                      <circle
                        cx={p.x}
                        cy={p.y}
                        r={3.5}
                        fill={color}
                        stroke="var(--color-card)"
                        strokeWidth={1.5}
                        tabIndex={0}
                        role="button"
                        aria-label={`${batch.name}, stop ${p.label ?? p.id}`}
                        className="pg-batch-stop cursor-pointer outline-none"
                        onPointerEnter={() => setActive(batch.name)}
                        onPointerLeave={() => setActive(null)}
                        onFocus={() => setActive(batch.name)}
                        onBlur={() => setActive(null)}
                      />
                    </TooltipTrigger>
                    <TooltipContent>
                      {batch.name} · {p.label ?? `Stop ${p.id}`}
                    </TooltipContent>
                  </Tooltip>
                ))}
              </g>
            );
          })}
        </svg>

        <ul className="mt-3 flex flex-col gap-1">
          <AnimatePresence initial={false}>
            {batches.map((batch) => (
              <motion.li
                key={batch.name}
                layout
                initial={
                  animate
                    ? { opacity: 0, y: 12, filter: "blur(4px)" }
                    : false
                }
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
              >
                <button
                  type="button"
                  onPointerEnter={() => setActive(batch.name)}
                  onPointerLeave={() => setActive(null)}
                  onFocus={() => setActive(batch.name)}
                  onBlur={() => setActive(null)}
                  className={cn(
                    "flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm outline-none",
                    "transition-[background-color,scale] duration-150 ease-out",
                    "hover:bg-accent focus-visible:bg-accent",
                    "focus-visible:ring-2 focus-visible:ring-ring",
                    !isStatic && "active:not-disabled:scale-[0.98]",
                  )}
                >
                  <span
                    aria-hidden
                    className="size-2.5 shrink-0 rounded-full transition-transform duration-150 ease-out"
                    style={{
                      backgroundColor: TONE[batch.tone],
                      transform:
                        active === batch.name ? "scale(1.3)" : "scale(1)",
                    }}
                  />
                  <span className="min-w-0 truncate font-medium">
                    {batch.name}
                  </span>
                  <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground tabular-nums">
                    <span className="w-[52px] text-right">
                      {batch.orders.length} stops
                    </span>
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <span className="flex w-[52px] items-center justify-end gap-1">
                          <Route aria-hidden className="size-3.5 shrink-0" />
                          {batch.distance}
                        </span>
                      </TooltipTrigger>
                      <TooltipContent>Optimized route distance</TooltipContent>
                    </Tooltip>
                  </span>
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>
      </div>
    </TooltipProvider>
  );
}