A delivery batch grouping view with orders plotted on a mini map and optimized route lines that trace in per batch.
npx shadcn@latest add @paragon/order-batchingAlso installs: number-ticker, tooltip
"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>
);
}