{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "route-tool",
  "title": "Route Tool",
  "description": "Click-to-add waypoint routing for map-view: numbered draggable role-coloured pins, the accent-yellow route line and a floating distance/duration info bar, with a debounced fetch against the nx-geo routing contract.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/geo-api",
    "@nx-ui/separator",
    "@nx-ui/spinner",
    "@nx-ui/tooltip",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/maps/route-tool.tsx",
      "content": "\"use client\"\n\n// @registry-dep: geo-api\n\nimport * as React from \"react\"\nimport {\n  Clock,\n  CornerUpLeft,\n  MarkerPin01,\n  Trash01,\n} from \"@untitledui/icons\"\nimport type { Feature, FeatureCollection, LineString } from \"geojson\"\nimport { Layer, Marker, Source } from \"react-map-gl/mapbox\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/**\n * route-tool — click-to-add waypoint routing for nx-ui maps.\n *\n * The marketplace map's haulage-route tool, rebuilt on the nx-geo contract:\n *\n *   • useRoute() — debounced (500 ms) fetch against the mock nx-geo endpoint\n *     (`POST /api/geo/route`, body `{ origin, destination, waypoints,\n *     profile }` with `{ lat, lon }` locations; response\n *     `data.routes[0] = { distanceMeters, durationSeconds, geometry }`).\n *     In-flight requests abort when waypoints move.\n *   • <RouteLineLayer> — the accent-yellow route line (a MapView child).\n *   • <RouteWaypointMarkers> — numbered, role-coloured pins with pulsing\n *     rings; drag to adjust, click to remove (MapView children).\n *   • <RouteInfoBar> — floating glass status bar: instructions, spinner,\n *     distance • duration, km/mi and traffic toggles, undo and clear.\n *     Render in MapView's `overlay` slot.\n *\n * Composition (see the maps/routing showcase page for the full wiring):\n *\n *   const { route, loading, error } = useRoute({ waypoints, profile })\n *   <MapView onClick={addWaypoint} overlay={<RouteInfoBar …/>}>\n *     {route && <RouteLineLayer geometry={route.geometry} />}\n *     <RouteWaypointMarkers waypoints={waypoints} … />\n *   </MapView>\n */\n\nexport type RouteProfile = \"driving\" | \"driving-traffic\"\n\nexport interface RouteToolSettings {\n  profile: RouteProfile\n  distanceUnit: \"km\" | \"mi\"\n}\n\n/** Marketplace defaults: live-traffic routing, miles. */\nexport const DEFAULT_ROUTE_SETTINGS: RouteToolSettings = {\n  profile: \"driving-traffic\",\n  distanceUnit: \"mi\",\n}\n\n/** nx-geo route shape (metres / seconds / GeoJSON LineString). */\nexport interface RouteData {\n  distanceMeters: number\n  durationSeconds: number\n  geometry: LineString\n}\n\n/** The house route-line style — Nexus accent yellow. */\nexport const ROUTE_LINE_STYLE = {\n  color: \"#f7ec33\",\n  width: 4,\n  opacity: 0.8,\n} as const\n\nexport type WaypointRole = \"origin\" | \"stop\" | \"destination\"\n\n/** Role colours (theme green/yellow/red-500s). */\nexport const WAYPOINT_COLORS: Record<WaypointRole, string> = {\n  origin: \"#17b26a\",\n  stop: \"#f79009\",\n  destination: \"#f04438\",\n}\n\nexport function waypointRole(index: number, count: number): WaypointRole {\n  if (index === 0) return \"origin\"\n  if (index === count - 1 && count > 1) return \"destination\"\n  return \"stop\"\n}\n\nexport const MAX_WAYPOINTS = 25\n\n// ---------------------------------------------------------------------------\n// Formatting\n// ---------------------------------------------------------------------------\n\nconst METERS_PER_MILE = 1609.344\n\nexport function formatRouteDistance(\n  meters: number,\n  unit: \"km\" | \"mi\" = \"km\"\n): string {\n  const value = unit === \"mi\" ? meters / METERS_PER_MILE : meters / 1000\n  return `${value.toFixed(1)} ${unit}`\n}\n\nexport function formatRouteDuration(seconds: number): string {\n  const minutes = Math.round(seconds / 60)\n  if (minutes < 60) return `${Math.max(minutes, 1)} min`\n  const hours = Math.floor(minutes / 60)\n  const rest = minutes % 60\n  return rest > 0 ? `${hours} h ${rest} min` : `${hours} h`\n}\n\n// ---------------------------------------------------------------------------\n// Fetching\n// ---------------------------------------------------------------------------\n\nexport interface UseRouteOptions {\n  /** Ordered [longitude, latitude] waypoints; a route needs at least two. */\n  waypoints: [number, number][]\n  profile?: RouteProfile\n  /** nx-geo-shaped endpoint. Default: the local mock. */\n  endpoint?: string\n  /** Fetch debounce, matching the marketplace's 500 ms. */\n  debounceMs?: number\n}\n\nexport function useRoute({\n  waypoints,\n  profile = \"driving-traffic\",\n  endpoint = \"/api/geo/route\",\n  debounceMs = 500,\n}: UseRouteOptions) {\n  const [route, setRoute] = React.useState<RouteData | null>(null)\n  const [loading, setLoading] = React.useState(false)\n  const [error, setError] = React.useState<string | null>(null)\n\n  const requestKey =\n    waypoints.length >= 2\n      ? `${waypoints.map((w) => `${w[0].toFixed(5)},${w[1].toFixed(5)}`).join(\";\")}|${profile}`\n      : null\n  const waypointsRef = React.useRef(waypoints)\n  waypointsRef.current = waypoints\n\n  React.useEffect(() => {\n    if (!requestKey) {\n      setRoute(null)\n      setLoading(false)\n      setError(null)\n      return\n    }\n    const controller = new AbortController()\n    setLoading(true)\n    setError(null)\n    const timer = setTimeout(() => {\n      const points = waypointsRef.current\n      const toLoc = (point: [number, number]) => ({ lat: point[1], lon: point[0] })\n      fetch(endpoint, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          origin: toLoc(points[0]),\n          destination: toLoc(points[points.length - 1]),\n          waypoints: points.slice(1, -1).map(toLoc),\n          profile,\n        }),\n        signal: controller.signal,\n      })\n        .then((response) => response.json())\n        .then(\n          (json: {\n            success: boolean\n            data?: { routes: RouteData[] }\n            error?: string\n          }) => {\n            if (controller.signal.aborted) return\n            const first = json.success ? json.data?.routes?.[0] : undefined\n            if (first) {\n              setRoute({\n                distanceMeters: first.distanceMeters,\n                durationSeconds: first.durationSeconds,\n                geometry: first.geometry,\n              })\n            } else {\n              setError(json.error ?? \"Route unavailable.\")\n            }\n            setLoading(false)\n          }\n        )\n        .catch((cause: unknown) => {\n          if (controller.signal.aborted) return\n          setError(cause instanceof Error ? cause.message : \"Route unavailable.\")\n          setLoading(false)\n        })\n    }, debounceMs)\n    return () => {\n      clearTimeout(timer)\n      controller.abort()\n    }\n  }, [requestKey, endpoint, profile, debounceMs])\n\n  return { route, loading, error }\n}\n\n// ---------------------------------------------------------------------------\n// Map layers & markers (render inside the map)\n// ---------------------------------------------------------------------------\n\nexport interface RouteLineLayerProps {\n  /** Route geometry; null renders nothing but KEEPS the source mounted —\n   * removing a live Mapbox source mid-session trips a terrain update crash\n   * in mapbox-gl v3, so keep this component mounted and pass null instead\n   * of unmounting it. */\n  geometry: LineString | null\n  color?: string\n  width?: number\n  opacity?: number\n  /** Source/layer id prefix — override when drawing several routes. */\n  id?: string\n}\n\n/** The route line, accent yellow by default, round caps and joins. */\nexport function RouteLineLayer({\n  geometry,\n  color = ROUTE_LINE_STYLE.color,\n  width = ROUTE_LINE_STYLE.width,\n  opacity = ROUTE_LINE_STYLE.opacity,\n  id = \"route\",\n}: RouteLineLayerProps) {\n  const data = React.useMemo<Feature<LineString> | FeatureCollection>(\n    () =>\n      geometry\n        ? { type: \"Feature\", geometry, properties: {} }\n        : { type: \"FeatureCollection\", features: [] },\n    [geometry]\n  )\n  return (\n    <Source id={`${id}-source`} type=\"geojson\" data={data}>\n      <Layer\n        id={`${id}-line`}\n        type=\"line\"\n        layout={{ \"line-cap\": \"round\", \"line-join\": \"round\" }}\n        paint={{\n          \"line-color\": color,\n          \"line-width\": width,\n          \"line-opacity\": opacity,\n        }}\n      />\n    </Source>\n  )\n}\n\nexport interface RouteWaypointPinProps {\n  longitude: number\n  latitude: number\n  /** 0-based position in the route; the pin shows `index + 1`. */\n  index: number\n  role: WaypointRole\n  draggable?: boolean\n  onDragEnd?: (coords: [number, number]) => void\n  /** Click-to-remove. Omit to render a passive pin. */\n  onRemove?: () => void\n  /** Pulsing halo behind the pin (reduced-motion aware). Default true. */\n  pulse?: boolean\n}\n\n/** A numbered waypoint pin: role-coloured head with a white ring, a pulsing\n * halo, and an entrance zoom. Draggable when asked; click removes. */\nexport function RouteWaypointPin({\n  longitude,\n  latitude,\n  index,\n  role,\n  draggable,\n  onDragEnd,\n  onRemove,\n  pulse = true,\n}: RouteWaypointPinProps) {\n  const color = WAYPOINT_COLORS[role]\n  // Suppress the click that Mapbox fires straight after a drag ends.\n  const lastDragRef = React.useRef(0)\n\n  const pin = (\n    <span className=\"relative flex flex-col items-center animate-in duration-300 fade-in zoom-in\">\n      {pulse ? (\n        <span\n          aria-hidden\n          className=\"absolute top-0 size-8 rounded-full opacity-30 motion-safe:animate-ping\"\n          style={{ backgroundColor: color }}\n        />\n      ) : null}\n      <span\n        className=\"relative flex size-8 items-center justify-center rounded-full border-2 border-white text-sm font-bold text-white shadow-lg\"\n        style={{ backgroundColor: color }}\n      >\n        {index + 1}\n      </span>\n      <span\n        aria-hidden\n        className=\"-mt-1.5 size-2.5 rotate-45 rounded-[1px] border-r-2 border-b-2 border-white\"\n        style={{ backgroundColor: color }}\n      />\n    </span>\n  )\n\n  return (\n    <Marker\n      longitude={longitude}\n      latitude={latitude}\n      anchor=\"bottom\"\n      draggable={draggable}\n      onDragEnd={(event) => {\n        lastDragRef.current = Date.now()\n        onDragEnd?.([event.lngLat.lng, event.lngLat.lat])\n      }}\n    >\n      {onRemove ? (\n        <button\n          type=\"button\"\n          aria-label={`Remove waypoint ${index + 1}`}\n          title=\"Remove waypoint\"\n          className=\"cursor-pointer\"\n          onClick={() => {\n            if (Date.now() - lastDragRef.current > 250) onRemove()\n          }}\n        >\n          {pin}\n        </button>\n      ) : (\n        pin\n      )}\n    </Marker>\n  )\n}\n\nexport interface RouteWaypointMarkersProps {\n  waypoints: [number, number][]\n  /** Drag-to-adjust. Default true when `onMoveWaypoint` is provided. */\n  draggable?: boolean\n  onMoveWaypoint?: (index: number, coords: [number, number]) => void\n  onRemoveWaypoint?: (index: number) => void\n}\n\n/** All waypoint pins for a route, numbered and role-coloured\n * (green origin → amber stops → red destination). */\nexport function RouteWaypointMarkers({\n  waypoints,\n  draggable,\n  onMoveWaypoint,\n  onRemoveWaypoint,\n}: RouteWaypointMarkersProps) {\n  const canDrag = draggable ?? onMoveWaypoint !== undefined\n  return (\n    <>\n      {waypoints.map((waypoint, index) => (\n        <RouteWaypointPin\n          // Index keys keep pins stable while a waypoint drags.\n          key={index}\n          longitude={waypoint[0]}\n          latitude={waypoint[1]}\n          index={index}\n          role={waypointRole(index, waypoints.length)}\n          draggable={canDrag}\n          onDragEnd={\n            onMoveWaypoint ? (coords) => onMoveWaypoint(index, coords) : undefined\n          }\n          onRemove={\n            onRemoveWaypoint ? () => onRemoveWaypoint(index) : undefined\n          }\n        />\n      ))}\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Info bar (render in MapView's `overlay` slot)\n// ---------------------------------------------------------------------------\n\nexport interface RouteInfoBarProps {\n  waypointCount: number\n  route: RouteData | null\n  loading?: boolean\n  error?: string | null\n  settings: RouteToolSettings\n  onSettingsChange: (settings: RouteToolSettings) => void\n  /** Remove the last waypoint. */\n  onUndo?: () => void\n  /** Remove every waypoint. */\n  onClear?: () => void\n  className?: string\n}\n\n/** The floating route status pill with the signature yellow ring: waypoint\n * instructions → spinner → \"distance • duration\", plus traffic profile,\n * km/mi, undo and clear controls. */\nexport function RouteInfoBar({\n  waypointCount,\n  route,\n  loading,\n  error,\n  settings,\n  onSettingsChange,\n  onUndo,\n  onClear,\n  className,\n}: RouteInfoBarProps) {\n  const traffic = settings.profile === \"driving-traffic\"\n  return (\n    <div\n      data-slot=\"route-info-bar\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-3\",\n        className\n      )}\n    >\n      <TooltipProvider delay={300}>\n        <div className=\"pointer-events-auto inline-flex max-w-full flex-wrap items-center gap-x-2.5 gap-y-1.5 rounded-lg border border-border/70 bg-background/90 py-1.5 pr-1.5 pl-3 shadow-lg ring-2 ring-accent-400/60 backdrop-blur-md [box-shadow:0_0_10px_rgba(247,236,51,0.3)]\">\n          {waypointCount === 0 ? (\n            <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n              <MarkerPin01 className=\"size-3.5\" />\n              Click the map to add waypoints\n            </span>\n          ) : waypointCount === 1 ? (\n            <span className=\"text-xs font-medium text-muted-foreground\">\n              Add at least one more waypoint\n            </span>\n          ) : loading ? (\n            <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n              <Spinner className=\"size-3.5\" />\n              Calculating route…\n            </span>\n          ) : error ? (\n            <span className=\"text-xs font-medium text-destructive\">{error}</span>\n          ) : route ? (\n            <span className=\"text-xs font-semibold text-foreground tabular-nums\">\n              {formatRouteDistance(route.distanceMeters, settings.distanceUnit)}\n              <span className=\"mx-1 text-muted-foreground\">•</span>\n              {formatRouteDuration(route.durationSeconds)}\n            </span>\n          ) : null}\n\n          <Separator orientation=\"vertical\" className=\"h-4!\" />\n\n          <span className=\"inline-flex items-center gap-0.5\">\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon-xs\"\n                    aria-label={\n                      traffic ? \"Ignore live traffic\" : \"Use live traffic\"\n                    }\n                    aria-pressed={traffic}\n                    onClick={() =>\n                      onSettingsChange({\n                        ...settings,\n                        profile: traffic ? \"driving\" : \"driving-traffic\",\n                      })\n                    }\n                    className={cn(\n                      \"text-fg-quaternary hover:text-foreground\",\n                      traffic && \"bg-muted text-foreground\"\n                    )}\n                  >\n                    <Clock />\n                  </Button>\n                }\n              />\n              <TooltipContent side=\"bottom\">\n                {traffic ? \"Live traffic on\" : \"Live traffic off\"}\n              </TooltipContent>\n            </Tooltip>\n\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon-xs\"\n                    aria-label={`Switch to ${settings.distanceUnit === \"mi\" ? \"kilometres\" : \"miles\"}`}\n                    onClick={() =>\n                      onSettingsChange({\n                        ...settings,\n                        distanceUnit: settings.distanceUnit === \"mi\" ? \"km\" : \"mi\",\n                      })\n                    }\n                    className=\"text-xs font-semibold text-fg-quaternary uppercase hover:text-foreground\"\n                  >\n                    {settings.distanceUnit}\n                  </Button>\n                }\n              />\n              <TooltipContent side=\"bottom\">Distance unit</TooltipContent>\n            </Tooltip>\n\n            {onUndo ? (\n              <Tooltip>\n                <TooltipTrigger\n                  render={\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon-xs\"\n                      aria-label=\"Undo last waypoint\"\n                      disabled={waypointCount === 0}\n                      onClick={onUndo}\n                      className=\"text-fg-quaternary hover:text-foreground\"\n                    >\n                      <CornerUpLeft />\n                    </Button>\n                  }\n                />\n                <TooltipContent side=\"bottom\">Undo last waypoint</TooltipContent>\n              </Tooltip>\n            ) : null}\n\n            {onClear ? (\n              <Tooltip>\n                <TooltipTrigger\n                  render={\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon-xs\"\n                      aria-label=\"Clear route\"\n                      disabled={waypointCount === 0}\n                      onClick={onClear}\n                      className=\"text-fg-quaternary hover:text-foreground\"\n                    >\n                      <Trash01 />\n                    </Button>\n                  }\n                />\n                <TooltipContent side=\"bottom\">Clear route</TooltipContent>\n              </Tooltip>\n            ) : null}\n          </span>\n        </div>\n      </TooltipProvider>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/maps/route-tool.tsx"
    }
  ],
  "type": "registry:block"
}