{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marketplace-map",
  "title": "Marketplace Map",
  "description": "The orchestrating marketplace map surface: supercluster bubbles with type-mix rings, toned facility pins with hover/selected states, an HTML spiderfy fan, travel-time and route tool integration, search-this-area re-querying, listing popups with the exchange handoff, and the full transfer-preview choreography.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "next-themes@^0.4.6",
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/isochrone-tool",
    "@nx-ui/listing-card",
    "@nx-ui/map-toolbar",
    "@nx-ui/map-view",
    "@nx-ui/marketplace-types",
    "@nx-ui/route-tool",
    "@nx-ui/spinner",
    "@nx-ui/transfer-preview",
    "@nx-ui/use-listings-clusters",
    "@nx-ui/use-marketplace",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/marketplace/marketplace-map.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertTriangle,\n  RefreshCcw01,\n  Route,\n  SearchLg,\n  Target04,\n} from \"@untitledui/icons\"\nimport { useTheme } from \"next-themes\"\nimport { Marker, type MapMouseEvent } from \"react-map-gl/mapbox\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport {\n  createIsochroneFilter,\n  DEFAULT_ISOCHRONE_SETTINGS,\n  IsochroneInfoBar,\n  IsochroneLayers,\n  IsochroneOriginMarker,\n  useIsochrone,\n  type IsochroneSettings,\n} from \"@/components/blocks/maps/isochrone-tool\"\nimport { MapToolbar } from \"@/components/blocks/maps/map-toolbar\"\nimport {\n  MapView,\n  resolveMapStyle,\n  UK_DEFAULT_VIEW,\n  type MapBasemap,\n  type MapViewProps,\n} from \"@/components/blocks/maps/map-view\"\nimport {\n  DEFAULT_ROUTE_SETTINGS,\n  RouteInfoBar,\n  RouteLineLayer,\n  RouteWaypointMarkers,\n  useRoute,\n  type RouteToolSettings,\n} from \"@/components/blocks/maps/route-tool\"\nimport {\n  TransferPreviewDock,\n  TransferWaypointPins,\n  useTransferCamera,\n  type TransferListing,\n  type TransferProposal,\n} from \"@/components/blocks/maps/transfer-preview\"\nimport { ListingPopup } from \"@/components/blocks/marketplace/listing-card\"\nimport {\n  LISTING_TONES,\n  listingTone,\n  padBbox,\n  type ListingFeature,\n  type MarketplaceBbox,\n  type MarketplaceListing,\n} from \"@/components/blocks/marketplace/marketplace-types\"\nimport {\n  useListingsClusters,\n  type ListingsClusterItem,\n} from \"@/components/blocks/marketplace/use-listings-clusters\"\nimport type { Marketplace } from \"@/components/blocks/marketplace/use-marketplace\"\n\n/**\n * marketplace-map — the orchestrating map surface of the marketplace family.\n *\n * Composes the nx-ui map blocks into the full marketplace experience, driven\n * entirely by the use-marketplace hook (pass the same instance to\n * <ListingsPanel> and <ListingsFilters> and all three surfaces stay in sync):\n *\n *   • supercluster-backed markers rendered as real HTML elements — cluster\n *     bubbles carry a type-mix conic ring and a count; individual pins are\n *     toned by facility type with hover / selected / keyboard states, synced\n *     bidirectionally with the listings panel;\n *   • a spiderfy fan for clusters that cannot split by zooming (co-located\n *     listings spread into an offset ring with connector spokes — an HTML\n *     rebuild of the GL plugin approach, so leaves are real buttons);\n *   • the map-toolbar with travel-time (isochrone-tool) and route\n *     (route-tool) toggles; the isochrone's largest contour can filter the\n *     whole experience by reach;\n *   • a \"Search this area\" pill when the viewport drifts from the committed\n *     query area — results never churn under the user mid-pan;\n *   • the listing popup with the select-for-exchange handoff, and the full\n *     transfer-preview choreography (70° pitch, route frame, bottom dock)\n *     once a source and destination are paired;\n *   • loading, error (degrade-don't-blank with retry) and empty states.\n */\n\nexport interface MarketplaceMapProps\n  extends Pick<MapViewProps, \"initialViewState\" | \"className\"> {\n  marketplace: Marketplace\n  /** Base listings endpoint, used for the popup's lazy detail fetch. */\n  detailEndpoint?: string\n  /** nx-geo-shaped routing endpoint (route tool + transfer preview). */\n  routeEndpoint?: string\n  /** nx-geo-shaped isochrone endpoint (travel-time tool). */\n  isochroneEndpoint?: string\n  /** Fired when the transfer dock saves a proposal. */\n  onSaveProposal?: (proposal: TransferProposal) => void\n  /** Nominal exchange volume for a listing, m³ (demo default: deterministic). */\n  volumeForListing?: (listing: MarketplaceListing) => number\n  /** Extra overlays / map children. */\n  overlay?: React.ReactNode\n  children?: React.ReactNode\n}\n\ntype MapTool = \"none\" | \"isochrone\" | \"route\"\n\n// ---------------------------------------------------------------------------\n// Markers\n// ---------------------------------------------------------------------------\n\nfunction clusterSize(count: number): number {\n  if (count >= 200) return 52\n  if (count >= 50) return 46\n  if (count >= 10) return 40\n  return 34\n}\n\n/** Conic-gradient stops for the cluster's type-mix ring — the rebuilt take on\n * nexus4's segmented \"overlap pie\" cluster icons, rendered in CSS. */\nfunction toneRing(tones: Record<string, number>): string {\n  const entries = Object.entries(tones).sort((a, b) => b[1] - a[1])\n  const total = entries.reduce((sum, [, count]) => sum + count, 0) || 1\n  let angle = 0\n  const stops: string[] = []\n  for (const [tone, count] of entries) {\n    const start = angle\n    angle += (count / total) * 360\n    const color = LISTING_TONES[tone]?.color ?? \"#667085\"\n    stops.push(`${color} ${start.toFixed(1)}deg ${angle.toFixed(1)}deg`)\n  }\n  return stops.length > 0\n    ? `conic-gradient(${stops.join(\", \")})`\n    : \"conic-gradient(#667085 0deg 360deg)\"\n}\n\nconst ClusterMarker = React.memo(function ClusterMarker({\n  longitude,\n  latitude,\n  count,\n  tones,\n  onClick,\n}: {\n  longitude: number\n  latitude: number\n  count: number\n  tones: Record<string, number>\n  onClick: (longitude: number, latitude: number) => void\n}) {\n  const size = clusterSize(count)\n  return (\n    <Marker longitude={longitude} latitude={latitude} anchor=\"center\">\n      <button\n        type=\"button\"\n        data-marker=\"cluster\"\n        aria-label={`Expand cluster of ${count} listings`}\n        onClick={(event) => {\n          event.stopPropagation()\n          onClick(longitude, latitude)\n        }}\n        className=\"group cursor-pointer rounded-full p-[3px] shadow-lg transition-transform hover:scale-110 focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\"\n        style={{ width: size, height: size, background: toneRing(tones) }}\n      >\n        <span className=\"flex size-full items-center justify-center rounded-full bg-brand-600 text-accent-400 ring-2 ring-background/90 transition-colors group-hover:bg-brand-500\">\n          <span\n            className=\"font-semibold tabular-nums\"\n            style={{ fontSize: count >= 1000 ? 11 : 12 }}\n          >\n            {count >= 1000 ? `${(count / 1000).toFixed(1)}k` : count}\n          </span>\n        </span>\n      </button>\n    </Marker>\n  )\n})\n\nconst PinMarker = React.memo(function PinMarker({\n  feature,\n  hovered,\n  selected,\n  dimmed,\n  exchangeRole,\n  onClick,\n  onHover,\n}: {\n  feature: ListingFeature\n  hovered: boolean\n  selected: boolean\n  dimmed: boolean\n  exchangeRole: \"source\" | \"destination\" | null\n  onClick: (feature: ListingFeature) => void\n  onHover: (id: string | null) => void\n}) {\n  const [longitude, latitude] = feature.geometry.coordinates\n  const tone = listingTone(feature.properties.facilityType)\n  const lifted = hovered || selected || exchangeRole !== null\n  return (\n    <Marker\n      longitude={longitude}\n      latitude={latitude}\n      anchor=\"center\"\n      style={lifted ? { zIndex: 5 } : undefined}\n    >\n      <button\n        type=\"button\"\n        data-marker=\"pin\"\n        data-listing-id={feature.properties.id}\n        data-hovered={hovered || undefined}\n        data-selected={selected || undefined}\n        aria-label={feature.properties.name}\n        onClick={(event) => {\n          event.stopPropagation()\n          onClick(feature)\n        }}\n        onMouseEnter={() => onHover(feature.properties.id)}\n        onMouseLeave={() => onHover(null)}\n        onFocus={() => onHover(feature.properties.id)}\n        onBlur={() => onHover(null)}\n        className={cn(\n          \"relative flex cursor-pointer items-center justify-center rounded-full transition-transform focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\",\n          lifted ? \"scale-125\" : \"hover:scale-110\",\n          dimmed && \"opacity-40\"\n        )}\n        style={{ width: 18, height: 18 }}\n      >\n        {lifted ? (\n          <span\n            aria-hidden\n            className=\"absolute inset-[-5px] rounded-full opacity-30\"\n            style={{ backgroundColor: tone.color }}\n          />\n        ) : null}\n        <span\n          className=\"relative size-full rounded-full shadow-md ring-2 ring-background\"\n          style={{\n            backgroundColor: tone.color,\n            boxShadow: selected ? `0 0 0 3px ${tone.color}55` : undefined,\n          }}\n        />\n        {exchangeRole ? (\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute -top-1.5 -right-1.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-bold text-white ring-2 ring-background\",\n              exchangeRole === \"source\" ? \"bg-green-600\" : \"bg-red-500\"\n            )}\n          >\n            {exchangeRole === \"source\" ? \"1\" : \"2\"}\n          </span>\n        ) : null}\n      </button>\n    </Marker>\n  )\n})\n\ninterface MarkerLayerProps {\n  items: ListingsClusterItem[]\n  hoveredId: string | null\n  selectedId: string | null\n  sourceId: string | null\n  destinationId: string | null\n  visibleIds: Set<string> | null\n  onClusterClick: (item: Extract<ListingsClusterItem, { kind: \"cluster\" }>) => void\n  onPinClick: (feature: ListingFeature) => void\n  onHover: (id: string | null) => void\n}\n\nconst MarkerLayer = React.memo(function MarkerLayer({\n  items,\n  hoveredId,\n  selectedId,\n  sourceId,\n  destinationId,\n  visibleIds,\n  onClusterClick,\n  onPinClick,\n  onHover,\n}: MarkerLayerProps) {\n  return (\n    <>\n      {items.map((item) => {\n        if (item.kind === \"cluster\") {\n          return (\n            <ClusterMarker\n              key={`cluster-${item.id}`}\n              longitude={item.longitude}\n              latitude={item.latitude}\n              count={item.count}\n              tones={item.tones}\n              onClick={() => onClusterClick(item)}\n            />\n          )\n        }\n        const id = item.feature.properties.id\n        return (\n          <PinMarker\n            key={`pin-${id}`}\n            feature={item.feature}\n            hovered={hoveredId === id}\n            selected={selectedId === id}\n            dimmed={visibleIds !== null && !visibleIds.has(id)}\n            exchangeRole={\n              sourceId === id ? \"source\" : destinationId === id ? \"destination\" : null\n            }\n            onClick={onPinClick}\n            onHover={onHover}\n          />\n        )\n      })}\n    </>\n  )\n})\n\n// ---------------------------------------------------------------------------\n// Spiderfy (co-located listings spread into an offset ring)\n// ---------------------------------------------------------------------------\n\ninterface SpiderState {\n  longitude: number\n  latitude: number\n  leaves: ListingFeature[]\n}\n\nfunction SpiderFan({\n  spider,\n  hoveredId,\n  selectedId,\n  onPinClick,\n  onHover,\n  onClose,\n}: {\n  spider: SpiderState\n  hoveredId: string | null\n  selectedId: string | null\n  onPinClick: (feature: ListingFeature) => void\n  onHover: (id: string | null) => void\n  onClose: () => void\n}) {\n  const count = spider.leaves.length\n  const radius = Math.max(40, 26 + count * 4.5)\n  const box = radius * 2 + 40\n\n  return (\n    <Marker\n      longitude={spider.longitude}\n      latitude={spider.latitude}\n      anchor=\"center\"\n      style={{ zIndex: 6 }}\n    >\n      <div\n        data-marker=\"spider\"\n        className=\"relative animate-in duration-200 fade-in zoom-in-95 motion-reduce:animate-none\"\n        style={{ width: box, height: box }}\n        onMouseLeave={() => onHover(null)}\n      >\n        {/* Connector spokes. */}\n        <svg\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 text-border\"\n          width={box}\n          height={box}\n          viewBox={`0 0 ${box} ${box}`}\n        >\n          {spider.leaves.map((leaf, index) => {\n            const angle = (index / count) * Math.PI * 2 - Math.PI / 2\n            return (\n              <line\n                key={leaf.properties.id}\n                x1={box / 2}\n                y1={box / 2}\n                x2={box / 2 + Math.cos(angle) * radius}\n                y2={box / 2 + Math.sin(angle) * radius}\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n              />\n            )\n          })}\n        </svg>\n        {/* Hub — click to collapse. */}\n        <button\n          type=\"button\"\n          aria-label=\"Collapse expanded listings\"\n          onClick={(event) => {\n            event.stopPropagation()\n            onClose()\n          }}\n          className=\"absolute top-1/2 left-1/2 size-4 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 border-background bg-fg-quaternary shadow-md\"\n        />\n        {spider.leaves.map((leaf, index) => {\n          const angle = (index / count) * Math.PI * 2 - Math.PI / 2\n          const x = box / 2 + Math.cos(angle) * radius\n          const y = box / 2 + Math.sin(angle) * radius\n          const id = leaf.properties.id\n          const tone = listingTone(leaf.properties.facilityType)\n          const lifted = hoveredId === id || selectedId === id\n          return (\n            <button\n              key={id}\n              type=\"button\"\n              data-marker=\"spider-leaf\"\n              data-listing-id={id}\n              aria-label={leaf.properties.name}\n              onClick={(event) => {\n                event.stopPropagation()\n                onPinClick(leaf)\n              }}\n              onMouseEnter={() => onHover(id)}\n              onFocus={() => onHover(id)}\n              onBlur={() => onHover(null)}\n              className={cn(\n                \"absolute flex -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full transition-transform focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\",\n                lifted ? \"z-10 scale-125\" : \"hover:scale-110\"\n              )}\n              style={{ left: x, top: y, width: 18, height: 18 }}\n            >\n              <span\n                className=\"size-full rounded-full shadow-md ring-2 ring-background\"\n                style={{\n                  backgroundColor: tone.color,\n                  boxShadow: lifted ? `0 0 0 3px ${tone.color}55` : undefined,\n                }}\n              />\n            </button>\n          )\n        })}\n      </div>\n    </Marker>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// The orchestrator\n// ---------------------------------------------------------------------------\n\n/** Deterministic demo volume (m³) so the transfer dock has a slider ceiling\n * even though public-register facilities carry no quantities. */\nfunction defaultVolume(listing: MarketplaceListing): number {\n  let hash = 0\n  for (const char of listing.id) hash = (hash * 31 + char.charCodeAt(0)) | 0\n  return 600 + (Math.abs(hash) % 3400)\n}\n\nexport function MarketplaceMap({\n  marketplace,\n  detailEndpoint = \"/api/geo/listings\",\n  routeEndpoint = \"/api/geo/route\",\n  isochroneEndpoint = \"/api/geo/isochrone\",\n  onSaveProposal,\n  volumeForListing = defaultVolume,\n  initialViewState,\n  className,\n  overlay,\n  children,\n}: MarketplaceMapProps) {\n  const {\n    features,\n    mapRef,\n    viewport,\n    setViewport,\n    hoveredId,\n    setHoveredId,\n    selectedId,\n    select,\n    selectedListing,\n    exchange,\n    startExchange,\n    completeExchange,\n    clearExchange,\n    transferActive,\n    exchangeSource,\n    exchangeDestination,\n    visibleIds,\n    reachFilter,\n    setReachFilter,\n    viewportDirty,\n    searchThisArea,\n    loading,\n    initialLoading,\n    error,\n    refresh,\n    meta,\n    activeFilterCount,\n    clearFilters,\n    resetArea,\n  } = marketplace\n\n  const { resolvedTheme } = useTheme()\n  const [basemap, setBasemap] = React.useState<MapBasemap>(\"map\")\n  const [tool, setToolState] = React.useState<MapTool>(\"none\")\n  const [spider, setSpider] = React.useState<SpiderState | null>(null)\n\n  // --- Viewport tracking (moveend only — panning itself stays free) --------\n  const readViewport = React.useCallback(() => {\n    const map = mapRef.current?.getMap()\n    if (!map) return\n    // Observable camera state for tests and debugging.\n    map.getContainer().setAttribute(\"data-pitch\", String(Math.round(map.getPitch())))\n    const bounds = map.getBounds()\n    if (!bounds) return\n    const center = map.getCenter()\n    setViewport({\n      bbox: [\n        bounds.getWest(),\n        bounds.getSouth(),\n        bounds.getEast(),\n        bounds.getNorth(),\n      ],\n      zoom: map.getZoom(),\n      center: [center.lng, center.lat],\n    })\n  }, [mapRef, setViewport])\n\n  // --- Clustering -----------------------------------------------------------\n  const clusterBbox = React.useMemo<MarketplaceBbox | null>(\n    () => (viewport ? padBbox(viewport.bbox, 0.2) : null),\n    [viewport]\n  )\n  const { clusters, expansionZoom, leaves, maxZoom } = useListingsClusters({\n    features,\n    zoom: viewport?.zoom ?? UK_DEFAULT_VIEW.zoom,\n    bbox: clusterBbox,\n  })\n\n  // --- Isochrone tool ---------------------------------------------------------\n  const [isoOrigin, setIsoOrigin] = React.useState<[number, number] | null>(null)\n  const [isoSettings, setIsoSettings] = React.useState<IsochroneSettings>(\n    DEFAULT_ISOCHRONE_SETTINGS\n  )\n  const [isoFilterEnabled, setIsoFilterEnabled] = React.useState(false)\n  const iso = useIsochrone({\n    origin: tool === \"isochrone\" ? isoOrigin : null,\n    settings: isoSettings,\n    endpoint: isochroneEndpoint,\n  })\n\n  React.useEffect(() => {\n    setReachFilter(\n      tool === \"isochrone\" && isoFilterEnabled && iso.data\n        ? createIsochroneFilter(iso.data)\n        : null\n    )\n  }, [tool, isoFilterEnabled, iso.data, setReachFilter])\n\n  // --- Route tool ----------------------------------------------------------------\n  const [routeWaypoints, setRouteWaypoints] = React.useState<[number, number][]>([])\n  const [routeSettings, setRouteSettings] = React.useState<RouteToolSettings>(\n    DEFAULT_ROUTE_SETTINGS\n  )\n  const toolRoute = useRoute({\n    waypoints: tool === \"route\" ? routeWaypoints : [],\n    profile: routeSettings.profile,\n    endpoint: routeEndpoint,\n  })\n\n  const setTool = React.useCallback((next: MapTool) => {\n    setToolState((current) => {\n      const resolved = current === next ? \"none\" : next\n      if (resolved !== \"isochrone\") {\n        setIsoOrigin(null)\n        setIsoFilterEnabled(false)\n      }\n      if (resolved !== \"route\") setRouteWaypoints([])\n      return resolved\n    })\n  }, [])\n\n  // --- Transfer preview -------------------------------------------------------\n  const transferWaypoints = React.useMemo<[number, number][]>(\n    () =>\n      transferActive && exchangeSource && exchangeDestination\n        ? [\n            [exchangeSource.longitude, exchangeSource.latitude],\n            [exchangeDestination.longitude, exchangeDestination.latitude],\n          ]\n        : [],\n    [transferActive, exchangeSource, exchangeDestination]\n  )\n  const transferRoute = useRoute({\n    waypoints: transferWaypoints,\n    profile: \"driving-traffic\",\n    endpoint: routeEndpoint,\n    debounceMs: 0,\n  })\n  const routeCoordinates = React.useMemo(\n    () =>\n      (transferActive\n        ? ((transferRoute.route?.geometry.coordinates as [number, number][] | undefined) ??\n          transferWaypoints)\n        : []) as [number, number][],\n    [transferActive, transferRoute.route, transferWaypoints]\n  )\n  const { flyToRouteBounds } = useTransferCamera({\n    mapRef,\n    active: transferActive,\n    routeCoordinates,\n  })\n\n  const [volumeM3, setVolumeM3] = React.useState(500)\n  const sourceVolume = exchangeSource ? volumeForListing(exchangeSource) : 0\n  React.useEffect(() => {\n    if (transferActive && exchangeSource) {\n      setVolumeM3(Math.round(volumeForListing(exchangeSource) * 0.4))\n    }\n    // Reset the slider each time a transfer starts.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [transferActive, exchangeSource?.id])\n\n  React.useEffect(() => {\n    if (transferActive) {\n      setSpider(null)\n      setToolState(\"none\")\n    }\n  }, [transferActive])\n\n  const toTransferListing = React.useCallback(\n    (listing: MarketplaceListing): TransferListing => ({\n      id: listing.id,\n      title: listing.name,\n      location: [listing.subRegion ?? listing.region, listing.postcode]\n        .filter(Boolean)\n        .join(\", \"),\n      coordinates: [listing.longitude, listing.latitude],\n      availableVolumeM3: volumeForListing(listing),\n      quantityLabel: `Nominal capacity ${volumeForListing(listing).toLocaleString()} m³`,\n    }),\n    [volumeForListing]\n  )\n\n  // --- Interactions ------------------------------------------------------------\n  const handleClusterClick = React.useCallback(\n    (item: Extract<ListingsClusterItem, { kind: \"cluster\" }>) => {\n      const map = mapRef.current?.getMap()\n      if (!map) return\n      const targetZoom = expansionZoom(item.id)\n      if (targetZoom > maxZoom) {\n        // The cluster cannot split by zooming — spread it instead.\n        setSpider({\n          longitude: item.longitude,\n          latitude: item.latitude,\n          leaves: leaves(item.id, 16),\n        })\n        map.easeTo({\n          center: [item.longitude, item.latitude],\n          duration: 400,\n        })\n        return\n      }\n      setSpider(null)\n      map.easeTo({\n        center: [item.longitude, item.latitude],\n        zoom: Math.min(targetZoom + 0.5, 17),\n        duration: 500,\n      })\n    },\n    [mapRef, expansionZoom, maxZoom, leaves]\n  )\n\n  const handlePinClick = React.useCallback(\n    (feature: ListingFeature) => {\n      const [longitude, latitude] = feature.geometry.coordinates\n      if (tool === \"route\") {\n        setRouteWaypoints((previous) =>\n          previous.length >= 25 ? previous : [...previous, [longitude, latitude]]\n        )\n        return\n      }\n      if (tool === \"isochrone\") {\n        setIsoOrigin([longitude, latitude])\n        return\n      }\n      select(feature.properties.id)\n    },\n    [tool, select]\n  )\n\n  const handleMapClick = React.useCallback(\n    (event: MapMouseEvent) => {\n      const target = event.originalEvent.target as HTMLElement | null\n      if (target?.closest(\".mapboxgl-marker\")) return\n      if (spider) {\n        setSpider(null)\n        return\n      }\n      if (tool === \"isochrone\") {\n        setIsoOrigin([event.lngLat.lng, event.lngLat.lat])\n        return\n      }\n      if (tool === \"route\") {\n        setRouteWaypoints((previous) =>\n          previous.length >= 25\n            ? previous\n            : [...previous, [event.lngLat.lng, event.lngLat.lat]]\n        )\n        return\n      }\n      select(null)\n    },\n    [spider, tool, select]\n  )\n\n  const handleResetView = React.useCallback(() => {\n    clearExchange()\n    setTool(\"none\")\n    setSpider(null)\n    select(null)\n    resetArea()\n    mapRef.current?.getMap()?.easeTo({\n      center: [UK_DEFAULT_VIEW.longitude, UK_DEFAULT_VIEW.latitude],\n      zoom: UK_DEFAULT_VIEW.zoom,\n      pitch: 0,\n      bearing: 0,\n      duration: 600,\n    })\n  }, [clearExchange, setTool, select, resetArea, mapRef])\n\n  // Close the spider fan once its zoom context changes materially.\n  const spiderZoomRef = React.useRef<number | null>(null)\n  React.useEffect(() => {\n    if (spider) {\n      spiderZoomRef.current = viewport?.zoom ?? null\n    }\n  }, [spider])\n  React.useEffect(() => {\n    if (\n      spider &&\n      spiderZoomRef.current !== null &&\n      viewport &&\n      Math.abs(viewport.zoom - spiderZoomRef.current) > 0.5\n    ) {\n      setSpider(null)\n    }\n  }, [viewport, spider])\n\n  const showEmpty =\n    !initialLoading && !loading && meta?.matched === 0 && !error\n\n  return (\n    <MapView\n      ref={mapRef}\n      className={className}\n      initialViewState={initialViewState ?? { ...UK_DEFAULT_VIEW, zoom: 5.2 }}\n      onLoad={readViewport}\n      onMoveEnd={readViewport}\n      onClick={handleMapClick}\n      cursor={tool !== \"none\" ? \"crosshair\" : undefined}\n      mapStyle={resolveMapStyle(basemap, resolvedTheme)}\n      showZoom={false}\n      showStyleSwitcher={false}\n      overlay={\n        <>\n          {/* Toolbar — zoom/locate | basemap | tools | reset. */}\n          <MapToolbar\n            mapRef={mapRef}\n            basemap={basemap}\n            onBasemapChange={setBasemap}\n            tools={[\n              {\n                id: \"isochrone\",\n                label: \"Travel time\",\n                icon: <Target04 />,\n                active: tool === \"isochrone\",\n                onToggle: () => setTool(\"isochrone\"),\n              },\n              {\n                id: \"route\",\n                label: \"Measure route\",\n                icon: <Route />,\n                active: tool === \"route\",\n                onToggle: () => setTool(\"route\"),\n              },\n            ]}\n            onResetView={handleResetView}\n          />\n\n          {tool === \"isochrone\" ? (\n            <IsochroneInfoBar\n              hasOrigin={isoOrigin !== null}\n              data={iso.data}\n              loading={iso.loading}\n              error={iso.error}\n              settings={isoSettings}\n              onSettingsChange={setIsoSettings}\n              filterEnabled={isoFilterEnabled}\n              onFilterEnabledChange={setIsoFilterEnabled}\n              filterHint={\n                reachFilter ? `${marketplace.visibleListings.length} in reach` : undefined\n              }\n              onClear={() => {\n                setIsoOrigin(null)\n                setIsoFilterEnabled(false)\n              }}\n            />\n          ) : null}\n\n          {tool === \"route\" ? (\n            <RouteInfoBar\n              waypointCount={routeWaypoints.length}\n              route={toolRoute.route}\n              loading={toolRoute.loading}\n              error={toolRoute.error}\n              settings={routeSettings}\n              onSettingsChange={setRouteSettings}\n              onUndo={() => setRouteWaypoints((previous) => previous.slice(0, -1))}\n              onClear={() => setRouteWaypoints([])}\n            />\n          ) : null}\n\n          {/* Search this area — the viewport drifted from the committed query. */}\n          {viewportDirty && !transferActive ? (\n            <div\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 z-20 flex justify-center\",\n                tool === \"none\" ? \"top-16\" : \"top-30\"\n              )}\n            >\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                size=\"sm\"\n                data-testid=\"search-this-area\"\n                onClick={searchThisArea}\n                className=\"pointer-events-auto bg-background/90 shadow-lg backdrop-blur-md\"\n              >\n                <SearchLg />\n                Search this area\n                {loading ? <Spinner className=\"size-3.5\" /> : null}\n              </Button>\n            </div>\n          ) : null}\n\n          {/* Initial shimmer. */}\n          {initialLoading ? (\n            <div className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center\">\n              <div className=\"inline-flex items-center gap-2 rounded-lg border border-border/70 bg-background/90 px-3 py-2 shadow-lg backdrop-blur-md\">\n                <Spinner className=\"size-4\" />\n                <span className=\"text-xs font-medium text-muted-foreground\">\n                  Loading listings…\n                </span>\n              </div>\n            </div>\n          ) : null}\n\n          {/* Degrade-don't-blank error banner (above the bottom dock strip). */}\n          {error ? (\n            <div className=\"absolute bottom-16 left-1/2 z-20 -translate-x-1/2\">\n              <div className=\"flex items-center gap-2.5 rounded-lg border border-border/70 bg-background/95 py-1.5 pr-1.5 pl-3 shadow-lg backdrop-blur-md\">\n                <AlertTriangle className=\"size-4 shrink-0 text-text-warning-primary\" />\n                <span className=\"text-xs font-medium text-secondary-foreground\">\n                  Listings unavailable — showing the last results.\n                </span>\n                <Button type=\"button\" variant=\"secondary\" size=\"xs\" onClick={refresh}>\n                  <RefreshCcw01 />\n                  Retry\n                </Button>\n              </div>\n            </div>\n          ) : null}\n\n          {/* Empty state. */}\n          {showEmpty ? (\n            <div className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-4\">\n              <div className=\"pointer-events-auto flex max-w-xs flex-col items-center gap-2.5 rounded-xl border border-border/70 bg-background/95 p-5 text-center shadow-lg backdrop-blur-md\">\n                <p className=\"text-sm font-semibold text-foreground\">\n                  No listings match your filters\n                </p>\n                <p className=\"text-xs text-muted-foreground\">\n                  Try widening the area or removing some filters.\n                </p>\n                {activeFilterCount > 0 ? (\n                  <Button type=\"button\" variant=\"secondary\" size=\"xs\" onClick={clearFilters}>\n                    Clear all filters\n                  </Button>\n                ) : null}\n              </div>\n            </div>\n          ) : null}\n\n          {/* Transfer dock. */}\n          {transferActive && exchangeSource && exchangeDestination ? (\n            <TransferPreviewDock\n              source={toTransferListing(exchangeSource)}\n              destination={toTransferListing(exchangeDestination)}\n              route={transferRoute.route}\n              loading={transferRoute.loading}\n              volumeM3={Math.min(volumeM3, sourceVolume)}\n              onVolumeChange={setVolumeM3}\n              distanceUnit={routeSettings.distanceUnit}\n              onResetView={flyToRouteBounds}\n              onExit={clearExchange}\n              onSave={onSaveProposal}\n            />\n          ) : null}\n\n          {overlay}\n        </>\n      }\n    >\n      {/* Sources stay mounted — mapbox-gl v3 dislikes source removal. */}\n      <IsochroneLayers data={tool === \"isochrone\" ? iso.data : null} />\n      <RouteLineLayer\n        geometry={\n          transferActive && transferRoute.route\n            ? transferRoute.route.geometry\n            : tool === \"route\" && toolRoute.route\n              ? toolRoute.route.geometry\n              : null\n        }\n      />\n\n      {!transferActive ? (\n        <MarkerLayer\n          items={clusters}\n          hoveredId={hoveredId}\n          selectedId={selectedId}\n          sourceId={exchange.sourceId}\n          destinationId={exchange.destinationId}\n          visibleIds={reachFilter ? visibleIds : null}\n          onClusterClick={handleClusterClick}\n          onPinClick={handlePinClick}\n          onHover={setHoveredId}\n        />\n      ) : null}\n\n      {spider && !transferActive ? (\n        <SpiderFan\n          spider={spider}\n          hoveredId={hoveredId}\n          selectedId={selectedId}\n          onPinClick={handlePinClick}\n          onHover={setHoveredId}\n          onClose={() => setSpider(null)}\n        />\n      ) : null}\n\n      {tool === \"isochrone\" && isoOrigin ? (\n        <IsochroneOriginMarker\n          longitude={isoOrigin[0]}\n          latitude={isoOrigin[1]}\n          draggable\n          onDragEnd={setIsoOrigin}\n        />\n      ) : null}\n\n      {tool === \"route\" ? (\n        <RouteWaypointMarkers\n          waypoints={routeWaypoints}\n          onMoveWaypoint={(index, coords) =>\n            setRouteWaypoints((previous) =>\n              previous.map((waypoint, i) => (i === index ? coords : waypoint))\n            )\n          }\n          onRemoveWaypoint={(index) =>\n            setRouteWaypoints((previous) => previous.filter((_, i) => i !== index))\n          }\n        />\n      ) : null}\n\n      {transferActive && exchangeSource && exchangeDestination ? (\n        <TransferWaypointPins\n          source={toTransferListing(exchangeSource)}\n          destination={toTransferListing(exchangeDestination)}\n        />\n      ) : null}\n\n      {selectedListing && !transferActive ? (\n        <ListingPopup\n          listing={selectedListing}\n          onClose={() => select(null)}\n          detailEndpoint={detailEndpoint}\n          exchangeSourceId={exchange.sourceId}\n          onStartExchange={startExchange}\n          onCompleteExchange={completeExchange}\n          onCancelExchange={clearExchange}\n        />\n      ) : null}\n\n      {children}\n    </MapView>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/marketplace/marketplace-map.tsx"
    }
  ],
  "type": "registry:block"
}