{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "isochrone-tool",
  "title": "Isochrone Tool",
  "description": "Travel-time isochrone overlays for map-view: a settings popover (profile, max time, interval), navy-to-blue contour fills, a legend info bar and point-in-polygon reach filtering, fetched via the nx-geo isochrone contract.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/geo-api",
    "@nx-ui/label",
    "@nx-ui/popover",
    "@nx-ui/separator",
    "@nx-ui/slider",
    "@nx-ui/spinner",
    "@nx-ui/switch",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/maps/isochrone-tool.tsx",
      "content": "\"use client\"\n\n// @registry-dep: geo-api\n\nimport * as React from \"react\"\nimport { Car01, Car02, Sliders04, Target04, User01, XClose } from \"@untitledui/icons\"\nimport type { FeatureCollection, MultiPolygon, Polygon, Position } from \"geojson\"\nimport { Layer, Marker, Source } from \"react-map-gl/mapbox\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverHeader,\n  PopoverTitle,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { Switch } from \"@/components/ui/switch\"\n\n/**\n * isochrone-tool — travel-time overlays for nx-ui maps.\n *\n * The marketplace map's isochrone tool, rebuilt on the nx-geo contract. Parts:\n *\n *   • useIsochrone() — fetches contours from the mock nx-geo endpoint\n *     (`POST /api/geo/isochrone`, body `{ origin: { lat, lon }, profile,\n *     contoursMinutes }`, response `{ success, data: FeatureCollection }` with\n *     `properties.minutes` per polygon), aborting stale requests.\n *   • <IsochroneLayers> — the navy → light-blue fill + outline layers.\n *     Render INSIDE the map (a MapView child).\n *   • <IsochroneOriginMarker> — pulsing blue origin dot (MapView child).\n *   • <IsochroneInfoBar> — floating glass legend/status bar with the filter\n *     toggle, settings popover and clear control. Render in MapView's\n *     `overlay` slot.\n *   • createIsochroneFilter() — turns the largest contour into a\n *     point-in-polygon predicate, for filtering listings/sites by reach.\n *\n * Composition (see the maps/isochrone showcase page for the full wiring):\n *\n *   const { data, loading, error } = useIsochrone({ origin, settings })\n *   <MapView onClick={setOriginFromClick} overlay={<IsochroneInfoBar …/>}>\n *     {data && <IsochroneLayers data={data} />}\n *     {origin && <IsochroneOriginMarker longitude={…} latitude={…} />}\n *   </MapView>\n */\n\nexport type IsochroneProfile = \"driving\" | \"driving-traffic\" | \"walking\"\n\nexport interface IsochroneSettings {\n  profile: IsochroneProfile\n  /** Largest contour, minutes. Slider range 15–60 (the Mapbox ceiling). */\n  maxMinutes: number\n  /** Contour interval, minutes. Slider range 5–30. */\n  stepMinutes: number\n}\n\n/** Matches the marketplace defaults: driving, contours 15/30/45/60. */\nexport const DEFAULT_ISOCHRONE_SETTINGS: IsochroneSettings = {\n  profile: \"driving\",\n  maxMinutes: 60,\n  stepMinutes: 15,\n}\n\n/** The API (like Mapbox's) draws at most 4 contours per request. */\nexport const MAX_CONTOURS = 4\n\n/** Contour minutes for a settings combination, capped at MAX_CONTOURS.\n * `truncated` flags combinations that would have produced more. */\nexport function calculateContours(settings: IsochroneSettings): {\n  contours: number[]\n  truncated: boolean\n} {\n  const all: number[] = []\n  for (\n    let minutes = settings.stepMinutes;\n    minutes <= settings.maxMinutes;\n    minutes += settings.stepMinutes\n  ) {\n    all.push(minutes)\n  }\n  return { contours: all.slice(0, MAX_CONTOURS), truncated: all.length > MAX_CONTOURS }\n}\n\n/** Navy → light-blue ramp, darkest for the closest contour (nexus4 palette). */\nexport const ISOCHRONE_COLORS = [\n  { fill: \"#172554\", outline: \"#1e3a8a\", opacity: 0.8 },\n  { fill: \"#1e40af\", outline: \"#1e3a8a\", opacity: 0.6 },\n  { fill: \"#60a5fa\", outline: \"#3b82f6\", opacity: 0.4 },\n  { fill: \"#93c5fd\", outline: \"#60a5fa\", opacity: 0.35 },\n] as const\n\nexport type IsochroneData = FeatureCollection\n\nconst PROFILE_OPTIONS: {\n  id: IsochroneProfile\n  label: string\n  icon: React.ReactNode\n}[] = [\n  { id: \"driving\", label: \"Driving\", icon: <Car01 /> },\n  { id: \"driving-traffic\", label: \"Traffic\", icon: <Car02 /> },\n  { id: \"walking\", label: \"Walking\", icon: <User01 /> },\n]\n\n// ---------------------------------------------------------------------------\n// Fetching\n// ---------------------------------------------------------------------------\n\nexport interface UseIsochroneOptions {\n  /** Travel-time origin as [longitude, latitude]; null clears the overlay. */\n  origin: [number, number] | null\n  settings: IsochroneSettings\n  /** nx-geo-shaped endpoint. Default: the local mock. */\n  endpoint?: string\n  /** Fired whenever fresh contours arrive (or the overlay clears). */\n  onData?: (data: IsochroneData | null) => void\n}\n\nexport function useIsochrone({\n  origin,\n  settings,\n  endpoint = \"/api/geo/isochrone\",\n  onData,\n}: UseIsochroneOptions) {\n  const [data, setData] = React.useState<IsochroneData | null>(null)\n  const [loading, setLoading] = React.useState(false)\n  const [error, setError] = React.useState<string | null>(null)\n\n  const onDataRef = React.useRef(onData)\n  onDataRef.current = onData\n\n  const { contours } = calculateContours(settings)\n  const requestKey = origin\n    ? `${origin[0].toFixed(5)},${origin[1].toFixed(5)}|${settings.profile}|${contours.join(\",\")}`\n    : null\n\n  React.useEffect(() => {\n    if (!requestKey || !origin) {\n      setData(null)\n      setLoading(false)\n      setError(null)\n      onDataRef.current?.(null)\n      return\n    }\n    const controller = new AbortController()\n    setLoading(true)\n    setError(null)\n    fetch(endpoint, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        origin: { lat: origin[1], lon: origin[0] },\n        profile: settings.profile,\n        contoursMinutes: contours,\n      }),\n      signal: controller.signal,\n    })\n      .then((response) => response.json())\n      .then((json: { success: boolean; data?: IsochroneData; error?: string }) => {\n        if (controller.signal.aborted) return\n        if (json.success && json.data) {\n          setData(json.data)\n          onDataRef.current?.(json.data)\n        } else {\n          setError(json.error ?? \"Travel times unavailable.\")\n        }\n        setLoading(false)\n      })\n      .catch((cause: unknown) => {\n        if (controller.signal.aborted) return\n        setError(cause instanceof Error ? cause.message : \"Travel times unavailable.\")\n        setLoading(false)\n      })\n    return () => controller.abort()\n    // requestKey encodes origin + profile + contours.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [requestKey, endpoint])\n\n  const clear = React.useCallback(() => {\n    setData(null)\n    setError(null)\n    onDataRef.current?.(null)\n  }, [])\n\n  return { data, loading, error, contours, clear }\n}\n\n// ---------------------------------------------------------------------------\n// Map layers (render inside the map)\n// ---------------------------------------------------------------------------\n\n/** Minutes present in the data, ascending — drives colours and the legend. */\nfunction contourMinutesOf(data: IsochroneData): number[] {\n  const minutes = new Set<number>()\n  for (const feature of data.features) {\n    const value = (feature.properties as { minutes?: number } | null)?.minutes\n    if (typeof value === \"number\") minutes.add(value)\n  }\n  return [...minutes].sort((a, b) => a - b)\n}\n\nconst EMPTY_COLLECTION: FeatureCollection = {\n  type: \"FeatureCollection\",\n  features: [],\n}\n\nexport interface IsochroneLayersProps {\n  /** Contours to draw; null/empty renders nothing but KEEPS the source\n   * mounted — removing a live Mapbox source mid-session trips a terrain\n   * update crash in mapbox-gl v3, so keep this component mounted and pass\n   * null instead of unmounting it. */\n  data: IsochroneData | null\n  /** Override the fill ramp (one entry per contour, closest first). */\n  colors?: readonly { fill: string; outline: string; opacity: number }[]\n}\n\n/** One fill + outline layer per contour, closest painted first so the ramp\n * stacks darkest at the origin — exactly the marketplace treatment. */\nexport function IsochroneLayers({\n  data,\n  colors = ISOCHRONE_COLORS,\n}: IsochroneLayersProps) {\n  const minutes = data ? contourMinutesOf(data) : []\n  // NOTE: the Layers must be DIRECT children of Source (no fragments) —\n  // react-map-gl clones each child to inject the `source` prop.\n  return (\n    <Source\n      id=\"isochrone-source\"\n      type=\"geojson\"\n      data={data ?? EMPTY_COLLECTION}\n    >\n      {minutes.flatMap((contour, index) => {\n        const colour = colors[Math.min(index, colors.length - 1)]\n        return [\n          <Layer\n            key={`fill-${contour}`}\n            id={`isochrone-fill-${contour}`}\n            type=\"fill\"\n            filter={[\"==\", [\"get\", \"minutes\"], contour]}\n            paint={{\n              \"fill-color\": colour.fill,\n              \"fill-opacity\": colour.opacity,\n            }}\n          />,\n          <Layer\n            key={`outline-${contour}`}\n            id={`isochrone-outline-${contour}`}\n            type=\"line\"\n            filter={[\"==\", [\"get\", \"minutes\"], contour]}\n            paint={{\n              \"line-color\": colour.outline,\n              \"line-width\": 1,\n              \"line-opacity\": 0.7,\n            }}\n          />,\n        ]\n      })}\n    </Source>\n  )\n}\n\nexport interface IsochroneOriginMarkerProps {\n  longitude: number\n  latitude: number\n  draggable?: boolean\n  onDragEnd?: (coords: [number, number]) => void\n}\n\n/** The travel-time origin: a blue dot with a pulsing halo (reduced-motion\n * aware). Render inside the map. */\nexport function IsochroneOriginMarker({\n  longitude,\n  latitude,\n  draggable,\n  onDragEnd,\n}: IsochroneOriginMarkerProps) {\n  return (\n    <Marker\n      longitude={longitude}\n      latitude={latitude}\n      anchor=\"center\"\n      draggable={draggable}\n      onDragEnd={(event) => onDragEnd?.([event.lngLat.lng, event.lngLat.lat])}\n    >\n      <span className=\"relative flex size-5\" aria-hidden>\n        <span className=\"absolute inline-flex size-full rounded-full bg-blue-500 opacity-40 motion-safe:animate-ping\" />\n        <span className=\"relative inline-flex size-5 rounded-full border-2 border-white bg-blue-500 shadow-lg\" />\n      </span>\n    </Marker>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Settings popover\n// ---------------------------------------------------------------------------\n\nexport interface IsochroneSettingsPopoverProps {\n  settings: IsochroneSettings\n  onSettingsChange: (settings: IsochroneSettings) => void\n}\n\n/** Profile buttons + max-time and interval sliders with a live contour\n * preview. Changes apply immediately (the fetch hook re-runs). */\nexport function IsochroneSettingsPopover({\n  settings,\n  onSettingsChange,\n}: IsochroneSettingsPopoverProps) {\n  const { contours, truncated } = calculateContours(settings)\n  return (\n    <Popover>\n      <PopoverTrigger\n        render={\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-xs\"\n            aria-label=\"Travel-time settings\"\n            className=\"text-fg-quaternary hover:text-foreground\"\n          >\n            <Sliders04 />\n          </Button>\n        }\n      />\n      <PopoverContent align=\"center\" className=\"w-80\">\n        <PopoverHeader>\n          <PopoverTitle>Travel time</PopoverTitle>\n        </PopoverHeader>\n\n        <div className=\"flex flex-col gap-2\">\n          <Label className=\"text-xs text-muted-foreground\">Mode</Label>\n          <div className=\"grid grid-cols-3 gap-1.5\">\n            {PROFILE_OPTIONS.map((option) => (\n              <Button\n                key={option.id}\n                type=\"button\"\n                size=\"xs\"\n                variant={settings.profile === option.id ? \"default\" : \"secondary\"}\n                aria-pressed={settings.profile === option.id}\n                onClick={() =>\n                  onSettingsChange({ ...settings, profile: option.id })\n                }\n              >\n                {option.icon}\n                {option.label}\n              </Button>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-center justify-between\">\n            <Label className=\"text-xs text-muted-foreground\">Maximum time</Label>\n            <span className=\"text-xs font-semibold tabular-nums\">\n              {settings.maxMinutes} min\n            </span>\n          </div>\n          <Slider\n            value={[settings.maxMinutes]}\n            min={15}\n            max={60}\n            step={5}\n            onValueChange={(value) =>\n              onSettingsChange({\n                ...settings,\n                maxMinutes: Array.isArray(value) ? (value[0] ?? 60) : value,\n              })\n            }\n            aria-label=\"Maximum time\"\n          />\n        </div>\n\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-center justify-between\">\n            <Label className=\"text-xs text-muted-foreground\">Interval</Label>\n            <span className=\"text-xs font-semibold tabular-nums\">\n              every {settings.stepMinutes} min\n            </span>\n          </div>\n          <Slider\n            value={[settings.stepMinutes]}\n            min={5}\n            max={30}\n            step={5}\n            onValueChange={(value) =>\n              onSettingsChange({\n                ...settings,\n                stepMinutes: Array.isArray(value) ? (value[0] ?? 15) : value,\n              })\n            }\n            aria-label=\"Interval\"\n          />\n        </div>\n\n        <div className=\"flex flex-col gap-1.5\">\n          <Label className=\"text-xs text-muted-foreground\">Contours</Label>\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            {contours.map((minutes, index) => (\n              <span\n                key={minutes}\n                className=\"inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium\"\n              >\n                <span\n                  aria-hidden\n                  className=\"size-2 rounded-full\"\n                  style={{\n                    backgroundColor:\n                      ISOCHRONE_COLORS[Math.min(index, ISOCHRONE_COLORS.length - 1)]\n                        .fill,\n                  }}\n                />\n                {minutes} min\n              </span>\n            ))}\n          </div>\n          {truncated ? (\n            <p className=\"text-xs text-text-warning-primary\">\n              Only {MAX_CONTOURS} contours shown (API limit).\n            </p>\n          ) : null}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Info bar (render in MapView's `overlay` slot)\n// ---------------------------------------------------------------------------\n\nexport interface IsochroneInfoBarProps {\n  /** Whether an origin is set (drives the instruction copy). */\n  hasOrigin: boolean\n  data: IsochroneData | null\n  loading?: boolean\n  error?: string | null\n  settings: IsochroneSettings\n  onSettingsChange: (settings: IsochroneSettings) => void\n  /** Point-in-polygon filter toggle — omit the handler to hide the control. */\n  filterEnabled?: boolean\n  onFilterEnabledChange?: (enabled: boolean) => void\n  /** Short hint beside the filter toggle, e.g. \"9 of 15 sites\". */\n  filterHint?: React.ReactNode\n  /** Clear/cancel — clears the origin and overlay. */\n  onClear?: () => void\n  className?: string\n}\n\n/** The floating status/legend pill: instructions → spinner → contour legend,\n * plus the reach filter toggle, settings popover and a clear control. */\nexport function IsochroneInfoBar({\n  hasOrigin,\n  data,\n  loading,\n  error,\n  settings,\n  onSettingsChange,\n  filterEnabled,\n  onFilterEnabledChange,\n  filterHint,\n  onClear,\n  className,\n}: IsochroneInfoBarProps) {\n  const minutes = data ? contourMinutesOf(data) : []\n  const showFilter = onFilterEnabledChange !== undefined\n\n  return (\n    <div\n      data-slot=\"isochrone-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      <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-brand-500/25 backdrop-blur-md dark:ring-brand-400/25\">\n        {!hasOrigin ? (\n          <span className=\"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n            <Target04 className=\"size-3.5\" />\n            Click the map to set a travel-time origin\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 travel times…\n          </span>\n        ) : error ? (\n          <span className=\"text-xs font-medium text-destructive\">{error}</span>\n        ) : (\n          <span className=\"inline-flex items-center gap-2\">\n            {minutes.map((contour, index) => (\n              <span\n                key={contour}\n                className=\"inline-flex items-center gap-1 text-xs font-medium text-secondary-foreground\"\n              >\n                <span\n                  aria-hidden\n                  className=\"size-2.5 rounded-full ring-1 ring-black/10 ring-inset\"\n                  style={{\n                    backgroundColor:\n                      ISOCHRONE_COLORS[Math.min(index, ISOCHRONE_COLORS.length - 1)]\n                        .fill,\n                  }}\n                />\n                {contour} min\n              </span>\n            ))}\n          </span>\n        )}\n\n        {showFilter && hasOrigin && data ? (\n          <>\n            <Separator orientation=\"vertical\" className=\"h-4!\" />\n            <label className=\"inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-secondary-foreground\">\n              <Switch\n                size=\"sm\"\n                checked={filterEnabled ?? false}\n                onCheckedChange={onFilterEnabledChange}\n              />\n              Filter by reach\n              {filterHint ? (\n                <span className=\"text-muted-foreground\">· {filterHint}</span>\n              ) : null}\n            </label>\n          </>\n        ) : null}\n\n        <Separator orientation=\"vertical\" className=\"h-4!\" />\n        <span className=\"inline-flex items-center gap-0.5\">\n          <IsochroneSettingsPopover\n            settings={settings}\n            onSettingsChange={onSettingsChange}\n          />\n          {onClear && hasOrigin ? (\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"icon-xs\"\n              aria-label=\"Clear travel times\"\n              onClick={onClear}\n              className=\"text-fg-quaternary hover:text-foreground\"\n            >\n              <XClose />\n            </Button>\n          ) : null}\n        </span>\n      </div>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Point-in-polygon filter (the marketplace \"filter markers by travel time\")\n// ---------------------------------------------------------------------------\n\n/** Ray-casting test for a single linear ring. */\nfunction pointInRing(lng: number, lat: number, ring: Position[]): boolean {\n  let inside = false\n  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n    const [xi, yi] = ring[i]\n    const [xj, yj] = ring[j]\n    const intersects =\n      yi > lat !== yj > lat &&\n      lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi\n    if (intersects) inside = !inside\n  }\n  return inside\n}\n\nfunction pointInPolygon(lng: number, lat: number, polygon: Position[][]): boolean {\n  if (polygon.length === 0 || !pointInRing(lng, lat, polygon[0])) return false\n  // Holes: inside a hole means outside the polygon.\n  for (let i = 1; i < polygon.length; i += 1) {\n    if (pointInRing(lng, lat, polygon[i])) return false\n  }\n  return true\n}\n\n/**\n * Build a `(lng, lat) => boolean` predicate from the LARGEST contour in an\n * isochrone result — anything the predicate accepts is reachable within the\n * maximum travel time. Returns null when there is nothing to filter by.\n */\nexport function createIsochroneFilter(\n  data: IsochroneData | null\n): ((lng: number, lat: number) => boolean) | null {\n  if (!data || data.features.length === 0) return null\n  let largest: (typeof data.features)[number] | null = null\n  let largestMinutes = -Infinity\n  for (const feature of data.features) {\n    const minutes = (feature.properties as { minutes?: number } | null)?.minutes\n    if (typeof minutes === \"number\" && minutes > largestMinutes) {\n      largest = feature\n      largestMinutes = minutes\n    }\n  }\n  if (!largest) return null\n  const geometry = largest.geometry as Polygon | MultiPolygon\n  if (geometry.type === \"Polygon\") {\n    const coords = geometry.coordinates\n    return (lng, lat) => pointInPolygon(lng, lat, coords)\n  }\n  if (geometry.type === \"MultiPolygon\") {\n    const coords = geometry.coordinates\n    return (lng, lat) => coords.some((polygon) => pointInPolygon(lng, lat, polygon))\n  }\n  return null\n}\n",
      "type": "registry:component",
      "target": "components/blocks/maps/isochrone-tool.tsx"
    }
  ],
  "type": "registry:block"
}