Sales Territory Map
blockanalyticsSales territory map. Region pins with quota attainment; select a territory to fly the camera.
Installation
$pnpm dlx uipkge-ng@latest add sales-territory-map$npx uipkge-ng@latest add sales-territory-map$yarn dlx uipkge-ng@latest add sales-territory-map$bunx uipkge-ng@latest add sales-territory-mapManifest:
https://uipkge.dev/r/angular/sales-territory-map.jsonInstalls to:src/app/components/blocks/sales-territory-map/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SalesTerritoryinterface SalesTerritory {
id: string
name: string
code: string
arr: string
attainment: number
status: 'attained' | 'ontrack' | 'attention'
centerLngLat: [number, number]
}Files installed (2)
src/app/components/blocks/sales-territory-map/sales-territory-map.component.ts7.7 kB
// Sales territory map. Region pins with quota attainment on a Mapbox map, and a // quota list beside it; selecting a territory (pin or row) flies the camera there. import { type AfterViewInit, Component, type ElementRef, Input, type OnDestroy, type OnInit, type QueryList, ViewChildren, signal, ChangeDetectionStrategy, } from '@angular/core' import type * as mapboxgl from 'mapbox-gl' import { cn } from '@/lib/utils' import { UiBadgeComponent, type BadgeVariant } from '@/ui/badge/badge.component' import { UiCardComponent, UiCardContentComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiMapComponent } from '@/ui/map/map.component' export interface SalesTerritory { id: string name: string code: string arr: string attainment: number status: 'attained' | 'ontrack' | 'attention' centerLngLat: [number, number] } export const DEFAULT_TERRITORIES: SalesTerritory[] = [ { id: 'na-enterprise', name: 'North America', code: 'NA-ENT', arr: '$6.4M', attainment: 111, status: 'attained', centerLngLat: [-98.5, 39.8], }, { id: 'emea-central', name: 'EMEA Central', code: 'EMEA-C', arr: '$5.8M', attainment: 124, status: 'attained', centerLngLat: [10.4, 51.1], }, { id: 'apac-growth', name: 'APAC Growth', code: 'APAC-G', arr: '$4.1M', attainment: 94, status: 'ontrack', centerLngLat: [120.0, 30.0], }, { id: 'latam-emerging', name: 'Latin America', code: 'LATAM-E', arr: '$2.1M', attainment: 83, status: 'attention', centerLngLat: [-55.0, -14.0], }, ] function statusVariant(status: SalesTerritory['status']): BadgeVariant { if (status === 'attained') return 'info' if (status === 'ontrack') return 'secondary' return 'warning' } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-sales-territory-map, [ui-sales-territory-map]', standalone: true, imports: [ UiBadgeComponent, UiCardComponent, UiCardHeaderComponent, UiCardTitleComponent, UiCardContentComponent, UiMapComponent, ], host: { 'data-slot': 'sales-territory-map', '[class]': 'hostClass', }, template: ` <div class="flex flex-wrap items-center justify-between gap-3"> <h2 class="text-foreground text-lg font-semibold tracking-tight">Territories</h2> <p class="text-muted-foreground text-xs">{{ territories.length }} regions</p> </div> <div class="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-12"> <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-8"> <div class="relative min-h-[360px] flex-1 overflow-hidden"> <div ui-map [accessToken]="accessToken ?? ''" variant="dark" [center]="[15, 20]" [zoom]="1.2" class="absolute inset-0 size-full" (created)="onMapCreated($event)" > <!-- Marker elements: handed to mapbox-gl Markers once the map exists, which moves the inner #marker node onto the map; the outer node stays put for Angular's @for. --> <div class="hidden"> @for (t of territories; track t.id) { <div> <div #marker> <button type="button" [class]="markerClass(t.id)" (click)="selectTerritory(t)"> {{ t.code }} {{ t.attainment }}% </button> </div> </div> } </div> </div> </div> </div> <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-4"> <div ui-card-header class="border-border border-b p-4"> <h3 ui-card-title class="text-sm font-semibold">Quota</h3> </div> <div ui-card-content class="divide-border min-h-0 flex-1 divide-y overflow-auto p-0"> @for (t of territories; track t.id) { <button type="button" [class]="rowClass(t.id)" (click)="selectTerritory(t)"> <div class="min-w-0"> <p class="text-foreground truncate text-sm font-medium">{{ t.code }} · {{ t.name }}</p> <p class="text-muted-foreground font-mono text-xs tabular-nums">{{ t.arr }}</p> </div> <span ui-badge [variant]="statusVariant(t.status)" class="shrink-0 text-xs tabular-nums"> {{ t.attainment }}% </span> </button> } </div> </div> </div> `, }) export class UiSalesTerritoryMapComponent implements OnInit, AfterViewInit, OnDestroy { @Input() territories: SalesTerritory[] = DEFAULT_TERRITORIES @Input() accessToken?: string @Input('class') className?: string @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>> readonly selectedId = signal('') protected readonly statusVariant = statusVariant private map: mapboxgl.Map | null = null private markers: mapboxgl.Marker[] = [] private destroyed = false get hostClass(): string { return cn('flex h-full min-h-0 flex-col gap-4', this.className) } ngOnInit(): void { this.selectedId.set(this.territories[0]?.id ?? '') } ngAfterViewInit(): void { // A new territories list re-renders the marker elements; put the new set on the map. this.markerEls?.changes.subscribe(() => void this.syncMarkers()) } ngOnDestroy(): void { this.destroyed = true this.clearMarkers() this.map = null } markerClass(id: string): string { return this.selectedId() === id ? 'border-primary bg-primary text-primary-foreground rounded-full border px-2 py-0.5 font-mono text-xs font-semibold shadow-xs' : 'border-border bg-card text-foreground rounded-full border px-2 py-0.5 font-mono text-xs font-semibold shadow-xs' } rowClass(id: string): string { return cn( 'hover:bg-accent/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors', this.selectedId() === id && 'bg-accent/60', ) } onMapCreated(map: mapboxgl.Map): void { this.map = map if (typeof map.loaded === 'function' && map.loaded()) this.fitTerritories(map) else map.once('load', () => this.fitTerritories(map)) void this.syncMarkers() } selectTerritory(territory: SalesTerritory): void { this.selectedId.set(territory.id) this.map?.flyTo({ center: territory.centerLngLat, zoom: 2.8, essential: true, duration: 800, }) } private fitTerritories(map: mapboxgl.Map): void { const coords = this.territories.map((t) => t.centerLngLat) if (coords.length < 2) return const lons = coords.map((c) => c[0]) const lats = coords.map((c) => c[1]) try { map.fitBounds( [ [Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)], ], { padding: { top: 48, bottom: 48, left: 80, right: 80 }, duration: 0, maxZoom: 1.8 }, ) } catch { /* map not ready */ } } /** One mapbox-gl Marker per territory, anchored at its centre (the React MapMarker, anchor="center"). */ private async syncMarkers(): Promise<void> { const map = this.map const els = this.markerEls?.toArray() ?? [] if (!map) return const mod = await import('mapbox-gl') const mapbox = mod.default ?? mod if (this.destroyed || map !== this.map) return this.clearMarkers() this.markers = els.flatMap((el, i) => { const t = this.territories[i] if (!t) return [] return [new mapbox.Marker({ element: el.nativeElement, anchor: 'center' }).setLngLat(t.centerLngLat).addTo(map)] }) } private clearMarkers(): void { for (const m of this.markers) m.remove() this.markers = [] } }src/app/components/blocks/sales-territory-map/index.ts0.1 kB
export { UiSalesTerritoryMapComponent, DEFAULT_TERRITORIES, type SalesTerritory } from './sales-territory-map.component'
Raw manifest:https://uipkge.dev/r/angular/sales-territory-map.json