Menu

Change language

GitHub repo

Maritime Vessel Map

blocklogistics

Automatic Identification System (AIS) vessel tracking with container ships, tankers, speed, and anchorage status.

Installation

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

Variants

Loading interactive previews…

Schema

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

Vessel
interface Vessel {
  mmsi: string
  name: string
  flag: string
  type: VesselType
  coords: [number, number]
  heading: number
  speedKnots: number
  draught: string
  dest: string
  eta: string
  status: VesselStatus
}

Files installed (2)

  • src/app/components/blocks/maritime-vessel-map/maritime-vessel-map.component.ts9.6 kB
    // Automatic Identification System (AIS) vessel tracking with container ships, tankers,
    // speed, and anchorage status on a Mapbox map. Selecting a vessel (pin or list row) flies
    // the camera there. Port of the React block 1:1; markers follow the asset-tracking-map
    // Angular pattern (same map lib as Vue/React: mapbox-gl via UiMapComponent).
    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 type VesselType = 'Container' | 'Crude Tanker' | 'Bulk Carrier' | 'Tug'
    export type VesselStatus = 'Underway' | 'Moored' | 'At Anchor'
    
    export interface Vessel {
      mmsi: string
      name: string
      flag: string
      type: VesselType
      coords: [number, number]
      heading: number
      speedKnots: number
      draught: string
      dest: string
      eta: string
      status: VesselStatus
    }
    
    export const DEFAULT_VESSELS: Vessel[] = [
      {
        mmsi: '219018442',
        name: 'EVER GIVEN',
        flag: 'PA',
        type: 'Container',
        coords: [103.78, 1.25],
        heading: 110,
        speedKnots: 14.8,
        draught: '15.7m',
        dest: 'SGSIN PEB',
        eta: 'OCT 24 14:00',
        status: 'Underway',
      },
      {
        mmsi: '636019812',
        name: 'NORDIC STAR',
        flag: 'LR',
        type: 'Crude Tanker',
        coords: [103.88, 1.22],
        heading: 245,
        speedKnots: 0.2,
        draught: '12.1m',
        dest: 'SINGAPORE ANCH',
        eta: 'OCT 24 08:30',
        status: 'At Anchor',
      },
      {
        mmsi: '356782000',
        name: 'PACIFIC RUBY',
        flag: 'SG',
        type: 'Bulk Carrier',
        coords: [103.95, 1.29],
        heading: 45,
        speedKnots: 11.2,
        draught: '10.4m',
        dest: 'JPTYO',
        eta: 'OCT 29 06:00',
        status: 'Underway',
      },
      {
        mmsi: '508111222',
        name: 'HARBOR VALIANT',
        flag: 'SG',
        type: 'Tug',
        coords: [103.75, 1.28],
        heading: 180,
        speedKnots: 6.4,
        draught: '4.5m',
        dest: 'PASIR PANJANG',
        eta: 'OCT 24 12:00',
        status: 'Underway',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-maritime-vessel-map, [ui-maritime-vessel-map]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
      ],
      template: `
        <div data-slot="maritime-vessel-map" [class]="rootClass">
          <div class="border-border bg-card relative h-[480px] overflow-hidden rounded-xl border">
            <div
              ui-map
              [accessToken]="accessToken ?? ''"
              variant="dark"
              [center]="activeVessel().coords"
              [zoom]="10.8"
              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 (v of vessels; track v.mmsi) {
                  <div>
                    <div #marker>
                      <button
                        type="button"
                        class="group relative flex cursor-pointer flex-col items-center"
                        (click)="selectVessel(v)"
                      >
                        <div [class]="pinClass(v.mmsi)" [style.transform]="'rotate(' + v.heading + 'deg)'">▲</div>
                        <span [class]="labelClass(v.mmsi)">{{ v.name }}</span>
                      </button>
                    </div>
                  </div>
                }
              </div>
            </div>
          </div>
    
          <!-- AIS Telemetry Drawer -->
          <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">{{ activeVessel().type }}</span>
                <span ui-badge class="border-cyan-500/20 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400">{{
                  activeVessel().status
                }}</span>
              </div>
              <h3 ui-card-title class="mt-2 font-mono text-lg">{{ activeVessel().name }}</h3>
              <p ui-card-description class="font-mono text-xs">
                MMSI: {{ activeVessel().mmsi }} • Flag: {{ activeVessel().flag }}
              </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">
                <div>
                  <div class="text-muted-foreground text-xs uppercase">Speed Over Ground</div>
                  <div class="text-foreground text-sm font-bold">{{ activeVessel().speedKnots }} kts</div>
                </div>
                <div>
                  <div class="text-muted-foreground text-xs uppercase">Heading</div>
                  <div class="text-foreground text-sm font-bold">{{ activeVessel().heading }}°</div>
                </div>
                <div class="mt-2">
                  <div class="text-muted-foreground text-xs uppercase">Max Draught</div>
                  <div class="text-foreground text-sm font-bold">{{ activeVessel().draught }}</div>
                </div>
                <div class="mt-2">
                  <div class="text-muted-foreground text-xs uppercase">Destination</div>
                  <div class="text-foreground truncate text-xs font-bold">{{ activeVessel().dest }}</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">
                  Singapore Strait Traffic
                </div>
                @for (v of vessels; track v.mmsi) {
                  <button type="button" [class]="rowClass(v.mmsi)" (click)="selectVessel(v)">
                    <span class="font-mono">{{ v.name }}</span>
                    <span class="font-mono text-xs opacity-75">{{ v.speedKnots }} kts</span>
                  </button>
                }
              </div>
    
              <button ui-button variant="outline" size="sm" class="w-full font-mono text-xs" (click)="recenter()">
                Center on Vessel
              </button>
            </div>
          </div>
        </div>
      `,
    })
    export class UiMaritimeVesselMapComponent implements OnInit, AfterViewInit, OnDestroy {
      @Input() vessels: Vessel[] = DEFAULT_VESSELS.map((v) => ({ ...v, coords: [...v.coords] as [number, number] }))
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>>
    
      readonly activeMmsi = signal('')
    
      private map: mapboxgl.Map | null = null
      private markers: mapboxgl.Marker[] = []
      private destroyed = false
    
      ngOnInit(): void {
        this.activeMmsi.set(this.vessels[0]?.mmsi ?? '')
      }
    
      ngAfterViewInit(): void {
        // A new vessels 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
      }
    
      get rootClass(): string {
        return cn('grid gap-4 lg:grid-cols-[1fr_320px]', this.className)
      }
    
      activeVessel(): Vessel {
        return this.vessels.find((v) => v.mmsi === this.activeMmsi()) ?? this.vessels[0]!
      }
    
      pinClass(mmsi: string): string {
        return cn(
          'flex size-7 items-center justify-center rounded-full font-bold text-cyan-400',
          this.activeMmsi() === mmsi ? 'bg-cyan-500/20 ring-2 ring-cyan-400' : 'bg-background/80',
        )
      }
    
      labelClass(mmsi: string): string {
        return cn(
          'border-border bg-card/90 mt-1 rounded border px-1 py-0.5 font-mono text-xs font-semibold tracking-wider whitespace-nowrap shadow-xs',
          this.activeMmsi() === mmsi ? 'border-cyan-400/50 text-cyan-400' : 'text-foreground',
        )
      }
    
      rowClass(mmsi: string): string {
        return cn(
          'flex w-full items-center justify-between rounded-md px-2 py-1 text-xs transition-colors',
          this.activeMmsi() === mmsi
            ? 'bg-accent text-accent-foreground font-semibold'
            : 'hover:bg-muted text-muted-foreground',
        )
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
        void this.syncMarkers()
      }
    
      selectVessel(v: Vessel): void {
        this.activeMmsi.set(v.mmsi)
        this.map?.flyTo?.({ center: v.coords, zoom: 11.5 })
      }
    
      recenter(): void {
        const active = this.activeVessel()
        if (active) this.selectVessel(active)
      }
    
      /** One mapbox-gl Marker per vessel, 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 vessel = this.vessels[i]
          if (!vessel) return []
          return [new mapbox.Marker({ element: el.nativeElement, anchor: 'center' }).setLngLat(vessel.coords).addTo(map)]
        })
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/maritime-vessel-map/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/maritime-vessel-map.json