Menu

Change language

GitHub repo

Fleet Vehicle Map

blocklogistics

Live fleet map with vehicle markers. Select a unit to fly the camera and read status.

Installation

$npx uipkge-ng@latest add fleet-vehicle-map
Manifest:https://uipkge.dev/r/angular/fleet-vehicle-map.jsonInstalls to:src/app/components/blocks/fleet-vehicle-map/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

FleetVehicle
interface FleetVehicle {
  id: string
  identifier: string
  model: string
  driver: string
  status: VehicleStatus
  speed: number
  lat: number
  lng: number
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/fleet-vehicle-map/fleet-vehicle-map.component.ts7.6 kB
    // Live fleet map with vehicle markers. Select a unit to fly the camera and read status.
    // Port of the React block 1:1 on Mapbox; markers follow the hub-locator-map Angular pattern.
    import {
      type AfterViewInit,
      ChangeDetectionStrategy,
      Component,
      type ElementRef,
      Input,
      type OnDestroy,
      type OnInit,
      type QueryList,
      ViewChildren,
      signal,
    } from '@angular/core'
    import type * as mapboxgl from 'mapbox-gl'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent } from '@/ui/map/map.component'
    
    export type VehicleStatus = 'moving' | 'idling' | 'parked'
    
    export interface FleetVehicle {
      id: string
      identifier: string
      model: string
      driver: string
      status: VehicleStatus
      speed: number
      lat: number
      lng: number
    }
    
    export const DEFAULT_FLEET_VEHICLES: FleetVehicle[] = [
      {
        id: 'v-104',
        identifier: 'Truck #104',
        model: 'Volvo VNL 860',
        driver: 'Marcus Vance',
        status: 'moving',
        speed: 64,
        lat: 41.5868,
        lng: -93.625,
      },
      {
        id: 'v-208',
        identifier: 'Van #208',
        model: 'Ford E-Transit 350',
        driver: 'Elena Rostova',
        status: 'moving',
        speed: 48,
        lat: 41.6005,
        lng: -93.712,
      },
      {
        id: 'v-109',
        identifier: 'Truck #109',
        model: 'Freightliner Cascadia',
        driver: 'Derrick Hayes',
        status: 'idling',
        speed: 0,
        lat: 41.984,
        lng: -93.582,
      },
      {
        id: 'v-212',
        identifier: 'Van #212',
        model: 'Mercedes Sprinter',
        driver: 'Aaliyah Patel',
        status: 'moving',
        speed: 52,
        lat: 41.591,
        lng: -93.604,
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-fleet-vehicle-map, [ui-fleet-vehicle-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
      ],
      host: {
        'data-slot': 'fleet-vehicle-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">Fleet</h2>
          <p class="text-muted-foreground text-xs">{{ vehicles.length }} units</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]="mapCenter()"
                [zoom]="11"
                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 (vehicle of vehicles; track vehicle.id) {
                    <div>
                      <div #marker>
                        <button type="button" [class]="markerClass(vehicle.id)" (click)="selectVehicle(vehicle)">
                          {{ markerLabel(vehicle.identifier) }}
                        </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">Vehicles</h3>
            </div>
            <div ui-card-content class="divide-border min-h-0 flex-1 divide-y overflow-auto p-0">
              @for (vehicle of vehicles; track vehicle.id) {
                <button type="button" [class]="rowClass(vehicle.id)" (click)="selectVehicle(vehicle)">
                  <div class="min-w-0">
                    <p class="text-foreground truncate text-sm font-medium">{{ vehicle.identifier }}</p>
                    <p class="text-muted-foreground truncate text-xs">{{ vehicle.driver }} · {{ vehicle.model }}</p>
                  </div>
                  <span ui-badge [variant]="statusVariant(vehicle.status)" class="shrink-0 text-xs capitalize">
                    {{ vehicle.status === 'moving' ? vehicle.speed + ' mph' : vehicle.status }}
                  </span>
                </button>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiFleetVehicleMapComponent implements OnInit, AfterViewInit, OnDestroy {
      @Input() vehicles: FleetVehicle[] = DEFAULT_FLEET_VEHICLES.map((v) => ({ ...v }))
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>>
    
      readonly selectedId = signal('')
    
      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.vehicles[0]?.id ?? '')
      }
    
      ngAfterViewInit(): void {
        // A new vehicles 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
      }
    
      selected(): FleetVehicle | undefined {
        return this.vehicles.find((v) => v.id === this.selectedId()) ?? this.vehicles[0]
      }
    
      mapCenter(): [number, number] {
        const s = this.selected()
        return s ? [s.lng, s.lat] : [-93.625, 41.59]
      }
    
      markerLabel(identifier: string): string {
        return identifier.replace('Truck ', '').replace('Van ', '')
      }
    
      statusVariant(status: VehicleStatus): 'info' | 'warning' | 'secondary' {
        if (status === 'moving') return 'info'
        if (status === 'idling') return 'warning'
        return 'secondary'
      }
    
      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
        void this.syncMarkers()
      }
    
      selectVehicle(vehicle: FleetVehicle): void {
        this.selectedId.set(vehicle.id)
        this.map?.flyTo?.({
          center: [vehicle.lng, vehicle.lat],
          zoom: 12,
          essential: true,
          duration: 800,
        })
      }
    
      /** One mapbox-gl Marker per vehicle, 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 vehicle = this.vehicles[i]
          if (!vehicle) return []
          return [
            new mapbox.Marker({ element: el.nativeElement, anchor: 'center' })
              .setLngLat([vehicle.lng, vehicle.lat])
              .addTo(map),
          ]
        })
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/fleet-vehicle-map/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/fleet-vehicle-map.json