Menu

Change language

GitHub repo

Leaflet Isochrone Reachability Map

blocklogistics

Drive-time reachability zones showing 10m, 20m, and 30m commute polygons with workforce population metrics — on free OpenStreetMap/Esri tiles, no API key.

Installation

$npx uipkge-ng@latest add leaflet-isochrone-reachability-map
Manifest:https://uipkge.dev/r/angular/leaflet-isochrone-reachability-map.jsonInstalls to:src/app/components/blocks/leaflet-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.

IsochroneBand
interface IsochroneBand {
  coords: [number, number][]
  pop: string
  jobs: string
  color: string
  label: string
}
IsochroneDestination
interface IsochroneDestination {
  name: string
  lngLat: [number, number]
}

Files installed (2)

  • src/app/components/blocks/leaflet-isochrone-reachability-map/leaflet-isochrone-reachability-map.component.ts8.8 kB
    // Drive-time reachability zones showing 10m, 20m, and 30m commute polygons with workforce
    // population metrics — on free OpenStreetMap/Esri tiles, no API key. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiLeafletMapComponent,
      UiLeafletMarkerComponent,
      UiLeafletPolygonComponent,
    } from '@/ui/leaflet-map/leaflet-map.component'
    
    export type IsochroneMinutes = 10 | 20 | 30
    
    export interface IsochroneBand {
      coords: [number, number][]
      pop: string
      jobs: string
      color: string
      label: string
    }
    
    export const HUB_LOCATION: [number, number] = [-122.419, 37.775] // Downtown San Francisco
    
    // Key-free drive-time approximation: a perturbed ring whose radius varies by
    // angle through fixed harmonic offsets, so each band reads as an irregular
    // blob rather than a perfect circle (a real isochrone needs a routing API).
    export function createIsochroneCoords(radiusMiles: number, phase: number): [number, number][] {
      const points: [number, number][] = []
      const steps = 48
      const latDelta = radiusMiles / 69
      const lngDelta = radiusMiles / (69 * Math.cos((HUB_LOCATION[1] * Math.PI) / 180))
      for (let i = 0; i <= steps; i++) {
        const angle = (i / steps) * 2 * Math.PI
        const jitter = 1 + 0.16 * Math.sin(angle * 3 + phase) + 0.07 * Math.sin(angle * 7 + phase * 2.3)
        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
    }
    
    export const ISOCHRONES: Record<IsochroneMinutes, IsochroneBand> = {
      10: {
        coords: createIsochroneCoords(2.8, 0.4),
        pop: '340K',
        jobs: '520K',
        color: '#10b981',
        label: '10 Min Commute',
      },
      20: {
        coords: createIsochroneCoords(6.2, 1.7),
        pop: '1.24M',
        jobs: '890K',
        color: '#3b82f6',
        label: '20 Min Commute',
      },
      30: {
        coords: createIsochroneCoords(11.5, 2.9),
        pop: '2.85M',
        jobs: '1.45M',
        color: '#f59e0b',
        label: '30 Min Commute',
      },
    }
    
    export interface IsochroneDestination {
      name: string
      lngLat: [number, number]
    }
    
    export const DESTINATIONS: IsochroneDestination[] = [
      { name: 'Mission Depot', lngLat: [-122.414, 37.762] },
      { name: 'Daly City Hub', lngLat: [-122.47, 37.705] },
      { name: 'Oakland Annex', lngLat: [-122.272, 37.804] },
    ]
    
    const TIME_OPTIONS: IsochroneMinutes[] = [10, 20, 30]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-leaflet-isochrone-reachability-map, [ui-leaflet-isochrone-reachability-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiLeafletMapComponent,
        UiLeafletMarkerComponent,
        UiLeafletPolygonComponent,
      ],
      host: {
        'data-slot': 'leaflet-isochrone-reachability-map',
        '[class]': 'hostClass',
      },
      template: `
        <div class="border-border bg-card relative h-[480px] overflow-hidden rounded-xl border">
          <ui-leaflet-map variant="light" [center]="hubLocation" [zoom]="10.5" class="absolute inset-0 size-full">
            <!-- 30m Isochrone (outermost, drawn first) -->
            <ui-leaflet-polygon
              [lngLatPath]="isochrones[30].coords"
              [color]="isochrones[30].color"
              [weight]="1.5"
              dashArray="4 4"
              [fill]="true"
              [fillColor]="isochrones[30].color"
              [fillOpacity]="selectedTime() >= 30 ? 0.15 : 0.04"
            />
    
            <!-- 20m Isochrone -->
            <ui-leaflet-polygon
              [lngLatPath]="isochrones[20].coords"
              [color]="isochrones[20].color"
              [weight]="2"
              [fill]="true"
              [fillColor]="isochrones[20].color"
              [fillOpacity]="selectedTime() >= 20 ? 0.22 : 0.06"
            />
    
            <!-- 10m Isochrone (innermost, most opaque) -->
            <ui-leaflet-polygon
              [lngLatPath]="isochrones[10].coords"
              [color]="isochrones[10].color"
              [weight]="2.5"
              [fill]="true"
              [fillColor]="isochrones[10].color"
              [fillOpacity]="0.3"
            />
    
            <!-- Destinations -->
            @for (dest of destinations; track dest.name) {
              <ui-leaflet-marker [lngLat]="dest.lngLat" anchor="center">
                <div class="flex flex-col items-center gap-0.5">
                  <span class="border-background bg-foreground size-2 rounded-full border-2 shadow-xs"></span>
                  <span
                    class="bg-card/90 text-foreground rounded px-1 font-mono text-xs font-semibold whitespace-nowrap shadow-xs"
                  >
                    {{ dest.name }}
                  </span>
                </div>
              </ui-leaflet-marker>
            }
    
            <!-- Central Hub Marker -->
            <ui-leaflet-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-leaflet-marker>
          </ui-leaflet-map>
        </div>
    
        <!-- Isochrone Analytics Card -->
        <ui-card class="flex flex-col justify-between">
          <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>
            <ui-card-title class="mt-2 text-lg">Reachability Zones</ui-card-title>
            <ui-card-description>Transit reachability from Downtown Distribution Hub.</ui-card-description>
          </ui-card-header>
          <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]="timeTabClass(t)" (click)="selectedTime.set(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">{{ activeBand().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">{{ activeBand().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]="activeBand().color">
                  {{ activeBand().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>
          </ui-card-content>
        </ui-card>
      `,
    })
    export class UiLeafletIsochroneReachabilityMapComponent {
      @Input('class') className?: string
    
      protected readonly hubLocation = HUB_LOCATION
      protected readonly isochrones = ISOCHRONES
      protected readonly destinations = DESTINATIONS
      protected readonly timeOptions = TIME_OPTIONS
    
      readonly selectedTime = signal<IsochroneMinutes>(20)
    
      get hostClass(): string {
        return cn('grid gap-4 lg:grid-cols-[1fr_320px]', this.className)
      }
    
      activeBand(): IsochroneBand {
        return this.isochrones[this.selectedTime()]
      }
    
      timeTabClass(t: IsochroneMinutes): string {
        return cn(
          'rounded-md py-1.5 font-medium transition-colors',
          this.selectedTime() === t
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    }
    
  • src/app/components/blocks/leaflet-isochrone-reachability-map/index.ts0.3 kB

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