Menu

Change language

GitHub repo

Leaflet Neighborhood Walkscore Card

blockreal-estate

Real estate neighborhood intelligence widget on free OpenStreetMap/Esri tiles — no API key. Walk Score, Transit Score, Bike Score, categorized points of interest (transit, dining, schools, parks), a 15-minute walk radius, and commute time estimator.

Installation

$npx uipkge-ng@latest add leaflet-neighborhood-walkscore-card
Manifest:https://uipkge.dev/r/angular/leaflet-neighborhood-walkscore-card.jsonInstalls to:src/app/components/blocks/leaflet-neighborhood-walkscore-card/

Variants

Loading interactive previews…

Schema

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

MobilityScore
interface MobilityScore {
  score: number
  maxScore?: number
  label: string
  verdict: string
  description: string
  verdictClass: string
  gaugeColor: string
  subtext: string
  highlights: string[]
}
PoiItem
interface PoiItem {
  id: string
  name: string
  category: 'transit' | 'dining' | 'schools' | 'parks'
  categoryLabel: string
  distance: string
  timeWalk?: string
  timeDrive?: string
  rating?: string
  badge?: string
  description: string
  address: string
  coords?: [number, number]
}
CommuteDestination
interface CommuteDestination {
  id: string
  name: string
  region: string
  distance: string
  transitTime: number
  transitLabel: string
  transitRoute: string
  driveTime: number
  driveLabel: string
  driveRoute: string
  peakRushTime?: string
}
PoiCategory
interface PoiCategory {
  id: PoiCategoryType
  label: string
}

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--chart-1

Files installed (2)

  • src/app/components/blocks/leaflet-neighborhood-walkscore-card/leaflet-neighborhood-walkscore-card.component.ts45.5 kB
    // Real estate neighborhood intelligence widget on free OpenStreetMap/Esri tiles — no API
    // key. Walk Score, Transit Score, Bike Score, categorized points of interest (transit, dining,
    // schools, parks), a 15-minute walk radius, and commute time estimator.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      ArrowUpRight,
      Bike,
      Bookmark,
      Bus,
      Car,
      Check,
      Clock,
      Coffee,
      Footprints,
      GraduationCap,
      LucideAngularModule,
      MapPin,
      Navigation,
      School,
      Search,
      Share2,
      ShieldCheck,
      Train,
      TreePine,
      UtensilsCrossed,
    } from 'lucide-angular'
    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 { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiLeafletCircleComponent,
      UiLeafletMapComponent,
      UiLeafletMarkerComponent,
    } from '@/ui/leaflet-map/leaflet-map.component'
    
    export type PoiCategoryType = 'all' | 'transit' | 'dining' | 'schools' | 'parks'
    export type CommuteMode = 'all' | 'transit' | 'drive'
    
    export interface MobilityScore {
      score: number
      maxScore?: number
      label: string
      verdict: string
      description: string
      verdictClass: string
      gaugeColor: string
      subtext: string
      highlights: string[]
    }
    
    export interface PoiItem {
      id: string
      name: string
      category: 'transit' | 'dining' | 'schools' | 'parks'
      categoryLabel: string
      distance: string
      timeWalk?: string
      timeDrive?: string
      rating?: string
      badge?: string
      description: string
      address: string
      coords?: [number, number]
    }
    
    export interface CommuteDestination {
      id: string
      name: string
      region: string
      distance: string
      transitTime: number
      transitLabel: string
      transitRoute: string
      driveTime: number
      driveLabel: string
      driveRoute: string
      peakRushTime?: string
    }
    
    export const DEFAULT_WALK_SCORE: MobilityScore = {
      score: 94,
      maxScore: 100,
      label: 'Walk Score®',
      verdict: "Walker's Paradise",
      description: 'Daily errands do not require a car',
      verdictClass: 'text-success',
      gaugeColor: '#10b981',
      subtext: '94 out of 100 on walkable street accessibility',
      highlights: ['48 amenities within 10-min walk', '98% continuous sidewalks', 'Pedestrian safety grade: A+'],
    }
    
    export const DEFAULT_TRANSIT_SCORE: MobilityScore = {
      score: 82,
      maxScore: 100,
      label: 'Transit Score®',
      verdict: 'Excellent Transit',
      description: '6 nearby bus & light rail routes',
      verdictClass: 'text-info',
      gaugeColor: '#0ea5e9',
      subtext: '82 out of 100 on public transportation connectivity',
      highlights: ['Metro A Line station in 4 min', '5 frequent municipal bus routes', '18 min direct train to DTLA'],
    }
    
    export const DEFAULT_BIKE_SCORE: MobilityScore = {
      score: 88,
      maxScore: 100,
      label: 'Bike Score®',
      verdict: 'Very Bikeable',
      description: 'Dedicated bike lanes and flat terrain',
      verdictClass: 'text-chart-1',
      gaugeColor: 'var(--chart-1)',
      subtext: '88 out of 100 on cycling infrastructure & slope',
      highlights: ['14 miles of protected bike lanes', '<2% average street gradient', '3 bikeshare docks nearby'],
    }
    
    export const DEFAULT_POIS: PoiItem[] = [
      {
        id: 'poi-1',
        name: 'Metro A Line Station',
        category: 'transit',
        categoryLabel: 'Public Transit',
        distance: '0.2 mi',
        timeWalk: '4 min walk',
        badge: 'Light Rail',
        description: 'High-frequency light rail connecting Pasadena directly to Downtown LA and Long Beach.',
        address: 'Del Mar Ave & S Raymond Ave',
      },
      {
        id: 'poi-2',
        name: 'Pasadena Transit Bus Stop',
        category: 'transit',
        categoryLabel: 'Public Transit',
        distance: '0.1 mi',
        timeWalk: '2 min walk',
        badge: 'Local Transit',
        description: 'Direct routes 10 & 20 connecting South Lake District to Old Town & City Hall.',
        address: 'S Lake Ave & E Green St',
      },
      {
        id: 'poi-3',
        name: 'Memorial Park Station',
        category: 'transit',
        categoryLabel: 'Public Transit',
        distance: '0.5 mi',
        timeWalk: '10 min walk',
        badge: 'Light Rail',
        description: 'Alternative metro access with regional bus connection bays and bike lockups.',
        address: '125 E Holly St',
      },
      {
        id: 'poi-4',
        name: 'Blue Bottle Coffee',
        category: 'dining',
        categoryLabel: 'Dining & Coffee',
        distance: '0.1 mi',
        timeWalk: '2 min walk',
        rating: '4.8 ★',
        badge: 'Artisanal Cafe',
        description: 'Specialty pour-over coffee, single-origin espresso drinks, and fresh bakery pastries.',
        address: '60 S Lake Ave',
      },
      {
        id: 'poi-5',
        name: 'Whole Foods Market',
        category: 'dining',
        categoryLabel: 'Dining & Coffee',
        distance: '0.3 mi',
        timeWalk: '6 min walk',
        rating: '4.7 ★',
        badge: 'Organic Grocery',
        description: 'Full-service natural grocer with organic produce, prepared hot bar, and wellness center.',
        address: '3751 E Foothill Blvd / Arroyo Pkwy',
      },
      {
        id: 'poi-6',
        name: "Trader Joe's",
        category: 'dining',
        categoryLabel: 'Dining & Coffee',
        distance: '0.4 mi',
        timeWalk: '8 min walk',
        rating: '4.9 ★',
        badge: 'Grocery',
        description: 'Neighborhood market renowned for unique provisions, fresh bakery, and wine selection.',
        address: '610 S Lake Ave',
      },
      {
        id: 'poi-7',
        name: 'Urth Caffe Pasadena',
        category: 'dining',
        categoryLabel: 'Dining & Coffee',
        distance: '0.2 mi',
        timeWalk: '5 min walk',
        rating: '4.6 ★',
        badge: 'Organic Eatery',
        description: 'Vibrant indoor/outdoor patio serving farm-to-table organic breakfast, lunch, and matcha.',
        address: '594 E Colorado Blvd',
      },
      {
        id: 'poi-8',
        name: 'Pasadena High School',
        category: 'schools',
        categoryLabel: 'Schools & Education',
        distance: '0.6 mi',
        timeWalk: '12 min walk',
        rating: '9/10',
        badge: 'Public 9-12',
        description: 'Top-rated public high school with Law and Creative Arts academies and AP honors programs.',
        address: '2925 E Sierra Madre Blvd',
      },
      {
        id: 'poi-9',
        name: 'Caltech Campus',
        category: 'schools',
        categoryLabel: 'Schools & Education',
        distance: '0.8 mi',
        timeWalk: '15 min walk',
        rating: 'Top Tier',
        badge: 'Higher Education',
        description: 'World-renowned private science & engineering research university campus and open grounds.',
        address: '1200 E California Blvd',
      },
      {
        id: 'poi-10',
        name: 'McKinley Elementary School',
        category: 'schools',
        categoryLabel: 'Schools & Education',
        distance: '0.4 mi',
        timeWalk: '8 min walk',
        rating: '8/10',
        badge: 'Public K-8',
        description: 'STEAM-focused neighborhood magnet school with dual-language immersion track.',
        address: '325 S Oak Knoll Ave',
      },
      {
        id: 'poi-11',
        name: 'Central Park',
        category: 'parks',
        categoryLabel: 'Parks & Recreation',
        distance: '0.3 mi',
        timeWalk: '6 min walk',
        rating: '4.8 ★',
        badge: 'Public Park',
        description: 'Historic shaded park with rolling green lawns, dedicated dog park, and lawn bowling greens.',
        address: '275 S Raymond Ave',
      },
      {
        id: 'poi-12',
        name: 'Rose Bowl Aquatics Center',
        category: 'parks',
        categoryLabel: 'Parks & Recreation',
        distance: '1.4 mi',
        timeDrive: '5 min drive',
        rating: '4.9 ★',
        badge: 'Sports Complex',
        description: 'Twin Olympic 50-meter pools, fitness facilities, and adjacent Arroyo Seco running trails.',
        address: '360 N Arroyo Blvd',
      },
    ]
    
    export const DEFAULT_COMMUTES: CommuteDestination[] = [
      {
        id: 'commute-dtla',
        name: 'Downtown Los Angeles',
        region: 'Financial District & Union Station',
        distance: '10.4 mi',
        transitTime: 18,
        transitLabel: '18m by train',
        transitRoute: 'Metro A Line (Direct Express)',
        driveTime: 24,
        driveLabel: '24m by car',
        driveRoute: 'via CA-110 S Freeway',
        peakRushTime: '28-34m at 8:30 AM',
      },
      {
        id: 'commute-sm',
        name: 'Santa Monica',
        region: 'Silicon Beach & Pier',
        distance: '26.2 mi',
        transitTime: 62,
        transitLabel: '62m by train',
        transitRoute: 'Metro A Line → Metro E Line',
        driveTime: 45,
        driveLabel: '45m by car',
        driveRoute: 'via I-10 W / CA-110 S',
        peakRushTime: '55-68m at 8:30 AM',
      },
      {
        id: 'commute-burbank',
        name: 'Burbank Media District',
        region: 'Warner Bros & Disney Studios',
        distance: '12.1 mi',
        transitTime: 38,
        transitLabel: '38m by transit',
        transitRoute: 'Metro Bus 501 Rapid',
        driveTime: 22,
        driveLabel: '22m by car',
        driveRoute: 'via CA-134 W Express',
        peakRushTime: '26-32m at 8:30 AM',
      },
      {
        id: 'commute-century',
        name: 'Century City / Westwood',
        region: 'Fox Studios & UCLA Area',
        distance: '21.5 mi',
        transitTime: 54,
        transitLabel: '54m by transit',
        transitRoute: 'Commuter Express 423',
        driveTime: 38,
        driveLabel: '38m by car',
        driveRoute: 'via CA-134 W to I-405 S',
        peakRushTime: '48-58m at 8:30 AM',
      },
    ]
    
    // Subject property (742 Evergreen Terrace, Pasadena) — map center, home marker,
    // and the 15-minute walkshed circle all anchor here.
    export const HOME_CENTER: [number, number] = [-118.1445, 34.1478]
    // ~15-minute pedestrian catchment (~1.2 km at typical walking speed).
    export const WALK_RADIUS_METERS = 1200
    
    const DEFAULT_POI_COORDS: Record<string, [number, number]> = {
      'poi-1': [-118.148, 34.141],
      'poi-2': [-118.132, 34.152],
      'poi-3': [-118.149, 34.148],
      'poi-4': [-118.133, 34.144],
      'poi-5': [-118.149, 34.135],
      'poi-6': [-118.132, 34.136],
      'poi-7': [-118.136, 34.146],
      'poi-8': [-118.125, 34.155],
      'poi-9': [-118.138, 34.138],
      'poi-10': [-118.148, 34.143],
      'poi-11': [-118.142, 34.133],
    }
    
    // Circular gauge calculation helper (radius 38, perimeter ~238.76)
    export const GAUGE_CIRCUMFERENCE = 2 * Math.PI * 38
    
    export function getGaugeDashOffset(score: number): number {
      const clamped = Math.min(100, Math.max(0, score))
      return GAUGE_CIRCUMFERENCE - (clamped / 100) * GAUGE_CIRCUMFERENCE
    }
    
    interface PoiCategory {
      id: PoiCategoryType
      label: string
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-leaflet-neighborhood-walkscore-card, [ui-leaflet-neighborhood-walkscore-card]',
      standalone: true,
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiProgressComponent,
        UiSeparatorComponent,
        UiLeafletCircleComponent,
        UiLeafletMapComponent,
        UiLeafletMarkerComponent,
      ],
      host: {
        'data-slot': 'leaflet-neighborhood-walkscore-card',
        '[class]': 'hostClass',
      },
      template: `
        <!-- Header Banner Card -->
        <ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
          <ui-card-header class="p-4 sm:p-6">
            <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
              <div class="space-y-2">
                <!-- Badges Bar -->
                <div class="flex flex-wrap items-center gap-2">
                  <span ui-badge wrap variant="outline" class="gap-1.5 font-medium">
                    <lucide-icon [img]="MapPin" class="text-primary size-3.5" />
                    <span>{{ city }} · {{ zipCode }}</span>
                  </span>
                  <span ui-badge wrap variant="success" class="gap-1 font-medium">
                    <lucide-icon [img]="Footprints" class="size-3.5" />
                    <span>Walker's Paradise</span>
                  </span>
                  <span ui-badge wrap variant="secondary" class="font-medium"> Urban Transit Hub </span>
                </div>
    
                <!-- Title & Address -->
                <div>
                  <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
                    {{ title }}
                  </h1>
                  <p class="text-foreground mt-1 text-sm font-semibold sm:text-base">
                    {{ address }}
                  </p>
                  <p class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
                    Neighborhood: <span class="text-foreground font-medium">{{ neighborhood }}</span>
                  </p>
                </div>
              </div>
    
              <!-- Top Actions -->
              <div class="flex flex-wrap items-center gap-2 self-start lg:self-auto">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="h-8 gap-1.5 text-xs font-medium shadow-xs"
                  (click)="handleCopyAddress()"
                >
                  @if (isCopied()) {
                    <lucide-icon [img]="Check" class="text-success size-3.5" />
                  } @else {
                    <lucide-icon [img]="Share2" class="size-3.5" />
                  }
                  <span>{{ isCopied() ? 'Address Copied' : 'Share Location' }}</span>
                </button>
    
                <button
                  ui-button
                  [variant]="isSaved() ? 'default' : 'outline'"
                  size="sm"
                  class="h-8 gap-1.5 text-xs font-medium shadow-xs"
                  (click)="handleToggleSave()"
                >
                  <lucide-icon [img]="Bookmark" [class]="isSaved() ? 'size-3.5 fill-current' : 'size-3.5'" />
                  <span>{{ isSaved() ? 'Saved Neighborhood' : 'Save Area' }}</span>
                </button>
              </div>
            </div>
          </ui-card-header>
        </ui-card>
    
        <!-- Section 1: 3 Primary Mobility Score Cards -->
        <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
          <!-- Card 1: Walk Score -->
          <ui-card
            class="border-border/80 bg-card text-card-foreground hover:border-success/40 shadow-xs transition-colors"
          >
            <ui-card-header class="p-4 pb-2 sm:p-5 sm:pb-2">
              <div class="flex items-center justify-between">
                <div class="flex items-center gap-2">
                  <div
                    class="border-success/30 bg-success/10 text-success flex size-8 items-center justify-center rounded-lg border"
                  >
                    <lucide-icon [img]="Footprints" class="size-4" />
                  </div>
                  <div>
                    <ui-card-title class="text-sm font-semibold">{{ walkScore.label }}</ui-card-title>
                    <ui-card-description class="text-xs">Pedestrian Index</ui-card-description>
                  </div>
                </div>
                <span ui-badge wrap variant="success" class="text-xs font-semibold"> {{ walkScore.score }}/100 </span>
              </div>
            </ui-card-header>
    
            <ui-card-content class="space-y-4 p-4 pt-2 sm:p-5 sm:pt-2">
              <!-- Circular Gauge & Score Verdict -->
              <div class="flex items-center gap-4 pt-1">
                <div class="relative flex size-20 shrink-0 items-center justify-center">
                  <svg class="size-20 -rotate-90" viewBox="0 0 96 96">
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      stroke="currentColor"
                      stroke-width="7"
                      class="text-muted/40"
                    />
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      [attr.stroke]="walkScore.gaugeColor"
                      stroke-width="7"
                      stroke-linecap="round"
                      [attr.stroke-dasharray]="gaugeCircumference"
                      [attr.stroke-dashoffset]="gaugeOffset(walkScore.score)"
                      class="transition-colors duration-700 ease-out"
                    />
                  </svg>
                  <div class="absolute inset-0 flex flex-col items-center justify-center text-center">
                    <span class="text-foreground font-mono text-xl font-bold tracking-tight tabular-nums">
                      {{ walkScore.score }}
                    </span>
                    <span class="text-muted-foreground text-xs font-medium">/ 100</span>
                  </div>
                </div>
    
                <div class="space-y-1">
                  <div class="text-foreground text-sm font-bold tracking-tight">
                    {{ walkScore.verdict }}
                  </div>
                  <p class="text-success text-xs leading-relaxed font-semibold">
                    {{ walkScore.description }}
                  </p>
                </div>
              </div>
    
              <ui-separator class="bg-border/60" />
    
              <!-- Key Highlights -->
              <div class="space-y-1.5">
                <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Mobility Highlights</p>
                <ul class="space-y-1 text-xs">
                  @for (item of walkScore.highlights; track $index) {
                    <li class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3.5 shrink-0" />
                      <span>{{ item }}</span>
                    </li>
                  }
                </ul>
              </div>
            </ui-card-content>
          </ui-card>
    
          <!-- Card 2: Transit Score -->
          <ui-card class="border-border/80 bg-card text-card-foreground hover:border-info/40 shadow-xs transition-colors">
            <ui-card-header class="p-4 pb-2 sm:p-5 sm:pb-2">
              <div class="flex items-center justify-between">
                <div class="flex items-center gap-2">
                  <div
                    class="border-info/30 bg-info/10 text-info flex size-8 items-center justify-center rounded-lg border"
                  >
                    <lucide-icon [img]="Bus" class="size-4" />
                  </div>
                  <div>
                    <ui-card-title class="text-sm font-semibold">{{ transitScore.label }}</ui-card-title>
                    <ui-card-description class="text-xs">Transit Connectivity</ui-card-description>
                  </div>
                </div>
                <span ui-badge wrap variant="info" class="text-xs font-semibold"> {{ transitScore.score }}/100 </span>
              </div>
            </ui-card-header>
    
            <ui-card-content class="space-y-4 p-4 pt-2 sm:p-5 sm:pt-2">
              <!-- Circular Gauge & Score Verdict -->
              <div class="flex items-center gap-4 pt-1">
                <div class="relative flex size-20 shrink-0 items-center justify-center">
                  <svg class="size-20 -rotate-90" viewBox="0 0 96 96">
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      stroke="currentColor"
                      stroke-width="7"
                      class="text-muted/40"
                    />
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      [attr.stroke]="transitScore.gaugeColor"
                      stroke-width="7"
                      stroke-linecap="round"
                      [attr.stroke-dasharray]="gaugeCircumference"
                      [attr.stroke-dashoffset]="gaugeOffset(transitScore.score)"
                      class="transition-colors duration-700 ease-out"
                    />
                  </svg>
                  <div class="absolute inset-0 flex flex-col items-center justify-center text-center">
                    <span class="text-foreground font-mono text-xl font-bold tracking-tight tabular-nums">
                      {{ transitScore.score }}
                    </span>
                    <span class="text-muted-foreground text-xs font-medium">/ 100</span>
                  </div>
                </div>
    
                <div class="space-y-1">
                  <div class="text-foreground text-sm font-bold tracking-tight">
                    {{ transitScore.verdict }}
                  </div>
                  <p class="text-info text-xs leading-relaxed font-semibold">
                    {{ transitScore.description }}
                  </p>
                </div>
              </div>
    
              <ui-separator class="bg-border/60" />
    
              <!-- Key Highlights -->
              <div class="space-y-1.5">
                <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Transit Highlights</p>
                <ul class="space-y-1 text-xs">
                  @for (item of transitScore.highlights; track $index) {
                    <li class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-info size-3.5 shrink-0" />
                      <span>{{ item }}</span>
                    </li>
                  }
                </ul>
              </div>
            </ui-card-content>
          </ui-card>
    
          <!-- Card 3: Bike Score -->
          <ui-card
            class="border-border/80 bg-card text-card-foreground hover:border-chart-1/40 shadow-xs transition-colors"
          >
            <ui-card-header class="p-4 pb-2 sm:p-5 sm:pb-2">
              <div class="flex items-center justify-between">
                <div class="flex items-center gap-2">
                  <div
                    class="border-chart-1/30 bg-chart-1/10 text-chart-1 flex size-8 items-center justify-center rounded-lg border"
                  >
                    <lucide-icon [img]="Bike" class="size-4" />
                  </div>
                  <div>
                    <ui-card-title class="text-sm font-semibold">{{ bikeScore.label }}</ui-card-title>
                    <ui-card-description class="text-xs">Cycling Grid</ui-card-description>
                  </div>
                </div>
                <span ui-badge wrap variant="outline" class="text-xs font-semibold"> {{ bikeScore.score }}/100 </span>
              </div>
            </ui-card-header>
    
            <ui-card-content class="space-y-4 p-4 pt-2 sm:p-5 sm:pt-2">
              <!-- Circular Gauge & Score Verdict -->
              <div class="flex items-center gap-4 pt-1">
                <div class="relative flex size-20 shrink-0 items-center justify-center">
                  <svg class="size-20 -rotate-90" viewBox="0 0 96 96">
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      stroke="currentColor"
                      stroke-width="7"
                      class="text-muted/40"
                    />
                    <circle
                      cx="48"
                      cy="48"
                      r="38"
                      fill="none"
                      [attr.stroke]="bikeScore.gaugeColor"
                      stroke-width="7"
                      stroke-linecap="round"
                      [attr.stroke-dasharray]="gaugeCircumference"
                      [attr.stroke-dashoffset]="gaugeOffset(bikeScore.score)"
                      class="transition-colors duration-700 ease-out"
                    />
                  </svg>
                  <div class="absolute inset-0 flex flex-col items-center justify-center text-center">
                    <span class="text-foreground font-mono text-xl font-bold tracking-tight tabular-nums">
                      {{ bikeScore.score }}
                    </span>
                    <span class="text-muted-foreground text-xs font-medium">/ 100</span>
                  </div>
                </div>
    
                <div class="space-y-1">
                  <div class="text-foreground text-sm font-bold tracking-tight">
                    {{ bikeScore.verdict }}
                  </div>
                  <p class="text-chart-1 text-xs leading-relaxed font-semibold">
                    {{ bikeScore.description }}
                  </p>
                </div>
              </div>
    
              <ui-separator class="bg-border/60" />
    
              <!-- Key Highlights -->
              <div class="space-y-1.5">
                <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Cycling Highlights</p>
                <ul class="space-y-1 text-xs">
                  @for (item of bikeScore.highlights; track $index) {
                    <li class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-chart-1 size-3.5 shrink-0" />
                      <span>{{ item }}</span>
                    </li>
                  }
                </ul>
              </div>
            </ui-card-content>
          </ui-card>
        </div>
    
        <!-- Section 2: Commute Time Estimator -->
        <div class="space-y-3">
          <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h2 class="text-foreground text-sm font-semibold tracking-tight">Commute Time Estimator</h2>
              <p class="text-muted-foreground text-xs">
                Estimated travel duration from 742 Evergreen Terrace to key employment & lifestyle hubs.
              </p>
            </div>
    
            <!-- Mode selector filters -->
            <div class="border-border/80 bg-muted/40 flex items-center rounded-lg border p-1 text-xs">
              <button type="button" [class]="commuteModeClass('all')" (click)="selectedCommuteMode.set('all')">
                All Modes
              </button>
              <button
                type="button"
                [class]="commuteModeClass('transit', true)"
                (click)="selectedCommuteMode.set('transit')"
              >
                <lucide-icon [img]="Train" class="size-3" />
                <span>Transit</span>
              </button>
              <button type="button" [class]="commuteModeClass('drive', true)" (click)="selectedCommuteMode.set('drive')">
                <lucide-icon [img]="Car" class="size-3" />
                <span>Driving</span>
              </button>
            </div>
          </div>
    
          <ui-card class="border-border/80 bg-card text-card-foreground overflow-hidden shadow-xs">
            <div class="divide-border/60 divide-y">
              @for (commute of commutes; track commute.id) {
                <div class="hover:bg-muted/15 p-4 transition-colors sm:p-5">
                  <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
                    <!-- Destination Info -->
                    <div class="space-y-1">
                      <div class="flex items-center gap-2">
                        <h3 class="text-foreground text-sm font-semibold">{{ commute.name }}</h3>
                        <span class="text-muted-foreground font-mono text-xs tabular-nums">· {{ commute.distance }}</span>
                      </div>
                      <p class="text-muted-foreground text-xs">{{ commute.region }}</p>
                    </div>
    
                    <!-- Time Badges & Routes -->
                    <div class="flex flex-wrap items-center gap-3">
                      <!-- Transit Mode Pill -->
                      @if (showTransit()) {
                        <div
                          class="border-info/20 bg-info/10 flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
                        >
                          <lucide-icon [img]="Train" class="text-info size-3.5" />
                          <div>
                            <span class="text-foreground font-mono font-semibold tabular-nums">
                              {{ commute.transitLabel }}
                            </span>
                            <span class="text-muted-foreground hidden sm:inline"> — {{ commute.transitRoute }}</span>
                          </div>
                        </div>
                      }
    
                      <!-- Drive Mode Pill -->
                      @if (showDrive()) {
                        <div
                          class="border-success/20 bg-success/10 flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
                        >
                          <lucide-icon [img]="Car" class="text-success size-3.5" />
                          <div>
                            <span class="text-foreground font-mono font-semibold tabular-nums">
                              {{ commute.driveLabel }}
                            </span>
                            <span class="text-muted-foreground hidden sm:inline"> — {{ commute.driveRoute }}</span>
                          </div>
                        </div>
                      }
    
                      <!-- Peak Rush Info -->
                      @if (commute.peakRushTime) {
                        <div class="text-muted-foreground flex items-center gap-1 font-mono text-xs tabular-nums">
                          <lucide-icon [img]="Clock" class="text-muted-foreground size-3" />
                          <span>Rush: {{ commute.peakRushTime }}</span>
                        </div>
                      }
                    </div>
                  </div>
    
                  <!-- Visual Progress Bar for Comparative Duration -->
                  <div class="mt-3 grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2">
                    @if (showTransit()) {
                      <div class="space-y-1">
                        <div class="text-muted-foreground flex items-center justify-between text-xs">
                          <span>Transit Duration</span>
                          <span class="font-mono tabular-nums">{{ commute.transitTime }} min</span>
                        </div>
                        <ui-progress [value]="transitProgress(commute)" class="bg-muted h-1.5" />
                      </div>
                    }
    
                    @if (showDrive()) {
                      <div class="space-y-1">
                        <div class="text-muted-foreground flex items-center justify-between text-xs">
                          <span>Driving Duration</span>
                          <span class="font-mono tabular-nums">{{ commute.driveTime }} min</span>
                        </div>
                        <ui-progress [value]="driveProgress(commute)" class="bg-muted h-1.5" />
                      </div>
                    }
                  </div>
                </div>
              }
            </div>
          </ui-card>
        </div>
    
        <!-- Section 3: Nearby Points of Interest Categorized List -->
        <div class="space-y-3">
          <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h2 class="text-foreground text-sm font-semibold tracking-tight">Nearby Points of Interest</h2>
              <p class="text-muted-foreground text-xs">
                Key transit hubs, dining, specialty cafes, schools, and parks located within walking distance.
              </p>
            </div>
    
            <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
              <lucide-icon [img]="Navigation" class="text-primary size-3.5" />
              <span class="font-mono tabular-nums">{{ pois.length }} verified local venues</span>
            </div>
          </div>
    
          <!-- Controls: Category Filter Tabs & Search Bar -->
          <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <!-- Category Filter Tabs -->
            <div class="flex flex-wrap items-center gap-1.5">
              @for (cat of categoriesList(); track cat.id) {
                <button type="button" [class]="categoryTabClass(cat.id)" (click)="selectedCategory.set(cat.id)">
                  {{ cat.label }}
                  <span
                    [class]="
                      selectedCategory() === cat.id
                        ? 'ml-1 font-mono tabular-nums opacity-80'
                        : 'text-muted-foreground ml-1 font-mono tabular-nums'
                    "
                  >
                    ({{ categoryCount(cat.id) }})
                  </span>
                </button>
              }
            </div>
    
            <!-- Search Input -->
            <div class="relative w-full sm:w-64">
              <lucide-icon
                [img]="Search"
                class="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 z-10 size-3.5"
              />
              <ui-input
                [value]="searchQuery()"
                (valueChange)="searchQuery.set($event)"
                placeholder="Search venue or street..."
                class="h-8 w-full pl-8 text-xs"
              />
            </div>
          </div>
    
          <!-- Interactive 15-Minute Walkshed & POI Map -->
          <ui-card class="overflow-hidden">
            <ui-card-header class="border-border/70 border-b p-4 pb-3 sm:p-5 sm:pb-3">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <ui-card-title class="flex items-center gap-2 text-base font-semibold">
                    <lucide-icon [img]="Navigation" class="text-success size-4" />
                    <span>15-Minute Walkshed & Amenity Locator</span>
                  </ui-card-title>
                  <ui-card-description>
                    Free OpenStreetMap/Esri basemap — no API key — with a 15-minute walk radius and categorized local
                    destinations.
                  </ui-card-description>
                </div>
                <span ui-badge variant="outline" class="w-fit gap-1 font-mono text-xs">
                  <lucide-icon [img]="Footprints" class="text-success size-3" />
                  <span>98 Walk Score Paradise</span>
                </span>
              </div>
            </ui-card-header>
            <div class="relative h-72 w-full sm:h-96">
              <ui-leaflet-map variant="light" [center]="homeCenter" [zoom]="14" class="size-full">
                <!-- 15-min Walk Radius Circle -->
                <ui-leaflet-circle
                  [center]="homeCenter"
                  [radius]="walkRadiusMeters"
                  color="#10b981"
                  [weight]="1.5"
                  [opacity]="0.6"
                  dashArray="4 4"
                  [fill]="true"
                  fillColor="#10b981"
                  [fillOpacity]="0.12"
                />
    
                <!-- Home Property Marker -->
                <ui-leaflet-marker [lngLat]="homeCenter" anchor="center" [zIndexOffset]="30">
                  <div class="relative flex size-9 items-center justify-center">
                    <span class="bg-warning absolute inline-flex size-full animate-ping rounded-full opacity-60"></span>
                    <div
                      class="bg-warning relative flex size-7 items-center justify-center rounded-full border-2 border-white text-white shadow-lg"
                    >
                      <lucide-icon [img]="MapPin" class="size-4" />
                    </div>
                  </div>
                </ui-leaflet-marker>
    
                <!-- Filtered POI Markers -->
                @for (poi of filteredPois(); track poi.id) {
                  <ui-leaflet-marker [lngLat]="poiCoords(poi)" anchor="center">
                    <div
                      class="border-background bg-background flex size-7 cursor-pointer items-center justify-center rounded-full border-2 shadow-md select-none"
                      [attr.title]="poi.name"
                    >
                      @if (poi.category === 'dining') {
                        <lucide-icon [img]="Coffee" class="text-warning size-3.5" />
                      } @else if (poi.category === 'transit') {
                        <lucide-icon [img]="Train" class="text-info size-3.5" />
                      } @else if (poi.category === 'schools') {
                        <lucide-icon [img]="School" class="text-chart-2 size-3.5" />
                      } @else {
                        <lucide-icon [img]="TreePine" class="text-success size-3.5" />
                      }
                    </div>
                  </ui-leaflet-marker>
                }
              </ui-leaflet-map>
            </div>
          </ui-card>
    
          <!-- POIs Grid -->
          @if (filteredPois().length > 0) {
            <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
              @for (poi of filteredPois(); track poi.id) {
                <ui-card
                  class="border-border/80 bg-card text-card-foreground hover:border-border transition-colors hover:shadow-xs"
                >
                  <ui-card-content class="p-4 sm:p-4">
                    <div class="flex items-start gap-3">
                      <!-- Icon Box based on category -->
                      <div [class]="poiIconBoxClass(poi)">
                        @if (poi.category === 'transit' && poi.badge === 'Light Rail') {
                          <lucide-icon [img]="Train" class="size-4" />
                        } @else if (poi.category === 'transit') {
                          <lucide-icon [img]="Bus" class="size-4" />
                        } @else if (poi.category === 'dining' && poi.name.includes('Coffee')) {
                          <lucide-icon [img]="Coffee" class="size-4" />
                        } @else if (poi.category === 'dining') {
                          <lucide-icon [img]="UtensilsCrossed" class="size-4" />
                        } @else if (poi.category === 'schools' && poi.name.includes('Caltech')) {
                          <lucide-icon [img]="GraduationCap" class="size-4" />
                        } @else if (poi.category === 'schools') {
                          <lucide-icon [img]="School" class="size-4" />
                        } @else {
                          <lucide-icon [img]="TreePine" class="size-4" />
                        }
                      </div>
    
                      <!-- Main POI Details -->
                      <div class="min-w-0 flex-1 space-y-1">
                        <div class="flex items-start justify-between gap-2">
                          <h3 class="text-foreground truncate text-sm font-semibold">
                            {{ poi.name }}
                          </h3>
    
                          <!-- Distance / Walk Time Pill -->
                          <div
                            class="bg-muted/70 text-foreground border-border/60 shrink-0 rounded-md border px-2 py-0.5 font-mono text-xs font-medium tabular-nums"
                          >
                            <span>{{ poi.distance }}</span>
                            @if (poi.timeWalk) {
                              <span class="text-muted-foreground"> · {{ poi.timeWalk }}</span>
                            } @else if (poi.timeDrive) {
                              <span class="text-muted-foreground"> · {{ poi.timeDrive }}</span>
                            }
                          </div>
                        </div>
    
                        <p class="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
                          {{ poi.description }}
                        </p>
    
                        <div class="flex flex-wrap items-center justify-between gap-2 pt-1.5 text-xs">
                          <span class="text-muted-foreground truncate">{{ poi.address }}</span>
                          <div class="flex items-center gap-1.5">
                            @if (poi.rating) {
                              <span ui-badge wrap variant="secondary" class="font-mono text-xs font-semibold tabular-nums">
                                {{ poi.rating }}
                              </span>
                            }
                            @if (poi.badge) {
                              <span ui-badge wrap variant="outline" class="text-xs">
                                {{ poi.badge }}
                              </span>
                            }
                          </div>
                        </div>
                      </div>
                    </div>
                  </ui-card-content>
                </ui-card>
              }
            </div>
          } @else {
            <!-- Empty State when filter yields nothing -->
            <ui-card class="border-border/80 bg-card text-card-foreground p-8 text-center shadow-xs">
              <div class="mx-auto flex max-w-sm flex-col items-center justify-center space-y-2">
                <lucide-icon [img]="Search" class="text-muted-foreground size-8" />
                <p class="text-foreground text-sm font-semibold">No points of interest match "{{ searchQuery() }}"</p>
                <p class="text-muted-foreground text-xs">
                  Try searching for other amenities like "coffee", "market", "school", or clear the filter.
                </p>
                <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetFilters()">
                  Clear Filters
                </button>
              </div>
            </ui-card>
          }
        </div>
    
        <!-- Section 4: Quick Neighborhood Summary Footnote -->
        <ui-card class="border-border/80 bg-muted/25 text-card-foreground p-4 shadow-xs sm:p-5">
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex items-center gap-2">
                <lucide-icon [img]="ShieldCheck" class="text-success size-4" />
                <p class="text-foreground text-xs font-semibold">Official Location Score Intelligence Verification</p>
              </div>
              <p class="text-muted-foreground text-xs">
                Calculated based on standard 15-minute pedestrian catchment models, municipal route timetables, and GIS road
                grades.
              </p>
            </div>
    
            <div class="flex items-center gap-3">
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-8 gap-1.5 text-xs font-medium shadow-xs"
                (click)="handleCopyAddress()"
              >
                <lucide-icon [img]="ArrowUpRight" class="size-3.5" />
                <span>Open in Maps</span>
              </button>
            </div>
          </div>
        </ui-card>
      `,
    })
    export class UiLeafletNeighborhoodWalkscoreCardComponent {
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Bike = Bike
      protected readonly Bookmark = Bookmark
      protected readonly Bus = Bus
      protected readonly Car = Car
      protected readonly Check = Check
      protected readonly Clock = Clock
      protected readonly Coffee = Coffee
      protected readonly Footprints = Footprints
      protected readonly GraduationCap = GraduationCap
      protected readonly MapPin = MapPin
      protected readonly Navigation = Navigation
      protected readonly School = School
      protected readonly Search = Search
      protected readonly Share2 = Share2
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Train = Train
      protected readonly TreePine = TreePine
      protected readonly UtensilsCrossed = UtensilsCrossed
    
      @Input() title = 'Neighborhood & Location Intelligence'
      @Input() address = '742 Evergreen Terrace, Pasadena, CA 91101'
      @Input() neighborhood = 'South Lake Commercial & Arts District'
      @Input() city = 'Pasadena'
      @Input() zipCode = '91101'
      @Input() walkScore: MobilityScore = DEFAULT_WALK_SCORE
      @Input() transitScore: MobilityScore = DEFAULT_TRANSIT_SCORE
      @Input() bikeScore: MobilityScore = DEFAULT_BIKE_SCORE
      @Input() pois: PoiItem[] = DEFAULT_POIS
      @Input() commutes: CommuteDestination[] = DEFAULT_COMMUTES
      @Input('class') className?: string
    
      protected readonly homeCenter = HOME_CENTER
      protected readonly walkRadiusMeters = WALK_RADIUS_METERS
      protected readonly gaugeCircumference = GAUGE_CIRCUMFERENCE
    
      readonly selectedCategory = signal<PoiCategoryType>('all')
      readonly searchQuery = signal('')
      readonly selectedCommuteMode = signal<CommuteMode>('all')
      readonly isSaved = signal(false)
      readonly isCopied = signal(false)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => clearTimeout(this.copyTimer))
      }
    
      get hostClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      readonly categoriesList = computed<PoiCategory[]>(() => [
        { id: 'all', label: 'All Places' },
        { id: 'transit', label: 'Public Transit' },
        { id: 'dining', label: 'Dining & Coffee' },
        { id: 'schools', label: 'Schools & Education' },
        { id: 'parks', label: 'Parks & Rec' },
      ])
    
      categoryCount(id: PoiCategoryType): number {
        if (id === 'all') return this.pois.length
        return this.pois.filter((p) => p.category === id).length
      }
    
      readonly filteredPois = computed(() => {
        const cat = this.selectedCategory()
        const q = this.searchQuery().trim().toLowerCase()
        return this.pois.filter((poi) => {
          const matchesCat = cat === 'all' || poi.category === cat
          const matchesSearch =
            q === '' ||
            poi.name.toLowerCase().includes(q) ||
            poi.description.toLowerCase().includes(q) ||
            poi.address.toLowerCase().includes(q) ||
            (poi.badge?.toLowerCase().includes(q) ?? false)
          return matchesCat && matchesSearch
        })
      })
    
      showTransit(): boolean {
        return this.selectedCommuteMode() === 'all' || this.selectedCommuteMode() === 'transit'
      }
    
      showDrive(): boolean {
        return this.selectedCommuteMode() === 'all' || this.selectedCommuteMode() === 'drive'
      }
    
      gaugeOffset(score: number): number {
        return getGaugeDashOffset(score)
      }
    
      transitProgress(c: CommuteDestination): number {
        return Math.min(100, (c.transitTime / 70) * 100)
      }
    
      driveProgress(c: CommuteDestination): number {
        return Math.min(100, (c.driveTime / 70) * 100)
      }
    
      poiCoords(poi: PoiItem): [number, number] {
        if (poi.coords) return poi.coords
        return (
          DEFAULT_POI_COORDS[poi.id] ?? [
            HOME_CENTER[0] + Math.sin(poi.name.length) * 0.008,
            HOME_CENTER[1] + Math.cos(poi.name.length) * 0.008,
          ]
        )
      }
    
      categoryTabClass(id: PoiCategoryType): string {
        return cn(
          'focus-visible:ring-ring rounded-md border px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.selectedCategory() === id
            ? 'border-primary bg-primary text-primary-foreground shadow-xs'
            : 'border-border/80 bg-card text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      commuteModeClass(mode: CommuteMode, withIcon = false): string {
        return cn(
          'focus-visible:ring-ring rounded-md px-2.5 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          withIcon && 'flex items-center gap-1',
          this.selectedCommuteMode() === mode
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      poiIconBoxClass(poi: PoiItem): string {
        return cn(
          'flex size-9 shrink-0 items-center justify-center rounded-lg border',
          poi.category === 'transit' && 'border-info/30 bg-info/10 text-info',
          poi.category === 'dining' && 'border-warning/30 bg-warning/10 text-warning',
          poi.category === 'schools' && 'border-chart-2/30 bg-chart-2/10 text-chart-2',
          poi.category === 'parks' && 'border-success/30 bg-success/10 text-success',
        )
      }
    
      resetFilters(): void {
        this.searchQuery.set('')
        this.selectedCategory.set('all')
      }
    
      handleCopyAddress(): void {
        try {
          void navigator.clipboard?.writeText(`${this.address}, ${this.neighborhood}`)
          this.isCopied.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.isCopied.set(false), 2000)
        } catch {
          // Clipboard unavailable (non-secure context) — leave the button state alone.
        }
      }
    
      handleToggleSave(): void {
        this.isSaved.set(!this.isSaved())
      }
    }
    
  • src/app/components/blocks/leaflet-neighborhood-walkscore-card/index.ts0.4 kB

Raw manifest:https://uipkge.dev/r/angular/leaflet-neighborhood-walkscore-card.json