Menu

Change language

GitHub repo

Vector Map

vector-mapui

Pure dependency-free SVG world landmass vector map. Features high-resolution continental paths (North America, South America, Europe, Africa, Asia, Oceania, UK, Japan) with region data shading, interactive pulsing pins, curved flight routes, and rich telemetry hover popovers.

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/vector-map.json
Named registry:npx shadcn-vue@latest add @uipkge/vector-mapInstalls to:app/components/ui/charts/vector-map/

Examples

Loading interactive previews…

npm dependencies

Includes

Files installed (2)

  • app/components/ui/charts/vector-map/VectorMap.vue9.8 kB
    <script lang="ts">
    import type { HTMLAttributes } from 'vue'
    
    export interface MapRegion {
      id: string
      name: string
      path?: string
    }
    
    export interface MapPin {
      id?: string
      lat?: number
      lng?: number
      x?: number
      y?: number
      label?: string
      value?: string | number
      color?: string
      status?: string
      description?: string
    }
    
    export interface FlowRoute {
      id?: string
      from: { lat?: number; lng?: number; x?: number; y?: number }
      to: { lat?: number; lng?: number; x?: number; y?: number }
      color?: string
      width?: number
      curvature?: number
      animated?: boolean
      dashed?: boolean
      duration?: number
      label?: string
    }
    
    export interface RegionDataRecord {
      value?: number
      label?: string
      status?: string
      color?: string
      description?: string
    }
    
    export interface VectorMapProps {
      mode?: 'countries' | 'continents'
      regions?: MapRegion[]
      regionData?: Record<string, RegionDataRecord>
      selectedRegion?: string
      pins?: MapPin[]
      routes?: FlowRoute[]
      height?: number | string
      interactive?: boolean
      showGraticule?: boolean
      showRegionLabels?: boolean
      fillColor?: string
      hoverColor?: string
      strokeColor?: string
      class?: HTMLAttributes['class']
      ariaLabel?: string
      projection?: 'globe' | 'mercator'
    }
    
    export const WORLD_REGIONS: MapRegion[] = [
      { id: 'northAmerica', name: 'North America' },
      { id: 'southAmerica', name: 'South America' },
      { id: 'europe', name: 'Europe' },
      { id: 'asia', name: 'Asia' },
      { id: 'africa', name: 'Africa' },
      { id: 'australiaOceania', name: 'Oceania' },
      { id: 'unitedKingdom', name: 'United Kingdom' },
      { id: 'japan', name: 'Japan' },
    ]
    
    export const WORLD_COUNTRIES: MapRegion[] = [
      { id: 'US', name: 'United States' },
      { id: 'CA', name: 'Canada' },
      { id: 'GB', name: 'United Kingdom' },
      { id: 'DE', name: 'Germany' },
      { id: 'FR', name: 'France' },
      { id: 'JP', name: 'Japan' },
      { id: 'CN', name: 'China' },
      { id: 'IN', name: 'India' },
      { id: 'BR', name: 'Brazil' },
      { id: 'AU', name: 'Australia' },
    ]
    
    export function projectPoint(pt: { lat?: number; lng?: number; x?: number; y?: number }) {
      if (pt.x !== undefined && pt.y !== undefined) return { x: pt.x, y: pt.y }
      const lng = pt.lng ?? 0
      const lat = pt.lat ?? 0
      const x = ((lng + 180) / 360) * 1000
      const y = ((90 - lat) / 180) * 500
      return { x, y }
    }
    
    const REGION_CENTROIDS: Record<string, [number, number]> = {
      northAmerica: [-100, 45],
      southAmerica: [-60, -15],
      europe: [15, 52],
      asia: [100, 40],
      africa: [20, 5],
      australiaOceania: [135, -25],
      unitedKingdom: [-2, 54],
      japan: [138, 38],
    }
    </script>
    
    <script setup lang="ts">
    import { computed, ref, watch } from 'vue'
    import { Map, MapMarker, MapSource, MapLayer, type MapVariant } from '@/components/ui/map'
    import { cn } from '@/lib/utils'
    import { Globe } from 'lucide-vue-next'
    
    const props = withDefaults(defineProps<VectorMapProps>(), {
      mode: 'continents',
      regions: () => WORLD_REGIONS,
      regionData: () => ({}),
      selectedRegion: undefined,
      pins: () => [],
      routes: () => [],
      height: 460,
      interactive: true,
      projection: 'globe',
    })
    
    const emit = defineEmits<{
      (e: 'update:selectedRegion', id: string): void
      (e: 'selectRegion', id: string): void
      (e: 'selectPin', pin: MapPin): void
    }>()
    
    const activeRegion = ref(props.selectedRegion || 'northAmerica')
    const currentProjection = ref<'globe' | 'mercator'>(props.projection)
    const hoveredPin = ref<MapPin | null>(null)
    
    watch(
      () => props.selectedRegion,
      (newVal) => {
        if (newVal !== undefined) activeRegion.value = newVal
      },
    )
    
    const activeRecord = computed(() => {
      return props.regionData[activeRegion.value] || null
    })
    
    function selectRegion(id: string) {
      if (!props.interactive) return
      activeRegion.value = id
      emit('update:selectedRegion', id)
      emit('selectRegion', id)
    }
    
    function toggleProjection() {
      currentProjection.value = currentProjection.value === 'globe' ? 'mercator' : 'globe'
    }
    
    const routesGeoJson = computed(() => {
      if (!props.routes || !props.routes.length) return null
      return {
        type: 'FeatureCollection',
        features: props.routes.map((r, i) => {
          const fromLng = r.from.lng ?? -74
          const fromLat = r.from.lat ?? 40
          const toLng = r.to.lng ?? 8
          const toLat = r.to.lat ?? 50
          return {
            type: 'Feature',
            id: i,
            properties: {
              color: r.color || 'rgba(56, 189, 248, 0.75)',
            },
            geometry: {
              type: 'LineString',
              coordinates: [
                [fromLng, fromLat],
                [(fromLng + toLng) / 2, (fromLat + toLat) / 2 + 5],
                [toLng, toLat],
              ],
            },
          }
        }),
      }
    })
    
    const routeLinePaint = {
      'line-color': ['get', 'color'],
      'line-width': 1.5,
      'line-dasharray': [2, 2],
    }
    </script>
    
    <template>
      <div
        :class="cn('border-border bg-card group relative w-full overflow-hidden rounded-xl border shadow-xs', props.class)"
        :style="{
          height:
            typeof props.height === 'number'
              ? `${props.height}px`
              : /^\d+$/.test(String(props.height))
                ? `${props.height}px`
                : props.height,
        }"
      >
        <Map variant="dark" :projection="currentProjection" :center="[10, 25]" :zoom="1.6" class="size-full">
          <!-- Flow Routes Layer -->
          <MapSource v-if="routesGeoJson" id="vector-routes-source" type="geojson" :data="routesGeoJson">
            <MapLayer id="vector-routes-layer" type="line" :paint="routeLinePaint" />
          </MapSource>
    
          <!-- Pins Layer -->
          <MapMarker
            v-for="(pin, i) in pins"
            :key="i"
            :lng-lat="[pin.lng ?? 0, pin.lat ?? 0]"
            anchor="center"
            class="cursor-pointer select-none"
          >
            <div
              class="relative flex size-6 items-center justify-center"
              @mouseenter="hoveredPin = pin"
              @mouseleave="hoveredPin = null"
              @click="emit('selectPin', pin)"
            >
              <span
                class="absolute inline-flex size-full animate-ping rounded-full opacity-60"
                :style="{ backgroundColor: pin.color || 'oklch(0.65 0.20 145)' }"
              />
              <span
                class="ring-background relative inline-flex size-2.5 rounded-full shadow-xs ring-2"
                :style="{
                  backgroundColor: pin.color || 'oklch(0.65 0.20 145)',
                  boxShadow: `0 0 10px ${pin.color || 'oklch(0.65 0.20 145)'}`,
                }"
              />
            </div>
          </MapMarker>
        </Map>
    
        <!-- Top-Left Region Selector Chips -->
        <div
          class="border-border/70 bg-card/85 absolute top-3 left-3 z-10 hidden max-w-md flex-wrap gap-1 rounded-lg border p-1.5 shadow-xs backdrop-blur-md md:flex"
        >
          <button
            v-for="r in regions"
            :key="r.id"
            type="button"
            :class="
              cn(
                'rounded-md px-2 py-0.5 text-xs font-medium transition',
                activeRegion === r.id
                  ? 'bg-primary text-primary-foreground font-semibold'
                  : 'text-muted-foreground hover:bg-muted hover:text-foreground',
              )
            "
            @click="selectRegion(r.id)"
          >
            {{ r.name }}
          </button>
        </div>
    
        <!-- Top-Right Controls -->
        <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"
        >
          <button
            type="button"
            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"
            @click="toggleProjection"
          >
            <Globe class="size-3.5" />
            <span class="capitalize">{{ currentProjection }}</span>
          </button>
        </div>
    
        <!-- Bottom-Left Region Detail Card -->
        <div
          v-if="activeRecord"
          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"
        >
          <div class="flex items-center gap-2">
            <span class="size-2 rounded-full" :style="{ backgroundColor: activeRecord.color || 'oklch(0.65 0.20 145)' }" />
            <h4 class="text-foreground text-sm font-semibold capitalize">
              {{ activeRegion.replace(/([A-Z])/g, ' $1') }}
            </h4>
            <span class="text-muted-foreground ml-auto font-mono text-xs uppercase">
              {{ activeRecord.status || 'Optimal' }}
            </span>
          </div>
    
          <div
            v-if="activeRecord.value !== undefined"
            class="border-border/60 mt-2.5 flex items-baseline justify-between border-t pt-2 font-mono text-xs"
          >
            <span class="text-muted-foreground">Active Nodes</span>
            <span class="text-foreground font-semibold">
              {{ activeRecord.value.toLocaleString() }}
            </span>
          </div>
    
          <p v-if="activeRecord.description" class="text-muted-foreground mt-1.5 text-xs leading-relaxed">
            {{ activeRecord.description }}
          </p>
        </div>
    
        <!-- Hovered Pin HUD -->
        <div
          v-if="hoveredPin"
          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"
        >
          <div class="flex items-center gap-2">
            <span class="size-2 rounded-full" :style="{ backgroundColor: hoveredPin.color || 'oklch(0.65 0.20 145)' }" />
            <h5 class="text-foreground text-xs font-semibold">
              {{ hoveredPin.label || 'Hub' }}
            </h5>
          </div>
          <p v-if="hoveredPin.description" class="text-muted-foreground mt-1 text-xs">
            {{ hoveredPin.description }}
          </p>
          <div
            v-if="hoveredPin.value !== undefined"
            class="border-border/60 mt-2 flex items-baseline justify-between border-t pt-1.5 font-mono text-xs"
          >
            <span class="text-muted-foreground">Throughput</span>
            <span class="text-foreground font-semibold">{{ hoveredPin.value }}</span>
          </div>
        </div>
      </div>
    </template>
    
  • app/components/ui/charts/vector-map/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/vue/vector-map.json