{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "bubble-map",
  "title": "Bubble Map",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/charts/bubble-map/BubbleMap.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue'\nimport { Map, MapMarker, type MapVariant } from '@/components/ui/map'\nimport { cn } from '@/lib/utils'\nimport { Globe } from 'lucide-vue-next'\n\nexport 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\nexport interface BubbleMapProps {\n  bubbles?: MapBubble[]\n  minRadius?: number\n  maxRadius?: number\n  showLegend?: boolean\n  legendTitle?: string\n  selectedId?: string\n  interactive?: boolean\n  projection?: 'globe' | 'mercator'\n  variant?: MapVariant\n  center?: [number, number]\n  zoom?: number\n  class?: string\n}\n\nconst props = withDefaults(defineProps<BubbleMapProps>(), {\n  bubbles: () => [],\n  minRadius: 10,\n  maxRadius: 42,\n  showLegend: true,\n  legendTitle: 'Scale by Magnitude',\n  interactive: true,\n  projection: 'globe',\n  variant: 'dark',\n  center: () => [0, 20],\n  zoom: 1.5,\n})\n\nconst emit = defineEmits<{\n  (e: 'update:selectedId', id: string): void\n  (e: 'select', bubble: MapBubble): void\n}>()\n\nconst activeId = ref(props.selectedId || '')\nconst currentProjection = ref<'globe' | 'mercator'>(props.projection)\n\nwatch(\n  () => props.selectedId,\n  (newId) => {\n    if (newId !== undefined) activeId.value = newId\n  },\n)\n\nconst values = computed(() => props.bubbles.map((b) => b.value))\nconst minValue = computed(() => (values.value.length ? Math.min(...values.value) : 1))\nconst maxValue = computed(() => (values.value.length ? Math.max(...values.value) : 100))\n\nfunction getRadius(val: number): number {\n  if (maxValue.value === minValue.value) return (props.minRadius + props.maxRadius) / 2\n  const ratio = Math.sqrt(Math.max(0, val - minValue.value) / (maxValue.value - minValue.value))\n  return Math.round(props.minRadius + ratio * (props.maxRadius - props.minRadius))\n}\n\nconst 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\nfunction 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\nconst activeBubble = computed(() => props.bubbles.find((b) => b.id === activeId.value))\n\nfunction selectBubble(b: MapBubble) {\n  if (!props.interactive) return\n  activeId.value = b.id\n  emit('update:selectedId', b.id)\n  emit('select', b)\n}\n\nfunction toggleProjection() {\n  currentProjection.value = currentProjection.value === 'globe' ? 'mercator' : 'globe'\n}\n</script>\n\n<script lang=\"ts\">\nexport 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\nexport const CONTINENT_LANDMASSES: Array<{ id: string; name: string; d: string }> = []\n</script>\n\n<template>\n  <div\n    :class=\"\n      cn(\n        'border-border bg-card group relative h-[420px] w-full overflow-hidden rounded-xl border shadow-xs',\n        props.class,\n      )\n    \"\n  >\n    <Map :variant=\"variant\" :projection=\"currentProjection\" :center=\"center\" :zoom=\"zoom\" class=\"size-full\">\n      <MapMarker\n        v-for=\"b in bubbles\"\n        :key=\"b.id\"\n        :lng-lat=\"[b.lng, b.lat]\"\n        anchor=\"center\"\n        :class=\"\n          cn(\n            'cursor-pointer transition-transform select-none',\n            activeId === b.id ? 'z-30 scale-110' : 'z-20 hover:scale-105',\n          )\n        \"\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          @click=\"selectBubble(b)\"\n        >\n          <span\n            v-if=\"b.pulse\"\n            class=\"absolute inline-flex size-full animate-ping rounded-full opacity-40\"\n            :style=\"{ backgroundColor: getBubbleColor(b) }\"\n          />\n\n          <div class=\"absolute inset-0 rounded-full opacity-25\" :style=\"{ backgroundColor: getBubbleColor(b) }\" />\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=\"{\n              backgroundColor: getBubbleColor(b),\n              boxShadow: activeId === b.id ? `0 0 16px ${getBubbleColor(b)}` : 'none',\n            }\"\n          >\n            <span\n              v-if=\"getRadius(b.value) >= 20\"\n              class=\"px-1 text-center font-mono text-[10px] font-bold text-white drop-shadow-xs\"\n            >\n              {{ b.formattedValue || b.value }}\n            </span>\n            <span v-else class=\"size-1.5 rounded-full bg-white shadow-xs\" />\n          </div>\n        </div>\n      </MapMarker>\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        @click=\"toggleProjection\"\n      >\n        <Globe class=\"size-3.5\" />\n        <span class=\"capitalize\">{{ currentProjection }}</span>\n      </button>\n    </div>\n\n    <div\n      v-if=\"activeBubble\"\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\n              class=\"size-2 rounded-full ring-2 ring-white/20\"\n              :style=\"{ backgroundColor: getBubbleColor(activeBubble) }\"\n            />\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\" @click=\"activeId = ''\">\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      <p v-if=\"activeBubble.description\" class=\"text-muted-foreground mt-1.5 text-xs leading-relaxed\">\n        {{ activeBubble.description }}\n      </p>\n    </div>\n\n    <div\n      v-if=\"showLegend && bubbles.length > 0\"\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\" />\n        <span class=\"text-muted-foreground font-mono text-[10px]\">{{ minValue.toLocaleString() }}</span>\n        <span class=\"bg-muted-foreground/60 size-4 rounded-full\" />\n        <span class=\"text-foreground font-mono text-[10px] font-semibold\">{{ maxValue.toLocaleString() }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/charts/bubble-map/BubbleMap.vue"
    },
    {
      "path": "packages/registry-vue/components/charts/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.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/charts/bubble-map/index.ts"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/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"
  ]
}