Dotted Map Chart
dotted-map-chartuiDotted world/USA map on Mapbox GL. Telemetry dot grid with theme-aware dot color, connection route arcs, and pulsing lat/lng pins with hover cards.
Installation
$pnpm dlx uipkge-ng@latest add dotted-map-chart$npx uipkge-ng@latest add dotted-map-chart$yarn dlx uipkge-ng@latest add dotted-map-chart$bunx uipkge-ng@latest add dotted-map-charthttps://uipkge.dev/r/angular/dotted-map-chart.jsonInstalls to:src/app/components/ui/charts/dotted-map-chart/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
pins | MapPin[] | [] | optional |
routes | MapRoute[] | [] | optional |
map | 'world''usa' | world | optional |
gridAccepted for API parity with React; the dot grid is always a vertical lattice. | 'vertical''diagonal' | vertical | optional |
shapeAccepted for API parity with React; dots always render as circles. | 'circle''hexagon' | circle | optional |
dotColor | | DEFAULT_DOT_COLOR | optional |
pulse | boolean | true | optional |
height | number | string | 420 | optional |
ariaLabel | string | — | optional |
interactiveShow the hover card for the hovered pin. | boolean | true | optional |
variant | MapVariant | dark | optional |
projectionInitial projection; the corner toggle switches it afterwards. | 'globe''mercator' | globe | optional |
accessTokenMapbox token. Falls back to `PUBLIC_MAPBOX_TOKEN`; pass `''` to force the no-token placeholder. | string | — | optional |
class | string | — | optional |
pinClickFired when a pin is clicked. | EventEmitter<MapPin> | — | optional |
pinHoverFired with the hovered pin, or null when the pointer leaves it. | EventEmitter<MapPin | null> | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
MapPininterface MapPin {
lat: number
lng: number
label?: string
color?: string
description?: string
value?: string | number
status?: string
}MapRouteinterface MapRoute {
from: { lat: number; lng: number }
to: { lat: number; lng: number }
color?: string
width?: number
curvature?: number
animated?: boolean
dashed?: boolean
duration?: number
label?: string
}npm dependencies
Includes
Used by
Files installed (3)
src/app/components/ui/charts/dotted-map-chart/dotted-map-chart.component.ts12.7 kB
import { AfterViewChecked, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, ViewChildren, signal, } from '@angular/core' import type * as mapboxgl from 'mapbox-gl' import { Globe, LucideAngularModule } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiMapComponent, type MapLayerInput, type MapSourceInput, type MapVariant } from '@/ui/map/map.component' import { onChartThemeChange, toCanvasColor } from '../use-chart-theme' export interface MapPin { lat: number lng: number label?: string color?: string description?: string value?: string | number status?: string } export interface MapRoute { from: { lat: number; lng: number } to: { lat: number; lng: number } color?: string width?: number curvature?: number animated?: boolean dashed?: boolean duration?: number label?: string } const DEFAULT_PIN_COLOR = 'oklch(0.65 0.20 145)' const DEFAULT_DOT_COLOR = 'rgba(255, 255, 255, 0.22)' const DEFAULT_ROUTE_COLOR = 'rgba(56, 189, 248, 0.75)' // Mapbox GL paint needs a concrete color — resolve `var(--token)` values // (the natural way to pass theme colors) via getComputedStyle first. export function resolvePaintColor(value: string): string { const match = value.trim().match(/^var\(\s*(--[\w-]+)\s*\)$/) const name = match?.[1] if (!name || typeof window === 'undefined') return value const resolved = getComputedStyle(document.documentElement).getPropertyValue(name).trim() return resolved ? toCanvasColor(resolved) : value } /** React's three-point route arc: straight to a midpoint lifted 5° of latitude, then on to the end. */ export function routeCoordinates(r: MapRoute): [number, number][] { return [ [r.from.lng, r.from.lat], [(r.from.lng + r.to.lng) / 2, (r.from.lat + r.to.lat) / 2 + 5], [r.to.lng, r.to.lat], ] } function envMapboxToken(): string | undefined { try { return ((import.meta as any).env?.PUBLIC_MAPBOX_TOKEN as string | undefined) || undefined } catch { return undefined } } /** * Angular port of the UIPKGE DottedMapChart: a Mapbox GL map (via `ui-map`) * with a dot-grid circle layer, route line layers and pulsing HTML pin * markers with a hover card. Without a Mapbox token `ui-map` renders its * "Mapbox token required" placeholder. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-dotted-map-chart, [ui-dotted-map-chart]', standalone: true, imports: [UiMapComponent, LucideAngularModule], host: { '[attr.data-slot]': '"dotted-map-chart"', '[attr.data-uipkge]': '""', '[class]': '"contents"', }, template: ` <div [class]="rootClass" [style.height]="heightStyle" [attr.role]="ariaLabel ? 'region' : null" [attr.aria-label]="ariaLabel || null" > <ui-map [variant]="variant" [projection]="currentProjection()" [center]="mapCenter" [zoom]="mapZoom" [accessToken]="resolvedToken" [sources]="mapSources" [layers]="mapLayers" class="size-full" (created)="onMapCreated($event)" /> <div class="hidden"> @for (pin of pins; track $index) { <div #pinMarker class="cursor-pointer select-none"> <div data-slot="dotted-map-pin" class="relative flex size-6 items-center justify-center" (mouseenter)="onPinEnter(pin)" (mouseleave)="onPinLeave()" (click)="pinClick.emit(pin)" > @if (pulse) { <span class="absolute inline-flex size-full animate-ping rounded-full opacity-60" [style.background-color]="pin.color || defaultPinColor" ></span> } <span class="ring-background relative inline-flex size-2.5 rounded-full shadow-xs ring-2" [style.background-color]="pin.color || defaultPinColor" [style.box-shadow]="'0 0 10px ' + (pin.color || defaultPinColor)" ></span> </div> </div> } </div> <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()" > <lucide-icon [img]="Globe" class="size-3.5" /> <span class="capitalize">{{ currentProjection() }}</span> </button> </div> @if (hoveredPin(); as hp) { @if (interactive) { <div data-slot="dotted-map-hover-card" 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-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.background-color]="hp.color || defaultPinColor"></span> <h5 class="text-foreground text-xs font-semibold">{{ hp.label || 'Telemetry Node' }}</h5> </div> @if (hp.description) { <p class="text-muted-foreground mt-1 text-xs">{{ hp.description }}</p> } @if (hp.value !== undefined) { <div 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">Value</span> <span class="text-foreground font-semibold">{{ hp.value }}</span> </div> } </div> } } </div> `, }) export class UiDottedMapChartComponent implements OnInit, OnChanges, AfterViewChecked, OnDestroy { @Input() pins: MapPin[] = [] @Input() routes: MapRoute[] = [] @Input() map: 'world' | 'usa' = 'world' /** Accepted for API parity with React; the dot grid is always a vertical lattice. */ @Input() grid: 'vertical' | 'diagonal' = 'vertical' /** Accepted for API parity with React; dots always render as circles. */ @Input() shape: 'circle' | 'hexagon' = 'circle' @Input() dotColor = DEFAULT_DOT_COLOR @Input() pulse = true @Input() height: number | string = 420 @Input() ariaLabel?: string /** Show the hover card for the hovered pin. */ @Input() interactive = true @Input() variant: MapVariant = 'dark' /** Initial projection; the corner toggle switches it afterwards. */ @Input() projection: 'globe' | 'mercator' = 'globe' /** Mapbox token. Falls back to `PUBLIC_MAPBOX_TOKEN`; pass `''` to force the no-token placeholder. */ @Input() accessToken?: string @Input('class') className?: string /** Fired when a pin is clicked. */ @Output() pinClick = new EventEmitter<MapPin>() /** Fired with the hovered pin, or null when the pointer leaves it. */ @Output() pinHover = new EventEmitter<MapPin | null>() @ViewChildren('pinMarker') pinMarkers?: QueryList<ElementRef<HTMLDivElement>> protected readonly Globe = Globe protected readonly defaultPinColor = DEFAULT_PIN_COLOR readonly currentProjection = signal<'globe' | 'mercator'>('globe') readonly hoveredPin = signal<MapPin | null>(null) mapSources: MapSourceInput[] = [] mapLayers: MapLayerInput[] = [] private mapInstance: mapboxgl.Map | null = null private Marker: typeof mapboxgl.Marker | null = null private markers: mapboxgl.Marker[] = [] private markersDirty = false private unsubscribeTheme: (() => void) | null = null get rootClass(): string { return cn('border-border bg-card group relative w-full overflow-hidden rounded-xl border shadow-xs', this.className) } get heightStyle(): string { return /^\d+(\.\d+)?$/.test(String(this.height)) ? `${this.height}px` : String(this.height) } get mapCenter(): [number, number] { return this.map === 'usa' ? [-98, 39] : [0, 20] } get mapZoom(): number { return this.map === 'usa' ? 3.5 : 1.5 } get resolvedToken(): string { return this.accessToken ?? envMapboxToken() ?? '' } /** Dot lattice over the world (6° step) or the contiguous USA (3° step). */ buildDotGrid(): { type: 'FeatureCollection'; features: unknown[] } { const features: unknown[] = [] const usa = this.map === 'usa' const step = usa ? 3 : 6 const latMin = usa ? 25 : -55 const latMax = usa ? 50 : 70 const lngMin = usa ? -125 : -170 const lngMax = usa ? -66 : 170 for (let lat = latMin; lat <= latMax; lat += step) { for (let lng = lngMin; lng <= lngMax; lng += step) { features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] } }) } } return { type: 'FeatureCollection', features } } /** * Route line features. Like React/Vue, only `color` is read (passed to Mapbox as given); * `width`, `dashed` and `curvature` are accepted but every route draws 1.5px, dashed, on the fixed arc. */ buildRoutes(): { type: 'FeatureCollection'; features: unknown[] } { return { type: 'FeatureCollection', features: (this.routes ?? []).map((r, i) => ({ type: 'Feature', id: i, properties: { color: r.color || DEFAULT_ROUTE_COLOR, dashed: r.dashed ?? true, }, geometry: { type: 'LineString', coordinates: routeCoordinates(r) }, })), } } buildDotPaint(): Record<string, unknown> { return { 'circle-radius': 1.5, 'circle-color': resolvePaintColor(this.dotColor || DEFAULT_DOT_COLOR), 'circle-opacity': 0.4, } } ngOnInit(): void { this.currentProjection.set(this.projection) this.rebuildLayers() this.unsubscribeTheme = onChartThemeChange(() => this.refreshMapData()) } ngOnChanges(changes: SimpleChanges): void { if (changes['pins']) this.markersDirty = true if (!changes['routes'] && !changes['map'] && !changes['dotColor']) return this.refreshMapData() } ngAfterViewChecked(): void { if (this.markersDirty && this.mapInstance && this.Marker) this.syncMarkers() } ngOnDestroy(): void { this.unsubscribeTheme?.() this.unsubscribeTheme = null this.removeMarkers() this.mapInstance = null } async onMapCreated(map: mapboxgl.Map): Promise<void> { this.mapInstance = map const mod = await import('mapbox-gl') const mapbox = mod.default ?? mod this.Marker = mapbox.Marker as unknown as typeof mapboxgl.Marker this.syncMarkers() } onPinEnter(pin: MapPin): void { this.hoveredPin.set(pin) this.pinHover.emit(pin) } onPinLeave(): void { this.hoveredPin.set(null) this.pinHover.emit(null) } toggleProjection(): void { const next = this.currentProjection() === 'globe' ? 'mercator' : 'globe' this.currentProjection.set(next) try { ;(this.mapInstance as unknown as { setProjection?: (p: string) => void } | null)?.setProjection?.(next) } catch { /* older api */ } } private rebuildLayers(): void { this.mapSources = [ { id: 'dot-grid-source', type: 'geojson', data: this.buildDotGrid() }, { id: 'routes-source', type: 'geojson', data: this.buildRoutes() }, ] this.mapLayers = [ { id: 'dot-grid-layer', type: 'circle', source: 'dot-grid-source', paint: this.buildDotPaint() }, { id: 'routes-layer', type: 'line', source: 'routes-source', paint: { 'line-color': ['get', 'color'], 'line-width': 1.5, 'line-dasharray': [2, 2] }, }, ] } /** Rebuild source data + paint and push them onto a live map (sources exist once the style loaded). */ private refreshMapData(): void { this.rebuildLayers() const map = this.mapInstance if (!map) return try { for (const s of this.mapSources) { ;(map.getSource(s.id) as { setData?: (d: unknown) => void } | undefined)?.setData?.(s.data) } if (map.getLayer('dot-grid-layer')) { map.setPaintProperty('dot-grid-layer', 'circle-color', this.buildDotPaint()['circle-color'] as string) } } catch { /* style reloading — ui-map re-adds sources from the inputs on style.load */ } } private removeMarkers(): void { for (const m of this.markers) m.remove() this.markers = [] } private syncMarkers(): void { this.markersDirty = false const map = this.mapInstance const Marker = this.Marker if (!map || !Marker) return this.removeMarkers() const els = this.pinMarkers?.toArray() ?? [] els.forEach((ref, i) => { const pin = this.pins[i] if (!pin) return const marker = new Marker({ element: ref.nativeElement, anchor: 'center' }) .setLngLat([pin.lng, pin.lat]) .addTo(map) this.markers.push(marker) }) } }src/app/components/ui/charts/dotted-map-chart/index.ts0.1 kB
export { UiDottedMapChartComponent, resolvePaintColor, routeCoordinates, type MapPin, type MapRoute, } from './dotted-map-chart.component'src/app/components/ui/charts/use-chart-theme.ts7.4 kB
// Angular port of `useChartTheme.ts` (Vue) — framework-free helpers + plain // functions instead of Vue `computed` refs. The palette is driven by Tailwind // v4 CSS variables (`--chart-1`..`--chart-5`, `--muted-foreground`, `--border`, // `--popover`, …) so dark/light flips happen automatically when the consumer // toggles their theme class. Values resolve at runtime via `getComputedStyle`, // so they pick up whatever the consumer set in their own `tailwind.css`. // // In a non-DOM environment (SSR / unit tests) every accessor returns the // fallback palette, which roughly matches the shadcn Neutral defaults so the // first paint doesn't flicker. export const CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308'] // ECharts' canvas renderer does not accept OKLCH in every browser. Assigning // one of our Tailwind tokens to `fillStyle` can silently leave the sentinel // colour in place, turning a whole chart black. Convert OKLCH ourselves and // let the canvas normalize older CSS colour formats. let _hexCanvas: CanvasRenderingContext2D | null = null export function toCanvasColor(cssColor: string): string { const value = cssColor.trim() const match = value.match( /^oklch\(\s*([+-]?(?:\d+\.?\d*|\.\d+))(%?)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+([+-]?(?:\d+\.?\d*|\.\d+))(?:deg)?(?:\s*\/\s*([+-]?(?:\d+\.?\d*|\.\d+))(%?))?\s*\)$/i, ) if (match) { const lightness = Number(match[1]) / (match[2] === '%' ? 100 : 1) const chroma = Number(match[3]) const hue = (Number(match[4]) * Math.PI) / 180 const alpha = match[5] == null ? 1 : Number(match[5]) / (match[6] === '%' ? 100 : 1) const a = chroma * Math.cos(hue) const b = chroma * Math.sin(hue) const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3) const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3) const s = Math.pow(lightness - 0.0894841775 * a - 1.291485548 * b, 3) const linear = [ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, ] const rgb = linear.map((channel) => { const encoded = channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055 return Math.round(Math.min(1, Math.max(0, encoded)) * 255) }) return alpha < 1 ? `rgba(${rgb.join(', ')}, ${alpha})` : `rgb(${rgb.join(', ')})` } if (typeof document === 'undefined') return cssColor if (!_hexCanvas) { _hexCanvas = document.createElement('canvas').getContext('2d') } if (!_hexCanvas) return cssColor _hexCanvas.fillStyle = '#010203' _hexCanvas.fillStyle = value const normalized = _hexCanvas.fillStyle as string return normalized === '#010203' && value.toLowerCase() !== '#010203' ? value : normalized } // Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a // canvas-safe rgba(r,g,b,a). `colorString + '40'` (8-digit hex alpha) // only works when `colorString` is `#rrggbb`; once tokens resolve to // oklch() post-hydration the gradient stops break and the canvas paint // throws every frame. Stay defensive and always return rgba. export function toRgba(cssColor: string, alpha: number): string { const normalized = toCanvasColor(cssColor) if (normalized.startsWith('#') && normalized.length === 7) { const r = parseInt(normalized.slice(1, 3), 16) const g = parseInt(normalized.slice(3, 5), 16) const b = parseInt(normalized.slice(5, 7), 16) return `rgba(${r},${g},${b},${alpha})` } if (normalized.startsWith('rgba(')) { return normalized.replace(/,\s*[\d.]+\s*\)$/, `,${alpha})`) } if (normalized.startsWith('rgb(')) { return normalized.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `,${alpha})`) } // Canvas refused to parse this color -- ship the original string and // let ECharts complain (better than crashing the paint loop). return cssColor } export function resolveCssVar(name: string, fallback: string): string { if (typeof window === 'undefined') return fallback const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim() if (!v) return fallback return toCanvasColor(v) } export function getChartColors(): string[] { return Array.from({ length: 5 }, (_, i) => resolveCssVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!)) } export function getChartTextColor(): string { return resolveCssVar('--muted-foreground', '#888888') } export function getChartAxisColor(): string { return resolveCssVar('--border', '#e5e5e5') } export function getChartSplitLineColor(): string { return resolveCssVar('--border', '#f0f0f0') } export function getChartTooltipBg(): string { return resolveCssVar('--popover', 'rgba(255,255,255,0.96)') } export function getChartTooltipBorder(): string { return resolveCssVar('--border', '#e5e5e5') } export function getChartTooltipText(): string { return resolveCssVar('--popover-foreground', '#333333') } export function getChartBgColor(): string { return resolveCssVar('--card', resolveCssVar('--background', '#ffffff')) } // The app's accent, for the one highlighted element on a chart or map — a // selected route, a focused bar. Category series keep using getChartColors(). export function getChartAccentColor(): string { return resolveCssVar('--primary', '#38bdf8') } // Out-of-bounds / failure colour for charts that encode good vs bad rather // than a category — control limits, error series. export function getChartDangerColor(): string { return resolveCssVar('--destructive', '#dc2626') } // Two-level deep merge for ECharts option blocks (xAxis, yAxis, grid, // tooltip, legend, singleAxis, parallel, etc.). The top-level keys merge // shallowly, but one nested level (axisLabel, axisLine, splitLine, etc.) // merges shallowly too so a consumer passing `xAxis: { axisLabel: { fontSize: 9 } }` // doesn't blow away the wrapper's `color` + base font defaults on the same // axisLabel block. Arrays + primitives replace outright. export function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T { if (!user) return base const out: any = { ...base } for (const k of Object.keys(user)) { const bv = (base as any)[k] const uv = (user as any)[k] if ( bv != null && uv != null && typeof bv === 'object' && typeof uv === 'object' && !Array.isArray(bv) && !Array.isArray(uv) ) { out[k] = { ...bv, ...uv } } else { out[k] = uv } } return out } // Default gauge stoplight: teal (safe) -> amber (warning) -> red (danger). // Static because gauges have semantic meaning (green safe / red danger) that // we deliberately don't theme-flip. export const gaugeThresholds: [number, string][] = [ [0.6, '#14b8a6'], [0.85, '#f59e0b'], [1, '#dc2626'], ] /** * Re-render callback subscription for theme flips: bumps every listener * whenever `<html>` class/style changes (the typical shadcn dark-mode pivot) * so chart components can rebuild their options and re-paint. Returns an * unsubscribe function. No-op (never fires) outside the DOM. */ export function onChartThemeChange(cb: () => void): () => void { if (typeof window === 'undefined' || typeof MutationObserver === 'undefined') return () => {} const observer = new MutationObserver(() => cb()) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }) return () => observer.disconnect() }
Raw manifest:https://uipkge.dev/r/angular/dotted-map-chart.json