Route Flow Map
route-flow-mapuiGlobal 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.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/route-flow-map.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/route-flow-map.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/route-flow-map.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/route-flow-map.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/route-flow-mapInstalls to:app/components/ui/charts/route-flow-map/Examples
Loading interactive previews…
Loading interactive previews…
npm dependencies
Includes
Files installed (2)
app/components/ui/charts/route-flow-map/RouteFlowMap.vue10 kB
<script lang="ts"> import type { HTMLAttributes } from 'vue' export interface RouteHub { id: string name: string city?: string lat: number lng: number status?: 'optimal' | 'busy' | 'delayed' | string latency?: string | number color?: string } export interface FlightRoute { id: string from: string to: string callsign?: string aircraft?: string speed?: string altitude?: string progress?: number eta?: string status?: 'en-route' | 'scheduled' | 'approaching' | 'diverted' | string color?: string vehicleType?: 'plane' | 'ship' | 'packet' | 'pulse' | 'dot' duration?: number curvature?: number } export interface RouteFlowMapProps { hubs?: RouteHub[] routes?: FlightRoute[] selectedRoute?: string showHubLabels?: boolean showGraticule?: boolean height?: number | string interactive?: boolean class?: HTMLAttributes['class'] ariaLabel?: string projection?: 'globe' | 'mercator' } </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 { chartAccentColor } from '../useChartTheme' import { Globe, Plane, Navigation2 } from 'lucide-vue-next' const props = withDefaults(defineProps<RouteFlowMapProps>(), { hubs: () => [], routes: () => [], selectedRoute: undefined, showHubLabels: true, showGraticule: true, height: 480, interactive: true, ariaLabel: 'Global Route and Flight Flow Map', projection: 'globe', }) const emit = defineEmits<{ (e: 'update:selectedRoute', id: string): void (e: 'routeSelect', route: FlightRoute): void (e: 'hubClick', hub: RouteHub): void }>() const activeRouteId = ref(props.selectedRoute || '') const currentProjection = ref<'globe' | 'mercator'>(props.projection) const hoveredHub = ref<RouteHub | null>(null) watch( () => props.selectedRoute, (newVal) => { if (newVal !== undefined) activeRouteId.value = newVal }, ) const hubMap = computed(() => { const map = new globalThis.Map<string, RouteHub>() for (const h of props.hubs) map.set(h.id, h) return map }) const routesGeoJson = computed(() => { if (!props.routes || !props.routes.length) return null return { type: 'FeatureCollection', features: props.routes .map((r) => { const fromHub = hubMap.value.get(r.from) const toHub = hubMap.value.get(r.to) if (!fromHub || !toHub) return null const midLng = (fromHub.lng + toHub.lng) / 2 const midLat = (fromHub.lat + toHub.lat) / 2 + 10 return { type: 'Feature', id: r.id, properties: { id: r.id, color: r.color || 'rgba(56, 189, 248, 0.8)', selected: activeRouteId.value === r.id, }, geometry: { type: 'LineString', coordinates: [ [fromHub.lng, fromHub.lat], [midLng, midLat], [toHub.lng, toHub.lat], ], }, } }) .filter(Boolean), } }) const routeLinePaint = computed(() => ({ 'line-color': ['case', ['==', ['get', 'id'], activeRouteId.value], chartAccentColor.value, ['get', 'color']], 'line-width': ['case', ['==', ['get', 'id'], activeRouteId.value], 3, 1.5], 'line-dasharray': [2, 2], })) const activeRoute = computed(() => props.routes.find((r) => r.id === activeRouteId.value)) function selectRoute(r: FlightRoute) { if (!props.interactive) return activeRouteId.value = r.id emit('update:selectedRoute', r.id) emit('routeSelect', r) } function getVehiclePosition(r: FlightRoute): [number, number] | null { const fromHub = hubMap.value.get(r.from) const toHub = hubMap.value.get(r.to) if (!fromHub || !toHub) return null const progress = (r.progress ?? 50) / 100 const lng = fromHub.lng + (toHub.lng - fromHub.lng) * progress const lat = fromHub.lat + (toHub.lat - fromHub.lat) * progress + Math.sin(progress * Math.PI) * 10 return [lng, lat] } function toggleProjection() { currentProjection.value = currentProjection.value === 'globe' ? 'mercator' : 'globe' } </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"> <!-- Great Circle Route Arcs --> <MapSource v-if="routesGeoJson" id="flight-routes-source" type="geojson" :data="routesGeoJson"> <MapLayer id="flight-routes-layer" type="line" :paint="routeLinePaint" /> </MapSource> <!-- Hub Markers --> <MapMarker v-for="hub in hubs" :key="hub.id" :lng-lat="[hub.lng, hub.lat]" anchor="center" class="cursor-pointer select-none" > <div class="relative flex flex-col items-center" @mouseenter="hoveredHub = hub" @mouseleave="hoveredHub = null" @click="emit('hubClick', hub)" > <span class="size-2 rounded-full shadow-xs ring-2" :style="{ backgroundColor: hub.color || 'oklch(0.65 0.20 145)', boxShadow: `0 0 8px ${hub.color || 'oklch(0.65 0.20 145)'}`, }" /> <span v-if="showHubLabels" 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" > {{ hub.id }} </span> </div> </MapMarker> <!-- Aircraft / Cargo Vehicles in Flight --> <template v-for="r in routes" :key="`vehicle-${r.id}`"> <MapMarker v-if="getVehiclePosition(r)" :lng-lat="getVehiclePosition(r)!" anchor="center" :class=" cn( 'cursor-pointer transition-transform select-none', activeRouteId === r.id ? 'z-30 scale-125' : 'z-20 hover:scale-110', ) " > <div 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" @click="selectRoute(r)" > <Plane class="size-3 rotate-45 text-sky-400" /> <span class="font-mono text-[9px] font-semibold text-sky-200"> {{ r.callsign || r.id }} </span> </div> </MapMarker> </template> </Map> <!-- 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> <!-- Active Flight Route HUD Card --> <div v-if="activeRoute" 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-start justify-between gap-3"> <div> <div class="flex items-center gap-2"> <span class="size-2 animate-pulse rounded-full bg-sky-400" /> <span class="text-foreground font-mono text-xs font-semibold tracking-wider uppercase"> {{ activeRoute.callsign || activeRoute.id }} </span> <span class="py-0.2 rounded bg-sky-500/10 px-1.5 font-mono text-[10px] text-sky-400 capitalize"> {{ activeRoute.status || 'En-route' }} </span> </div> <div class="text-muted-foreground mt-1 flex items-center gap-2 font-mono text-xs"> <span>{{ activeRoute.from }}</span> <span>→</span> <span>{{ activeRoute.to }}</span> <span v-if="activeRoute.aircraft" class="text-[10px]">({{ activeRoute.aircraft }})</span> </div> </div> <button type="button" class="text-muted-foreground hover:text-foreground text-xs" @click="activeRouteId = ''"> ✕ </button> </div> <div class="border-border/60 mt-3 grid grid-cols-3 gap-2 border-t pt-2 font-mono text-[11px]"> <div> <span class="text-muted-foreground block text-[9px] uppercase">Speed</span> <span class="text-foreground font-semibold">{{ activeRoute.speed || '480 kts' }}</span> </div> <div> <span class="text-muted-foreground block text-[9px] uppercase">Altitude</span> <span class="text-foreground font-semibold">{{ activeRoute.altitude || 'FL360' }}</span> </div> <div> <span class="text-muted-foreground block text-[9px] uppercase">ETA</span> <span class="text-foreground font-semibold">{{ activeRoute.eta || '02h 15m' }}</span> </div> </div> </div> <!-- Hovered Hub HUD --> <div v-if="hoveredHub" 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: hoveredHub.color || 'oklch(0.65 0.20 145)' }" /> <h5 class="text-foreground text-xs font-semibold">{{ hoveredHub.name }} ({{ hoveredHub.id }})</h5> </div> <div v-if="hoveredHub.latency" class="mt-1.5 flex items-baseline justify-between font-mono text-xs"> <span class="text-muted-foreground">Turnaround</span> <span class="text-foreground font-semibold">{{ hoveredHub.latency }}</span> </div> </div> </div> </template>app/components/ui/charts/route-flow-map/index.ts0.1 kB
export { default as RouteFlowMap, default, type RouteFlowMapProps, type RouteHub, type FlightRoute, } from './RouteFlowMap.vue'
Raw manifest:https://uipkge.dev/r/vue/route-flow-map.json