Menu

Change language

GitHub repo

Asset Tracking Map

blocklogistics

Intermodal cargo container tracking with cold-chain sensor status, geofence perimeter rings, and battery levels.

Installation

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

Variants

Loading interactive previews…

Schema

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

ContainerAsset
interface ContainerAsset {
  id: string
  containerNo: string
  type: 'Reefer' | 'Dry Cargo' | 'Hazardous'
  location: string
  coords: [number, number]
  temp?: string
  humidity?: string
  battery: string
  geofence: 'Inside Zone' | 'In Transit' | 'Exited Perimeter'
  status: 'Nominal' | 'Alert' | 'Secured'
}

npm dependencies

Files installed (2)

  • src/app/components/blocks/asset-tracking-map/asset-tracking-map.component.ts9.8 kB
    // Intermodal cargo container tracking with cold-chain sensor status, geofence state and
    // battery levels on a Mapbox map. Selecting a container (pin or list row) flies the camera
    // there. Port of the React block 1:1; 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 { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent } from '@/ui/map/map.component'
    
    export interface ContainerAsset {
      id: string
      containerNo: string
      type: 'Reefer' | 'Dry Cargo' | 'Hazardous'
      location: string
      coords: [number, number]
      temp?: string
      humidity?: string
      battery: string
      geofence: 'Inside Zone' | 'In Transit' | 'Exited Perimeter'
      status: 'Nominal' | 'Alert' | 'Secured'
    }
    
    export const DEFAULT_CONTAINER_ASSETS: ContainerAsset[] = [
      {
        id: 'c-1',
        containerNo: 'MSKU-948102',
        type: 'Reefer',
        location: 'Port of Long Beach, Pier G',
        coords: [-118.216, 33.754],
        temp: '-18.4°C',
        humidity: '84%',
        battery: '92%',
        geofence: 'Inside Zone',
        status: 'Nominal',
      },
      {
        id: 'c-2',
        containerNo: 'CMAU-310892',
        type: 'Dry Cargo',
        location: 'Barstow Rail Yard, Track 4',
        coords: [-117.022, 34.898],
        battery: '78%',
        geofence: 'In Transit',
        status: 'Nominal',
      },
      {
        id: 'c-3',
        containerNo: 'HLXU-772901',
        type: 'Reefer',
        location: 'Inland Empire Logistics Depot',
        coords: [-117.585, 34.062],
        temp: '+4.2°C',
        humidity: '62%',
        battery: '64%',
        geofence: 'Inside Zone',
        status: 'Alert',
      },
      {
        id: 'c-4',
        containerNo: 'OOLU-550183',
        type: 'Hazardous',
        location: 'Pacific Intermodal Terminal',
        coords: [-118.243, 33.974],
        battery: '95%',
        geofence: 'Inside Zone',
        status: 'Secured',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-asset-tracking-map, [ui-asset-tracking-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
      ],
      host: {
        'data-slot': 'asset-tracking-map',
        '[class]': 'hostClass',
      },
      template: `
        <div class="grid gap-4 lg:grid-cols-[1fr_320px]">
          <div class="border-border bg-card relative h-[480px] overflow-hidden rounded-xl border">
            <div
              ui-map
              [accessToken]="accessToken ?? ''"
              variant="light"
              [center]="activeAsset().coords"
              [zoom]="9.2"
              class="h-full w-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 (asset of assets; track asset.id) {
                  <div>
                    <div #marker>
                      <button
                        type="button"
                        class="group relative flex cursor-pointer flex-col items-center"
                        (click)="selectAsset(asset)"
                      >
                        <div [class]="pinClass(asset.status)">📦</div>
                        <span
                          class="border-border bg-background/90 mt-1 rounded border px-1 py-0.5 font-mono text-xs font-bold shadow-xs"
                        >
                          {{ shortNo(asset.containerNo) }}
                        </span>
                      </button>
                    </div>
                  </div>
                }
              </div>
            </div>
          </div>
    
          <!-- Asset Telemetry & Sensor Card -->
          <div ui-card class="flex flex-col justify-between">
            <div ui-card-header>
              <div class="flex items-center justify-between">
                <span ui-badge variant="outline" class="font-mono text-xs">{{ activeAsset().type }}</span>
                <span ui-badge [class]="statusBadgeClass(activeAsset().status)">{{ activeAsset().status }}</span>
              </div>
              <h3 ui-card-title class="mt-2 font-mono text-lg">{{ activeAsset().containerNo }}</h3>
              <p ui-card-description class="text-xs">{{ activeAsset().location }}</p>
            </div>
            <div ui-card-content class="space-y-4">
              <div class="border-border bg-muted/40 grid grid-cols-2 gap-2 rounded-lg border p-2.5 font-mono text-xs">
                @if (activeAsset().temp) {
                  <div>
                    <div class="text-muted-foreground text-xs uppercase">Cold Chain Temp</div>
                    <div [class]="tempClass(activeAsset().status)">{{ activeAsset().temp }}</div>
                  </div>
                }
                @if (activeAsset().humidity) {
                  <div>
                    <div class="text-muted-foreground text-xs uppercase">Humidity</div>
                    <div class="text-foreground text-sm font-bold">{{ activeAsset().humidity }}</div>
                  </div>
                }
                <div>
                  <div class="text-muted-foreground text-xs uppercase">IoT Battery</div>
                  <div class="text-foreground text-sm font-bold">{{ activeAsset().battery }}</div>
                </div>
                <div>
                  <div class="text-muted-foreground text-xs uppercase">Geofence</div>
                  <div class="text-foreground text-sm font-bold">{{ activeAsset().geofence }}</div>
                </div>
              </div>
    
              <div class="border-border space-y-1 border-t pt-3">
                <div class="text-muted-foreground mb-1 font-mono text-xs tracking-wider uppercase">Tracked Cargo Units</div>
                @for (asset of assets; track asset.id) {
                  <button type="button" [class]="unitRowClass(asset.id)" (click)="selectAsset(asset)">
                    <span class="font-mono">{{ asset.containerNo }}</span>
                    <span [class]="unitTypeClass(asset.status)">{{ asset.type }}</span>
                  </button>
                }
              </div>
    
              <button ui-button variant="outline" size="sm" class="w-full font-mono text-xs" (click)="recenter()">
                Center on Container
              </button>
            </div>
          </div>
        </div>
      `,
    })
    export class UiAssetTrackingMapComponent implements OnInit, AfterViewInit, OnDestroy {
      @Input() assets: ContainerAsset[] = DEFAULT_CONTAINER_ASSETS.map((a) => ({
        ...a,
        coords: [...a.coords] as [number, number],
      }))
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>>
    
      readonly activeId = signal('')
    
      private map: mapboxgl.Map | null = null
      private markers: mapboxgl.Marker[] = []
      private destroyed = false
    
      get hostClass(): string {
        return cn('block w-full', this.className)
      }
    
      ngOnInit(): void {
        this.activeId.set(this.assets[0]?.id ?? '')
      }
    
      ngAfterViewInit(): void {
        // A new assets 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
      }
    
      activeAsset(): ContainerAsset {
        return this.assets.find((a) => a.id === this.activeId()) ?? this.assets[0]!
      }
    
      shortNo(containerNo: string): string {
        return containerNo.split('-')[1] ?? containerNo
      }
    
      pinClass(status: ContainerAsset['status']): string {
        return cn(
          'flex size-7 items-center justify-center rounded-md font-mono text-xs font-bold shadow-md ring-2',
          status === 'Alert'
            ? 'bg-warning text-white ring-amber-400'
            : 'bg-primary text-primary-foreground ring-primary/25',
        )
      }
    
      statusBadgeClass(status: ContainerAsset['status']): string {
        return status === 'Alert'
          ? 'border-warning/20 bg-warning/10 text-warning'
          : 'border-success/20 bg-success/10 text-success'
      }
    
      tempClass(status: ContainerAsset['status']): string {
        return cn('text-sm font-bold', status === 'Alert' ? 'text-warning' : 'text-foreground')
      }
    
      unitRowClass(id: string): string {
        return cn(
          'flex w-full items-center justify-between rounded-md px-2 py-1 text-xs transition-colors',
          this.activeId() === id
            ? 'bg-accent text-accent-foreground font-semibold'
            : 'hover:bg-muted text-muted-foreground',
        )
      }
    
      unitTypeClass(status: ContainerAsset['status']): string {
        return cn('font-mono text-xs', status === 'Alert' ? 'text-warning font-bold' : 'opacity-70')
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
        void this.syncMarkers()
      }
    
      selectAsset(asset: ContainerAsset): void {
        this.activeId.set(asset.id)
        this.map?.flyTo?.({ center: asset.coords, zoom: 11.5 })
      }
    
      recenter(): void {
        const active = this.activeAsset()
        if (active) this.selectAsset(active)
      }
    
      /** One mapbox-gl Marker per container, anchored at the bottom (the React MapMarker). */
      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 asset = this.assets[i]
          if (!asset) return []
          return [new mapbox.Marker({ element: el.nativeElement, anchor: 'bottom' }).setLngLat(asset.coords).addTo(map)]
        })
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/asset-tracking-map/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/asset-tracking-map.json