{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "bubble-map",
  "title": "Bubble Map",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/bubble-map/BubbleMap.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { MapVariant } from '$lib/components/ui/map'\n\n  export interface MapBubble {\n    id: string\n    name: string\n    lat: number\n    lng: number\n    value: number\n    formattedValue?: string\n    category?: string\n    status?: 'optimal' | 'warning' | 'destructive' | 'neutral' | 'active'\n    color?: string\n    pulse?: boolean\n    description?: string\n  }\n\n  export interface BubbleMapProps {\n    bubbles?: MapBubble[]\n    minRadius?: number\n    maxRadius?: number\n    showLegend?: boolean\n    legendTitle?: string\n    /** Selected bubble id. Two-way bindable (`bind:selectedId`). */\n    selectedId?: string\n    /** React-parity selection callback (fires alongside the `bind:selectedId` update). */\n    onSelectedIdChange?: (id: string | undefined) => void\n    interactive?: boolean\n    projection?: 'globe' | 'mercator'\n    variant?: MapVariant\n    center?: [number, number]\n    zoom?: number\n    class?: string\n    onselect?: (bubble: MapBubble) => void\n    ref?: HTMLDivElement | null\n  }\n\n  export function projectPoint(lat: number, lng: number): { x: number; y: number } {\n    const x = ((lng + 180) / 360) * 1000\n    const y = ((90 - lat) / 180) * 500\n    return { x, y }\n  }\n\n  export const CONTINENT_LANDMASSES: Array<{ id: string; name: string; d: string }> = []\n</script>\n\n<script lang=\"ts\">\n  import { Map, MapMarker } from '$lib/components/ui/map'\n  import { Globe } from '@lucide/svelte'\n  import { cn } from '$lib/utils'\n\n  let {\n    bubbles = [],\n    minRadius = 10,\n    maxRadius = 42,\n    showLegend = true,\n    legendTitle = 'Scale by Magnitude',\n    selectedId = $bindable<string | undefined>(undefined),\n    onSelectedIdChange,\n    interactive = true,\n    projection = 'globe',\n    variant = 'dark',\n    center = [0, 20],\n    zoom = 1.5,\n    class: className,\n    onselect,\n    ref = $bindable(null),\n  }: BubbleMapProps = $props()\n\n  let activeId = $state(selectedId || '')\n  // Initial capture is intentional (mirrors the Vue twin): projection is view state owned by the map after mount.\n  // svelte-ignore state_referenced_locally\n  let currentProjection = $state<'globe' | 'mercator'>(projection)\n\n  // Sync internal selection when the parent drives `selectedId`.\n  $effect(() => {\n    activeId = selectedId ?? ''\n  })\n\n  const values = $derived(bubbles.map((b) => b.value))\n  const minValue = $derived(values.length ? Math.min(...values) : 1)\n  const maxValue = $derived(values.length ? Math.max(...values) : 100)\n\n  function getRadius(val: number): number {\n    if (maxValue === minValue) return (minRadius + maxRadius) / 2\n    const ratio = Math.sqrt(Math.max(0, val - minValue) / (maxValue - minValue))\n    return Math.round(minRadius + ratio * (maxRadius - minRadius))\n  }\n\n  const STATUS_COLORS: Record<string, string> = {\n    optimal: 'oklch(0.65 0.20 145)',\n    active: 'oklch(0.60 0.20 250)',\n    warning: 'oklch(0.75 0.18 65)',\n    destructive: 'oklch(0.60 0.22 25)',\n    neutral: 'oklch(0.65 0.05 240)',\n  }\n\n  function getBubbleColor(b: MapBubble): string {\n    if (b.color) return b.color\n    if (b.status && STATUS_COLORS[b.status]) return STATUS_COLORS[b.status]\n    return 'oklch(0.60 0.20 250)'\n  }\n\n  const activeBubble = $derived(bubbles.find((b) => b.id === activeId))\n\n  function selectBubble(b: MapBubble) {\n    if (!interactive) return\n    activeId = b.id\n    selectedId = b.id\n    onSelectedIdChange?.(b.id)\n    onselect?.(b)\n  }\n\n  function clearSelection() {\n    activeId = ''\n    selectedId = undefined\n    onSelectedIdChange?.(undefined)\n  }\n\n  function toggleProjection() {\n    currentProjection = currentProjection === 'globe' ? 'mercator' : 'globe'\n  }\n</script>\n\n<div\n  bind:this={ref}\n  class={cn(\n    'border-border bg-card group relative h-[420px] w-full overflow-hidden rounded-xl border shadow-xs',\n    className,\n  )}\n>\n  <Map {variant} projection={currentProjection} {center} {zoom} class=\"size-full\">\n    {#each bubbles as b (b.id)}\n      <MapMarker\n        lngLat={[b.lng, b.lat]}\n        anchor=\"center\"\n        class={cn('cursor-pointer transition-transform select-none', activeId === b.id ? 'z-30 scale-110' : 'z-20 hover:scale-105')}\n      >\n        <div\n          class=\"relative flex items-center justify-center\"\n          style={`width: ${getRadius(b.value) * 2}px; height: ${getRadius(b.value) * 2}px`}\n          onclick={() => selectBubble(b)}\n          onkeydown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault()\n              selectBubble(b)\n            }\n          }}\n          role=\"button\"\n          tabindex={interactive ? 0 : -1}\n          aria-label={b.name}\n        >\n          {#if b.pulse}\n            <span\n              class=\"absolute inline-flex size-full animate-ping rounded-full opacity-40\"\n              style={`background-color: ${getBubbleColor(b)}`}\n            ></span>\n          {/if}\n\n          <div class=\"absolute inset-0 rounded-full opacity-25\" style={`background-color: ${getBubbleColor(b)}`}></div>\n\n          <div\n            class=\"relative flex size-4/5 items-center justify-center rounded-full border border-white/40 shadow-sm backdrop-blur-[1px] transition-[background-color,box-shadow]\"\n            style={`background-color: ${getBubbleColor(b)}; box-shadow: ${activeId === b.id ? `0 0 16px ${getBubbleColor(b)}` : 'none'}`}\n          >\n            {#if getRadius(b.value) >= 20}\n              <span class=\"px-1 text-center font-mono text-[10px] font-bold text-white drop-shadow-xs\">\n                {b.formattedValue || b.value}\n              </span>\n            {:else}\n              <span class=\"size-1.5 rounded-full bg-white shadow-xs\"></span>\n            {/if}\n          </div>\n        </div>\n      </MapMarker>\n    {/each}\n  </Map>\n\n  <div\n    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  >\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  {#if activeBubble}\n    <div\n      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    >\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 rounded-full ring-2 ring-white/20\" style={`background-color: ${getBubbleColor(activeBubble)}`}></span>\n            <span class=\"text-muted-foreground font-mono text-xs tracking-wider uppercase\">\n              {activeBubble.category || 'Node'}\n            </span>\n          </div>\n          <h4 class=\"text-foreground mt-0.5 text-sm font-semibold\">\n            {activeBubble.name}\n          </h4>\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-2.5 flex items-baseline justify-between border-t pt-2 font-mono text-xs\">\n        <span class=\"text-muted-foreground\">Magnitude</span>\n        <span class=\"text-foreground font-semibold\">\n          {activeBubble.formattedValue || activeBubble.value.toLocaleString()}\n        </span>\n      </div>\n\n      {#if activeBubble.description}\n        <p class=\"text-muted-foreground mt-1.5 text-xs leading-relaxed\">\n          {activeBubble.description}\n        </p>\n      {/if}\n    </div>\n  {/if}\n\n  {#if showLegend && bubbles.length > 0}\n    <div\n      class=\"border-border/70 bg-card/85 absolute right-3 bottom-3 z-10 hidden items-center gap-3 rounded-lg border px-3 py-2 text-xs shadow-xs backdrop-blur-md sm:flex\"\n    >\n      <span class=\"text-muted-foreground font-mono text-[11px]\">{legendTitle}</span>\n      <div class=\"flex items-center gap-2\">\n        <span class=\"bg-muted-foreground/40 size-2 rounded-full\"></span>\n        <span class=\"text-muted-foreground font-mono text-[10px]\">{minValue.toLocaleString()}</span>\n        <span class=\"bg-muted-foreground/60 size-4 rounded-full\"></span>\n        <span class=\"text-foreground font-mono text-[10px] font-semibold\">{maxValue.toLocaleString()}</span>\n      </div>\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/bubble-map/BubbleMap.svelte"
    },
    {
      "path": "packages/registry-svelte/components/bubble-map/index.ts",
      "content": "export {\n  default as BubbleMap,\n  default,\n  type MapBubble,\n  type BubbleMapProps,\n  projectPoint,\n  CONTINENT_LANDMASSES,\n} from './BubbleMap.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/bubble-map/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/map.json"
  ],
  "description": "Proportional symbol bubble map as dependency-free SVG. Eliminates geographic landmass distortion by scaling circle areas to continuous quantitative values with mathematical square-root radius normalization, pulsating concentric ripple rings, multi-tier size legend, and interactive hover cards.",
  "categories": [
    "chart"
  ]
}