{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "route-flow-map",
  "title": "Route Flow Map",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/route-flow-map/RouteFlowMap.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface RouteHub {\n    id: string\n    name: string\n    city?: string\n    lat: number\n    lng: number\n    status?: 'optimal' | 'busy' | 'delayed' | string\n    latency?: string | number\n    color?: string\n  }\n\n  export interface FlightRoute {\n    id: string\n    from: string\n    to: string\n    callsign?: string\n    aircraft?: string\n    speed?: string\n    altitude?: string\n    progress?: number\n    eta?: string\n    status?: 'en-route' | 'scheduled' | 'approaching' | 'diverted' | string\n    color?: string\n    vehicleType?: 'plane' | 'ship' | 'packet' | 'pulse' | 'dot'\n    duration?: number\n    curvature?: number\n  }\n\n  export interface RouteFlowMapProps extends HTMLAttributes<HTMLDivElement> {\n    hubs?: RouteHub[]\n    routes?: FlightRoute[]\n    selectedRoute?: string\n    /** React-parity selection callback (fires alongside the `bind:selectedRoute` update). */\n    onSelectedRouteChange?: (id: string | undefined) => void\n    showHubLabels?: boolean\n    showGraticule?: boolean\n    height?: number | string\n    interactive?: boolean\n    ariaLabel?: string\n    projection?: 'globe' | 'mercator'\n    onRouteSelect?: (route: FlightRoute) => void\n    onHubClick?: (hub: RouteHub) => void\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { Globe, Plane } from '@lucide/svelte'\n  import { Map, MapMarker, MapSource, MapLayer } from '$lib/components/ui/map'\n  import { cn } from '$lib/utils'\n  import { chartAccentColor } from './chart-theme'\n\n  let {\n    class: className,\n    hubs = [],\n    routes = [],\n    selectedRoute = $bindable<string | undefined>(),\n    onSelectedRouteChange,\n    showHubLabels = true,\n    showGraticule = true,\n    height = 480,\n    interactive = true,\n    ariaLabel = 'Global Route and Flight Flow Map',\n    projection = 'globe',\n    onRouteSelect,\n    onHubClick,\n    ref = $bindable(null),\n    ...restProps\n  }: RouteFlowMapProps = $props()\n\n  let activeRouteId = $state(selectedRoute || '')\n  let currentProjection = $state<'globe' | 'mercator'>(projection)\n  let hoveredHub = $state<RouteHub | null>(null)\n  let themeTick = $state(0)\n\n  // Sync controlled selection from the parent.\n  $effect(() => {\n    activeRouteId = selectedRoute ?? ''\n  })\n\n  $effect(() => {\n    if (projection !== currentProjection) currentProjection = projection\n  })\n\n  // Re-resolve the accent color when the theme flips (shadcn dark-mode pivot).\n  $effect(() => {\n    if (typeof window === 'undefined') return\n    const raf = requestAnimationFrame(() => themeTick++)\n    const mo = new MutationObserver(() => themeTick++)\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })\n    return () => {\n      cancelAnimationFrame(raf)\n      mo.disconnect()\n    }\n  })\n\n  const hubMap = $derived.by(() => {\n    const map = new globalThis.Map<string, RouteHub>()\n    for (const h of hubs) map.set(h.id, h)\n    return map\n  })\n\n  const routesGeoJson = $derived.by(() => {\n    if (!routes || !routes.length) return null\n    return {\n      type: 'FeatureCollection',\n      features: routes\n        .map((r) => {\n          const fromHub = hubMap.get(r.from)\n          const toHub = hubMap.get(r.to)\n          if (!fromHub || !toHub) return null\n\n          const midLng = (fromHub.lng + toHub.lng) / 2\n          const midLat = (fromHub.lat + toHub.lat) / 2 + 10\n\n          return {\n            type: 'Feature',\n            id: r.id,\n            properties: {\n              id: r.id,\n              color: r.color || 'rgba(56, 189, 248, 0.8)',\n              selected: activeRouteId === r.id,\n            },\n            geometry: {\n              type: 'LineString',\n              coordinates: [\n                [fromHub.lng, fromHub.lat],\n                [midLng, midLat],\n                [toHub.lng, toHub.lat],\n              ],\n            },\n          }\n        })\n        .filter(Boolean),\n    }\n  })\n\n  const routeLinePaint = $derived.by(() => {\n    void themeTick\n    return {\n      'line-color': ['case', ['==', ['get', 'id'], activeRouteId], chartAccentColor(), ['get', 'color']],\n      'line-width': ['case', ['==', ['get', 'id'], activeRouteId], 3, 1.5],\n      'line-dasharray': [2, 2],\n    }\n  })\n\n  const activeRoute = $derived(routes.find((r) => r.id === activeRouteId))\n\n  function selectRoute(r: FlightRoute) {\n    if (!interactive) return\n    activeRouteId = r.id\n    selectedRoute = r.id\n    onSelectedRouteChange?.(r.id)\n    onRouteSelect?.(r)\n  }\n\n  function clearSelection() {\n    activeRouteId = ''\n    selectedRoute = undefined\n    onSelectedRouteChange?.(undefined)\n  }\n\n  function getVehiclePosition(r: FlightRoute): [number, number] | null {\n    const fromHub = hubMap.get(r.from)\n    const toHub = hubMap.get(r.to)\n    if (!fromHub || !toHub) return null\n    const progress = (r.progress ?? 50) / 100\n    const lng = fromHub.lng + (toHub.lng - fromHub.lng) * progress\n    const lat = fromHub.lat + (toHub.lat - fromHub.lat) * progress + Math.sin(progress * Math.PI) * 10\n    return [lng, lat]\n  }\n\n  function toggleProjection() {\n    currentProjection = currentProjection === 'globe' ? 'mercator' : 'globe'\n  }\n\n  const heightStyle = $derived(\n    typeof height === 'number' ? `${height}px` : /^\\d+$/.test(String(height)) ? `${height}px` : height,\n  )\n\n  // Reference otherwise-unused parity props so intent stays explicit.\n  void showGraticule\n  void ariaLabel\n</script>\n\n<div\n  bind:this={ref}\n  class={cn('border-border bg-card group relative w-full overflow-hidden rounded-xl border shadow-xs', className)}\n  style=\"height: {heightStyle};\"\n  {...restProps}\n>\n  <Map variant=\"dark\" projection={currentProjection} center={[10, 25]} zoom={1.6} class=\"size-full\">\n    <!-- Great Circle Route Arcs -->\n    {#if routesGeoJson}\n      <MapSource id=\"flight-routes-source\" type=\"geojson\" data={routesGeoJson}>\n        <MapLayer id=\"flight-routes-layer\" type=\"line\" paint={routeLinePaint} />\n      </MapSource>\n    {/if}\n\n    <!-- Hub Markers -->\n    {#each hubs as hub (hub.id)}\n      <MapMarker lngLat={[hub.lng, hub.lat]} anchor=\"center\" class=\"cursor-pointer select-none\">\n        <div\n          class=\"relative flex flex-col items-center\"\n          onmouseenter={() => (hoveredHub = hub)}\n          onmouseleave={() => (hoveredHub = null)}\n          onclick={() => onHubClick?.(hub)}\n          onkeydown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') onHubClick?.(hub)\n          }}\n          role=\"button\"\n          tabindex=\"0\"\n        >\n          <span\n            class=\"size-2 rounded-full shadow-xs ring-2\"\n            style=\"background-color: {hub.color || 'oklch(0.65 0.20 145)'}; box-shadow: 0 0 8px {hub.color ||\n              'oklch(0.65 0.20 145)'};\"\n          ></span>\n          {#if showHubLabels}\n            <span class=\"border-border/80 bg-background/85 py-0.2 text-foreground mt-1 rounded border px-1 font-mono text-[9px] font-bold shadow-xs backdrop-blur-xs\">\n              {hub.id}\n            </span>\n          {/if}\n        </div>\n      </MapMarker>\n    {/each}\n\n    <!-- Aircraft / Cargo Vehicles in Flight -->\n    {#each routes as r (r.id)}\n      {@const vehiclePos = getVehiclePosition(r)}\n      {#if vehiclePos}\n        <MapMarker\n          lngLat={vehiclePos}\n          anchor=\"center\"\n          class={cn('cursor-pointer transition-transform select-none', activeRouteId === r.id ? 'z-30 scale-125' : 'z-20 hover:scale-110')}\n        >\n          <div\n            class=\"flex items-center gap-1 rounded-full border border-sky-400/40 bg-sky-950/80 px-1.5 py-0.5 shadow-md backdrop-blur-xs\"\n            onclick={() => selectRoute(r)}\n            onkeydown={(e) => {\n              if (e.key === 'Enter' || e.key === ' ') selectRoute(r)\n            }}\n            role=\"button\"\n            tabindex=\"0\"\n          >\n            <Plane class=\"size-3 rotate-45 text-sky-400\" />\n            <span class=\"font-mono text-[9px] font-semibold text-sky-200\">\n              {r.callsign || r.id}\n            </span>\n          </div>\n        </MapMarker>\n      {/if}\n    {/each}\n  </Map>\n\n  <!-- Top-Right Controls -->\n  <div class=\"border-border/70 bg-card/85 absolute top-3 right-3 z-10 flex items-center gap-1 rounded-lg border p-1 shadow-xs backdrop-blur-md\">\n    <button\n      type=\"button\"\n      class=\"text-muted-foreground hover:bg-muted hover:text-foreground flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition\"\n      onclick={toggleProjection}\n    >\n      <Globe class=\"size-3.5\" />\n      <span class=\"capitalize\">{currentProjection}</span>\n    </button>\n  </div>\n\n  <!-- Active Flight Route HUD Card -->\n  {#if activeRoute}\n    <div class=\"border-border/80 bg-card/95 animate-in fade-in slide-in-from-bottom-2 absolute bottom-3 left-3 z-10 max-w-sm rounded-xl border p-3.5 shadow-lg backdrop-blur-md duration-150\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div>\n          <div class=\"flex items-center gap-2\">\n            <span class=\"size-2 animate-pulse rounded-full bg-sky-400\"></span>\n            <span class=\"text-foreground font-mono text-xs font-semibold tracking-wider uppercase\">\n              {activeRoute.callsign || activeRoute.id}\n            </span>\n            <span class=\"py-0.2 rounded bg-sky-500/10 px-1.5 font-mono text-[10px] text-sky-400 capitalize\">\n              {activeRoute.status || 'En-route'}\n            </span>\n          </div>\n          <div class=\"text-muted-foreground mt-1 flex items-center gap-2 font-mono text-xs\">\n            <span>{activeRoute.from}</span>\n            <span>→</span>\n            <span>{activeRoute.to}</span>\n            {#if activeRoute.aircraft}\n              <span class=\"text-[10px]\">({activeRoute.aircraft})</span>\n            {/if}\n          </div>\n        </div>\n        <button type=\"button\" class=\"text-muted-foreground hover:text-foreground text-xs\" onclick={clearSelection}>\n          ✕\n        </button>\n      </div>\n\n      <div class=\"border-border/60 mt-3 grid grid-cols-3 gap-2 border-t pt-2 font-mono text-[11px]\">\n        <div>\n          <span class=\"text-muted-foreground block text-[9px] uppercase\">Speed</span>\n          <span class=\"text-foreground font-semibold\">{activeRoute.speed || '480 kts'}</span>\n        </div>\n        <div>\n          <span class=\"text-muted-foreground block text-[9px] uppercase\">Altitude</span>\n          <span class=\"text-foreground font-semibold\">{activeRoute.altitude || 'FL360'}</span>\n        </div>\n        <div>\n          <span class=\"text-muted-foreground block text-[9px] uppercase\">ETA</span>\n          <span class=\"text-foreground font-semibold\">{activeRoute.eta || '02h 15m'}</span>\n        </div>\n      </div>\n    </div>\n  {/if}\n\n  <!-- Hovered Hub HUD -->\n  {#if hoveredHub}\n    <div class=\"border-border/80 bg-card/95 animate-in fade-in slide-in-from-bottom-2 absolute right-3 bottom-3 z-10 max-w-xs rounded-xl border p-3 shadow-lg backdrop-blur-md duration-150\">\n      <div class=\"flex items-center gap-2\">\n        <span class=\"size-2 rounded-full\" style=\"background-color: {hoveredHub.color || 'oklch(0.65 0.20 145)'}\"></span>\n        <h5 class=\"text-foreground text-xs font-semibold\">{hoveredHub.name} ({hoveredHub.id})</h5>\n      </div>\n      {#if hoveredHub.latency}\n        <div class=\"mt-1.5 flex items-baseline justify-between font-mono text-xs\">\n          <span class=\"text-muted-foreground\">Turnaround</span>\n          <span class=\"text-foreground font-semibold\">{hoveredHub.latency}</span>\n        </div>\n      {/if}\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/route-flow-map/RouteFlowMap.svelte"
    },
    {
      "path": "packages/registry-svelte/components/route-flow-map/index.ts",
      "content": "export { default as RouteFlowMap, type RouteFlowMapProps, type RouteHub, type FlightRoute } from './RouteFlowMap.svelte'\nexport { chartAccentColor } from './chart-theme'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/route-flow-map/index.ts"
    },
    {
      "path": "packages/registry-svelte/components/route-flow-map/chart-theme.ts",
      "content": "/**\n * Chart palette driven by Tailwind v4 CSS variables (`--chart-1`..`--chart-5`,\n * `--muted-foreground`, `--border`, `--popover`, etc.) so dark/light flips happen\n * automatically when the consumer toggles their theme class. Values resolve at\n * runtime via `getComputedStyle`, so they pick up whatever the consumer set in\n * their own `tailwind.css` -- no fork required.\n *\n * Svelte port of the Vue `useChartTheme.ts`. Plain functions (no runes) so the\n * component can re-read them whenever its own MutationObserver tick fires.\n */\n\n// ECharts' canvas renderer does not accept OKLCH in every browser. Assigning\n// one of our Tailwind tokens to `fillStyle` can silently leave the sentinel\n// colour in place, turning a whole chart black. Convert OKLCH ourselves and\n// let the canvas normalize older CSS colour formats.\nlet _hexCanvas: CanvasRenderingContext2D | null = null\n\nexport function toCanvasColor(cssColor: string): string {\n  const value = cssColor.trim()\n  const match = value.match(\n    /^oklch\\(\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?)\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))\\s+([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(?:deg)?(?:\\s*\\/\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))(%?))?\\s*\\)$/i,\n  )\n\n  if (match) {\n    const lightness = Number(match[1]) / (match[2] === '%' ? 100 : 1)\n    const chroma = Number(match[3])\n    const hue = (Number(match[4]) * Math.PI) / 180\n    const alpha = match[5] == null ? 1 : Number(match[5]) / (match[6] === '%' ? 100 : 1)\n    const a = chroma * Math.cos(hue)\n    const b = chroma * Math.sin(hue)\n\n    const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3)\n    const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3)\n    const s = Math.pow(lightness - 0.0894841775 * a - 1.291485548 * b, 3)\n    const linear = [\n      4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n      -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n      -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,\n    ]\n    const rgb = linear.map((channel) => {\n      const encoded = channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055\n      return Math.round(Math.min(1, Math.max(0, encoded)) * 255)\n    })\n\n    return alpha < 1 ? `rgba(${rgb.join(', ')}, ${alpha})` : `rgb(${rgb.join(', ')})`\n  }\n\n  if (typeof document === 'undefined') return cssColor\n  if (!_hexCanvas) {\n    _hexCanvas = document.createElement('canvas').getContext('2d')\n  }\n  if (!_hexCanvas) return cssColor\n  _hexCanvas.fillStyle = '#010203'\n  _hexCanvas.fillStyle = value\n  const normalized = _hexCanvas.fillStyle as string\n  return normalized === '#010203' && value.toLowerCase() !== '#010203' ? value : normalized\n}\n\n// Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a\n// canvas-safe rgba(r,g,b,a). `colorString + '40'` (8-digit hex alpha)\n// only works when `colorString` is `#rrggbb`; once tokens resolve to\n// oklch() post-hydration the gradient stops break and the canvas paint\n// throws every frame. Stay defensive and always return rgba.\nexport function toRgba(cssColor: string, alpha: number): string {\n  const normalized = toCanvasColor(cssColor)\n  if (normalized.startsWith('#') && normalized.length === 7) {\n    const r = parseInt(normalized.slice(1, 3), 16)\n    const g = parseInt(normalized.slice(3, 5), 16)\n    const b = parseInt(normalized.slice(5, 7), 16)\n    return `rgba(${r},${g},${b},${alpha})`\n  }\n  if (normalized.startsWith('rgba(')) {\n    return normalized.replace(/,\\s*[\\d.]+\\s*\\)$/, `,${alpha})`)\n  }\n  if (normalized.startsWith('rgb(')) {\n    return normalized.replace(/^rgb\\(/, 'rgba(').replace(/\\)$/, `,${alpha})`)\n  }\n  // Canvas refused to parse this color -- ship the original string and\n  // let ECharts complain (better than crashing the paint loop).\n  return cssColor\n}\n\nfunction resolveVar(name: string, fallback: string): string {\n  if (typeof window === 'undefined') return fallback\n  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()\n  if (!v) return fallback\n  return toCanvasColor(v)\n}\n\n// SSR / pre-hydration fallback palette. Hex values picked to roughly\n// match the shadcn Neutral defaults in `tailwind.css` so the first paint\n// doesn't flicker.\nconst CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308']\n\nexport function chartColors(): string[] {\n  return Array.from({ length: 5 }, (_, i) => resolveVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!))\n}\n\nexport function chartTextColor(): string {\n  return resolveVar('--muted-foreground', '#888888')\n}\n\nexport function chartAxisColor(): string {\n  return resolveVar('--border', '#e5e5e5')\n}\n\nexport function chartSplitLineColor(): string {\n  return resolveVar('--border', '#f0f0f0')\n}\n\nexport function chartTooltipBg(): string {\n  return resolveVar('--popover', 'rgba(255,255,255,0.96)')\n}\n\nexport function chartTooltipBorder(): string {\n  return resolveVar('--border', '#e5e5e5')\n}\n\nexport function chartTooltipText(): string {\n  return resolveVar('--popover-foreground', '#333333')\n}\n\nexport function chartBgColor(): string {\n  return resolveVar('--card', resolveVar('--background', '#ffffff'))\n}\n\n// The app's accent, for the one highlighted element on a chart or map — a\n// selected route, a focused bar. Category series keep using chartColors.\nexport function chartAccentColor(): string {\n  return resolveVar('--primary', '#38bdf8')\n}\n\n// Out-of-bounds / failure colour for charts that encode good vs bad rather\n// than a category — control limits, error series. CSS-level marks can use\n// `var(--destructive)` directly; this exists because ECharts needs a resolved\n// colour string on the canvas.\nexport function chartDangerColor(): string {\n  return resolveVar('--destructive', '#dc2626')\n}\n\n// Two-level deep merge for ECharts option blocks (xAxis, yAxis, grid,\n// tooltip, legend, singleAxis, parallel, etc.). The top-level keys merge\n// shallowly, but one nested level (axisLabel, axisLine, splitLine, etc.)\n// merges shallowly too so a consumer passing `xAxis: { axisLabel: { fontSize: 9 } }`\n// doesn't blow away the wrapper's `color` + base font defaults on the same\n// axisLabel block. Arrays + primitives replace outright.\nexport function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T {\n  if (!user) return base\n  const out: any = { ...base }\n  for (const k of Object.keys(user)) {\n    const bv = (base as any)[k]\n    const uv = (user as any)[k]\n    if (bv != null && uv != null && typeof bv === 'object' && typeof uv === 'object' && !Array.isArray(bv) && !Array.isArray(uv)) {\n      out[k] = { ...bv, ...uv }\n    } else {\n      out[k] = uv\n    }\n  }\n  return out\n}\n\n// Default gauge stoplight: teal (safe) -> amber (warning) -> red (danger).\n// Static because gauges have semantic meaning (green safe / red danger) that\n// we deliberately don't theme-flip.\nexport const gaugeThresholds: [number, string][] = [\n  [0.6, '#14b8a6'],\n  [0.85, '#f59e0b'],\n  [1, '#dc2626'],\n]\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/route-flow-map/chart-theme.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/map.json"
  ],
  "description": "Global route and flight corridor flow map as pure dependency-free SVG. Renders great-circle Bezier curved arcs with traveling aircraft or data pulses via native SVG motion, origin and destination telemetry hubs, and hover inspection cards.",
  "categories": [
    "chart"
  ]
}