Menu

Change language

GitHub repo

Isochrone Reachability Map

blocklogistics

Drive-time reachability zones showing 10m, 20m, and 30m commute polygons with workforce population metrics.

Installation

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

Variants

Loading interactive previews…

Schema

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

IsochroneZone
interface IsochroneZone {
  coords: [number, number][]
  pop: string
  jobs: string
  color: string
  label: string
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/isochrone-reachability-map/isochrone-reachability-map.component.ts9.2 kB
    import { ChangeDetectionStrategy, Component, Input, computed, 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,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent, UiMapMarkerComponent, type MapLayerInput, type MapSourceInput } from '@/ui/map/map.component'
    
    export type IsochroneTime = 10 | 20 | 30
    
    interface IsochroneZone {
      coords: [number, number][]
      pop: string
      jobs: string
      color: string
      label: string
    }
    
    const HUB_LOCATION: [number, number] = [-87.6298, 41.8781] // Chicago Loop
    const HUB_LAT = 41.8781
    const TIME_OPTIONS: IsochroneTime[] = [10, 20, 30]
    
    // Approximated polygon coordinates for 10m, 20m, 30m isochrones
    function createIsochroneCoords(radiusMiles: number): [number, number][] {
      const points: [number, number][] = []
      const steps = 36
      const latDelta = radiusMiles / 69
      const lngDelta = radiusMiles / (69 * Math.cos((HUB_LAT * Math.PI) / 180))
      for (let i = 0; i <= steps; i++) {
        const angle = (i / steps) * 2 * Math.PI
        // Add small organic jitter
        const jitter = 1 + Math.sin(angle * 3) * 0.12
        const lng = HUB_LOCATION[0] + lngDelta * Math.cos(angle) * jitter
        const lat = HUB_LOCATION[1] + latDelta * Math.sin(angle) * jitter
        points.push([lng, lat])
      }
      return points
    }
    
    function buildZones(): Record<IsochroneTime, IsochroneZone> {
      return {
        10: { coords: createIsochroneCoords(2.8), pop: '340K', jobs: '520K', color: '#10b981', label: '10 Min Commute' },
        20: { coords: createIsochroneCoords(6.2), pop: '1.24M', jobs: '890K', color: '#3b82f6', label: '20 Min Commute' },
        30: { coords: createIsochroneCoords(11.5), pop: '2.85M', jobs: '1.45M', color: '#f59e0b', label: '30 Min Commute' },
      }
    }
    
    const ISOCHRONES = buildZones()
    
    function polygonSource(id: string, coords: [number, number][]): MapSourceInput {
      return {
        id,
        type: 'geojson',
        data: {
          type: 'Feature',
          properties: {},
          geometry: { type: 'Polygon', coordinates: [coords] },
        },
      }
    }
    
    /**
     * IsochroneReachabilityMap -- the React block, 1:1: drive-time reachability zones showing
     * 10m, 20m, and 30m commute polygons on a Mapbox map with a workforce analytics card
     * (COMMUTE ANALYSIS, Drive-Time, Reachability Zones, Transit reachability from Downtown
     * Distribution Hub) and a 10/20/30 min range selector driving population, labor pool and
     * service band metrics. Sources are static GeoJSON; layer fill emphasis follows the
     * selected time, live-patched onto the map when it exists.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-isochrone-reachability-map, [ui-isochrone-reachability-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,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
        UiMapMarkerComponent,
      ],
      template: `
        <div data-slot="isochrone-reachability-map" [class]="rootClass">
          <div class="border-border bg-card relative h-[480px] overflow-hidden rounded-xl border">
            <div
              ui-map
              [accessToken]="accessToken"
              variant="light"
              [center]="hubLocation"
              [zoom]="10.8"
              class="h-full w-full"
              [sources]="mapSources"
              [layers]="mapLayers()"
              (created)="onMapCreated($event)"
            >
              <!-- Central Hub Marker -->
              <ui-map-marker [lngLat]="hubLocation" anchor="center">
                <div class="relative flex items-center justify-center">
                  <span class="bg-primary/30 absolute size-6 animate-ping rounded-full"></span>
                  <div
                    class="bg-primary text-primary-foreground ring-background flex size-6 items-center justify-center rounded-full font-mono text-xs font-bold shadow-md ring-2"
                  >
                    ★
                  </div>
                </div>
              </ui-map-marker>
            </div>
          </div>
    
          <!-- Isochrone Analytics 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">COMMUTE ANALYSIS</span>
                <span ui-badge class="border-info/20 bg-info/10 text-info">Drive-Time</span>
              </div>
              <h3 ui-card-title class="mt-2 text-lg">Reachability Zones</h3>
              <p ui-card-description>Transit reachability from Downtown Distribution Hub.</p>
            </div>
            <div ui-card-content class="space-y-4">
              <!-- Range Selector Tabs -->
              <div class="border-border bg-muted grid grid-cols-3 gap-1 rounded-lg border p-1 text-xs">
                @for (t of timeOptions; track t) {
                  <button
                    type="button"
                    class="rounded-md py-1.5 font-medium transition-colors"
                    [class]="
                      selectedTime() === t
                        ? 'bg-background text-foreground shadow-xs'
                        : 'text-muted-foreground hover:text-foreground'
                    "
                    (click)="selectTime(t)"
                  >
                    {{ t }} mins
                  </button>
                }
              </div>
    
              <div class="border-border space-y-3 border-t pt-3">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs">Population Reach</span>
                  <span class="text-foreground font-mono text-base font-bold">{{ activeZone().pop }}</span>
                </div>
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs">Workforce / Labor Pool</span>
                  <span class="text-foreground font-mono text-base font-bold">{{ activeZone().jobs }}</span>
                </div>
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs">Service Band</span>
                  <span class="font-mono text-xs font-semibold" [style.color]="activeZone().color">
                    {{ activeZone().label }}
                  </span>
                </div>
              </div>
    
              <div class="border-border text-muted-foreground space-y-2 border-t pt-3 text-xs">
                <div class="flex items-center gap-2">
                  <span class="bg-success size-2 rounded-full"></span>
                  <span>Green ring: 10-minute urban core</span>
                </div>
                <div class="flex items-center gap-2">
                  <span class="bg-info size-2 rounded-full"></span>
                  <span>Blue ring: 20-minute metropolitan belt</span>
                </div>
                <div class="flex items-center gap-2">
                  <span class="bg-warning size-2 rounded-full"></span>
                  <span>Amber ring: 30-minute outer ring</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiIsochroneReachabilityMapComponent {
      protected readonly timeOptions = TIME_OPTIONS
      protected readonly hubLocation = HUB_LOCATION
    
      @Input() accessToken = ''
      @Input('class') className?: string
    
      readonly selectedTime = signal<IsochroneTime>(20)
    
      readonly activeZone = computed<IsochroneZone>(() => ISOCHRONES[this.selectedTime()])
    
      /** Static GeoJSON polygons: 30m outer ring first so the 10m core paints on top. */
      readonly mapSources: MapSourceInput[] = (TIME_OPTIONS.slice().reverse() as IsochroneTime[]).map((t) =>
        polygonSource(`iso-${t}`, ISOCHRONES[t].coords),
      )
    
      /** Fill emphasis follows the selected time; lines stay constant. */
      readonly mapLayers = computed<MapLayerInput[]>(() => {
        const selected = this.selectedTime()
        const layers: MapLayerInput[] = []
        for (const t of TIME_OPTIONS.slice().reverse() as IsochroneTime[]) {
          const zone = ISOCHRONES[t]
          const fillOpacity = t === 10 ? 0.3 : t === 20 ? (selected >= 20 ? 0.22 : 0.06) : selected >= 30 ? 0.15 : 0.04
          layers.push({
            id: `iso-${t}-fill`,
            type: 'fill',
            source: `iso-${t}`,
            paint: { 'fill-color': zone.color, 'fill-opacity': fillOpacity },
          })
          layers.push({
            id: `iso-${t}-line`,
            type: 'line',
            source: `iso-${t}`,
            paint:
              t === 30
                ? { 'line-color': zone.color, 'line-width': 1.5, 'line-dasharray': [2, 2] }
                : { 'line-color': zone.color, 'line-width': t === 10 ? 2.5 : 2 },
          })
        }
        return layers
      })
    
      private map: mapboxgl.Map | null = null
    
      get rootClass(): string {
        return cn('grid gap-4 lg:grid-cols-[1fr_320px]', this.className)
      }
    
      selectTime(t: IsochroneTime): void {
        this.selectedTime.set(t)
        // The map only adds missing layers on input change, so patch live paint directly.
        try {
          this.map?.setPaintProperty('iso-30-fill', 'fill-opacity', t >= 30 ? 0.15 : 0.04)
          this.map?.setPaintProperty('iso-20-fill', 'fill-opacity', t >= 20 ? 0.22 : 0.06)
        } catch {
          /* map not ready yet; the computed layers cover first paint */
        }
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
      }
    }
    
  • src/app/components/blocks/isochrone-reachability-map/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/isochrone-reachability-map.json