Logistics

Warehouse Map

A zone and bin occupancy grid rendering warehouse utilization as a success-to-full heat map.

Install

npx shadcn@latest add @paragon/warehouse-map

Also installs: number-ticker, tooltip

warehouse-map.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/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 WarehouseBin {
  /** Bin / slot label, e.g. "A-04". */
  label: string;
  /** Occupancy 0–1 driving the heat tint. */
  fill: number;
}

export interface WarehouseZone {
  name: string;
  bins: WarehouseBin[];
}

/** Occupancy bands the heat scale is quantized into. */
export type HeatBand = "low" | "mid" | "full";

export interface WarehouseMapProps extends React.ComponentProps<"div"> {
  zones: WarehouseZone[];
  /** Columns per zone grid. */
  columns?: number;
  /** Suppresses the reveal + stagger animations. */
  static?: boolean;
}

const BAND_META: Record<
  HeatBand,
  { label: string; token: string; swatch: string }
> = {
  low: { label: "Low", token: "var(--color-success)", swatch: "bg-success/70" },
  mid: {
    label: "Mid",
    token: "var(--color-warning)",
    swatch: "bg-warning/70",
  },
  full: {
    label: "Full",
    token: "var(--color-destructive)",
    swatch: "bg-destructive/70",
  },
};

const BAND_ORDER: HeatBand[] = ["low", "mid", "full"];

/** Quantize a 0–1 fill into a heat band. Thresholds are the tint breakpoints. */
function bandOf(fill: number): HeatBand {
  const f = Math.min(Math.max(fill, 0), 1);
  return f >= 0.85 ? "full" : f >= 0.6 ? "mid" : "low";
}

/** Tint a bin from its fill fraction: token mixed over the secondary surface. */
function heatStyle(fill: number): React.CSSProperties {
  const f = Math.min(Math.max(fill, 0), 1);
  const token = BAND_META[bandOf(f)].token;
  return {
    backgroundColor: `color-mix(in oklch, ${token} ${Math.round(
      20 + f * 65,
    )}%, var(--color-secondary))`,
  };
}

/**
 * A zone/bin occupancy grid rendering warehouse utilization as a heat map. Each
 * bin's tint is derived deterministically from its fill fraction via a
 * color-mix across success → warning → destructive tokens. Bins reveal in on
 * first scroll into view with a short row-wise stagger; the header and each
 * zone total count up via a number-ticker. Hovering (or focusing) a legend
 * band highlights matching bins and dims the rest; every bin carries a tooltip
 * with its exact occupancy. Deterministic — all fills arrive as props.
 */
export function WarehouseMap({
  zones,
  columns = 6,
  static: isStatic = false,
  className,
  ...props
}: WarehouseMapProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, {
    once: true,
    margin: "0px 0px -32px 0px",
  });
  const revealed = !animate || inView;
  const [band, setBand] = React.useState<HeatBand | null>(null);

  const allBins = React.useMemo(
    () => zones.flatMap((z) => z.bins),
    [zones],
  );
  const empty = zones.length === 0 || allBins.length === 0;

  // Aggregate occupancy across every bin, plus a per-band count for the legend.
  const { occupancy, counts } = React.useMemo(() => {
    const c: Record<HeatBand, number> = { low: 0, mid: 0, full: 0 };
    let sum = 0;
    for (const b of allBins) {
      sum += Math.min(Math.max(b.fill, 0), 1);
      c[bandOf(b.fill)]++;
    }
    return {
      occupancy: allBins.length ? (sum / allBins.length) * 100 : 0,
      counts: c,
    };
  }, [allBins]);

  // Running index across all bins so the reveal stagger is continuous
  // across zone boundaries rather than resetting per zone.
  let binIndex = 0;

  return (
    <TooltipProvider>
      <div
        ref={rootRef}
        data-slot="warehouse-map"
        className={cn(
          "w-full max-w-lg rounded-xl bg-card p-5 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <p className="truncate text-sm font-medium">Warehouse occupancy</p>
            <p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
              <NumberTicker
                value={allBins.length}
                static={!animate}
                className="text-muted-foreground"
              />{" "}
              bins across {zones.length}{" "}
              {zones.length === 1 ? "zone" : "zones"}
            </p>
          </div>
          <Tooltip>
            <TooltipTrigger asChild>
              <div className="shrink-0 text-right outline-none">
                <p className="text-xl font-semibold tabular-nums leading-none">
                  <NumberTicker
                    value={Math.round(occupancy)}
                    static={!animate}
                  />
                  <span className="text-sm font-medium text-muted-foreground">
                    %
                  </span>
                </p>
                <p className="mt-1 text-[11px] text-muted-foreground">
                  utilization
                </p>
              </div>
            </TooltipTrigger>
            <TooltipContent>Mean fill across all bins</TooltipContent>
          </Tooltip>
        </div>

        {empty ? (
          <div className="mt-4 flex h-28 items-center justify-center rounded-lg border border-dashed border-border">
            <p className="text-xs text-muted-foreground">No zones to display</p>
          </div>
        ) : (
          <div className="mt-4 flex flex-col gap-4">
            {zones.map((zone) => {
              const zoneSum = zone.bins.reduce(
                (s, b) => s + Math.min(Math.max(b.fill, 0), 1),
                0,
              );
              const zoneAvg = zone.bins.length
                ? (zoneSum / zone.bins.length) * 100
                : 0;
              return (
                <div key={zone.name}>
                  <div className="mb-1.5 flex items-center justify-between gap-3">
                    <p className="min-w-0 truncate text-xs font-medium text-muted-foreground">
                      Zone {zone.name}
                    </p>
                    <p className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
                      <NumberTicker
                        value={Math.round(zoneAvg)}
                        static={!animate}
                        className="text-muted-foreground"
                      />
                      % full
                    </p>
                  </div>
                  {/* Quiet fill bar — scaleX only, never width. */}
                  <div className="mb-2 h-1 overflow-hidden rounded-full bg-secondary">
                    <div
                      aria-hidden
                      className="h-full origin-left rounded-full bg-foreground/25 transition-transform duration-500 ease-out"
                      style={{
                        transform: `scaleX(${revealed ? zoneAvg / 100 : 0})`,
                      }}
                    />
                  </div>
                  <div
                    className="grid gap-1"
                    style={{
                      gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
                    }}
                    role="list"
                    aria-label={`Zone ${zone.name} bins`}
                  >
                    {zone.bins.map((bin) => {
                      const b = bandOf(bin.fill);
                      const dim = band !== null && band !== b;
                      const pct = Math.round(
                        Math.min(Math.max(bin.fill, 0), 1) * 100,
                      );
                      const i = binIndex++;
                      return (
                        <Tooltip key={bin.label}>
                          <TooltipTrigger asChild>
                            <motion.div
                              role="listitem"
                              tabIndex={0}
                              aria-label={`Bin ${bin.label}, ${pct}% full, ${BAND_META[b].label}`}
                              initial={
                                animate
                                  ? { opacity: 0, y: 12, filter: "blur(4px)" }
                                  : false
                              }
                              animate={
                                revealed
                                  ? {
                                      opacity: dim ? 0.3 : 1,
                                      y: 0,
                                      filter: "blur(0px)",
                                    }
                                  : undefined
                              }
                              transition={{
                                duration: 0.32,
                                ease: [0.22, 1, 0.36, 1],
                                delay: animate ? Math.min(i * 0.012, 0.4) : 0,
                                opacity: { duration: 0.15 },
                              }}
                              className={cn(
                                "relative flex aspect-square items-center justify-center rounded-[5px] outline-none ring-ring",
                                "transition-[box-shadow,scale] duration-150 ease-out",
                                "hover:shadow-border-hover focus-visible:ring-2",
                                !isStatic && "active:scale-[0.94]",
                              )}
                              style={heatStyle(bin.fill)}
                            >
                              <span className="text-[9px] font-medium tabular-nums text-foreground/70">
                                {bin.label}
                              </span>
                            </motion.div>
                          </TooltipTrigger>
                          <TooltipContent>
                            {bin.label} · {pct}% full
                          </TooltipContent>
                        </Tooltip>
                      );
                    })}
                  </div>
                </div>
              );
            })}
          </div>
        )}

        {!empty && (
          <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1.5">
            {BAND_ORDER.map((s) => (
              <button
                key={s}
                type="button"
                onPointerEnter={() => setBand(s)}
                onPointerLeave={() => setBand(null)}
                onFocus={() => setBand(s)}
                onBlur={() => setBand(null)}
                className={cn(
                  "flex items-center gap-1.5 rounded-md px-1 py-0.5 text-[11px] text-muted-foreground 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:scale-[0.97]",
                )}
              >
                <span
                  aria-hidden
                  className={cn("size-2.5 rounded-[3px]", BAND_META[s].swatch)}
                />
                {BAND_META[s].label}
                <span className="font-medium text-foreground tabular-nums">
                  {counts[s]}
                </span>
              </button>
            ))}
          </div>
        )}
      </div>
    </TooltipProvider>
  );
}