Menu

Change language

GitHub repo

Neighborhood Walkscore Card

blockreal-estate

Real estate neighborhood intelligence widget with Walk Score, Transit Score, Bike Score, categorized points of interest (transit, dining, schools, parks), and commute time estimator.

Installation

$npx uipkge-ng@latest add neighborhood-walkscore-card
Manifest:https://uipkge.dev/r/angular/neighborhood-walkscore-card.jsonInstalls to:src/app/components/blocks/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
}

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/neighborhood-walkscore-card/neighborhood-walkscore-card.component.ts47.1 kB
    // Real estate neighborhood intelligence widget: Walk/Transit/Bike score gauges,
    // commute time estimator, categorized points of interest with search, and a Mapbox
    // 15-minute walkshed with amenity markers. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, 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 { UiMapComponent, UiMapLayerComponent, UiMapMarkerComponent, UiMapSourceComponent } from '@/ui/map/map.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.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
    }
    
    const defaultWalkScore: 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+'],
    }
    
    const defaultTransitScore: 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'],
    }
    
    const defaultBikeScore: 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'],
    }
    
    const defaultPois: 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',
      },
    ]
    
    const defaultCommutes: 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',
      },
    ]
    
    const defaultPoiCoords: 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],
    }
    
    const HOME_CENTER: [number, number] = [-118.138, 34.145]
    
    /** Circular gauge geometry (radius 38, perimeter ~238.76). Exported for specs. */
    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
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-neighborhood-walkscore-card, [ui-neighborhood-walkscore-card]',
      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: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
        UiMapLayerComponent,
        UiMapMarkerComponent,
        UiMapSourceComponent,
        UiProgressComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="neighborhood-walkscore-card" [class]="rootClass">
          <!-- Header Banner Card -->
          <div ui-card class="border-border/80 bg-card text-card-foreground shadow-xs">
            <div 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 }} &middot; {{ 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)="isSaved.set(!isSaved())"
                  >
                    <lucide-icon [img]="Bookmark" [class]="isSaved() ? 'size-3.5 fill-current' : 'size-3.5'" />
                    <span>{{ isSaved() ? 'Saved Neighborhood' : 'Save Area' }}</span>
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Section 1: 3 Primary Mobility Score Cards -->
          <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
            <!-- Card 1: Walk Score -->
            <div
              ui-card
              class="border-border/80 bg-card text-card-foreground hover:border-success/40 shadow-xs transition-colors"
            >
              <div 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>
                      <h3 ui-card-title class="text-sm font-semibold">{{ walkScore.label }}</h3>
                      <p ui-card-description class="text-xs">Pedestrian Index</p>
                    </div>
                  </div>
                  <span ui-badge wrap variant="success" class="text-xs font-semibold">{{ walkScore.score }}/100</span>
                </div>
              </div>
    
              <div 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 item) {
                      <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>
              </div>
            </div>
    
            <!-- Card 2: Transit Score -->
            <div
              ui-card
              class="border-border/80 bg-card text-card-foreground hover:border-info/40 shadow-xs transition-colors"
            >
              <div 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>
                      <h3 ui-card-title class="text-sm font-semibold">{{ transitScore.label }}</h3>
                      <p ui-card-description class="text-xs">Transit Connectivity</p>
                    </div>
                  </div>
                  <span ui-badge wrap variant="info" class="text-xs font-semibold">{{ transitScore.score }}/100</span>
                </div>
              </div>
    
              <div 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 item) {
                      <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>
              </div>
            </div>
    
            <!-- Card 3: Bike Score -->
            <div
              ui-card
              class="border-border/80 bg-card text-card-foreground hover:border-chart-1/40 shadow-xs transition-colors"
            >
              <div 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>
                      <h3 ui-card-title class="text-sm font-semibold">{{ bikeScore.label }}</h3>
                      <p ui-card-description class="text-xs">Cycling Grid</p>
                    </div>
                  </div>
                  <span ui-badge wrap variant="outline" class="text-xs font-semibold">{{ bikeScore.score }}/100</span>
                </div>
              </div>
    
              <div 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 item) {
                      <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>
              </div>
            </div>
          </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]="commuteModeIconClass('transit')"
                  (click)="selectedCommuteMode.set('transit')"
                >
                  <lucide-icon [img]="Train" class="size-3" />
                  <span>Transit</span>
                </button>
                <button type="button" [class]="commuteModeIconClass('drive')" (click)="selectedCommuteMode.set('drive')">
                  <lucide-icon [img]="Car" class="size-3" />
                  <span>Driving</span>
                </button>
              </div>
            </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">
                            &middot; {{ 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 (selectedCommuteMode() === 'all' || selectedCommuteMode() === 'transit') {
                          <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 (selectedCommuteMode() === 'all' || selectedCommuteMode() === 'drive') {
                          <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 (selectedCommuteMode() === 'all' || selectedCommuteMode() === 'transit') {
                        <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]="commutePct(commute.transitTime)" class="bg-muted h-1.5" />
                        </div>
                      }
    
                      @if (selectedCommuteMode() === 'all' || selectedCommuteMode() === 'drive') {
                        <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]="commutePct(commute.driveTime)" class="bg-muted h-1.5" />
                        </div>
                      }
                    </div>
                  </div>
                }
              </div>
            </div>
          </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]="categoryClass(cat.id)" (click)="selectedCategory.set(cat.id)">
                    {{ cat.label }}
                    <span [class]="categoryCountClass(cat.id)">({{ cat.count }})</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 size-3.5"
                />
                <input
                  type="text"
                  [value]="searchQuery()"
                  (input)="searchQuery.set($any($event.target).value)"
                  placeholder="Search venue or street..."
                  class="border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-8 w-full rounded-md border pr-3 pl-8 text-xs focus-visible:ring-2 focus-visible:outline-none"
                />
              </div>
            </div>
    
            <!-- Interactive 15-Minute Walkshed & POI Map -->
            <div ui-card class="overflow-hidden">
              <div 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>
                    <h3 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>
                    </h3>
                    <p ui-card-description>
                      Mapbox street basemap with 15-minute isochrone walking perimeter and categorized local destinations.
                    </p>
                  </div>
                  <span ui-badge wrap 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>
              </div>
              <div class="relative h-72 w-full sm:h-96">
                <ui-map
                  variant="dark"
                  [accessToken]="accessToken ?? ''"
                  [center]="homeCenter"
                  [zoom]="14.2"
                  class="size-full"
                >
                  <!-- Isochrone 15-min Walk Circle -->
                  <ui-map-source id="walkshed-source" type="geojson" [data]="walkshedGeoJson()">
                    <ui-map-layer id="walkshed-fill" type="fill" [paint]="walkshedFillPaint" />
                    <ui-map-layer id="walkshed-line" type="line" [paint]="walkshedLinePaint" />
                  </ui-map-source>
    
                  <!-- Home Property Marker -->
                  <ui-map-marker [lngLat]="homeCenter" anchor="center">
                    <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-map-marker>
    
                  <!-- Filtered POI Markers -->
                  @for (poi of filteredPois(); track poi.id) {
                    <ui-map-marker [lngLat]="poiCoords(poi)" anchor="center">
                      <div
                        class="border-background bg-background flex size-7 items-center justify-center rounded-full border-2 shadow-md"
                        [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-map-marker>
                  }
                </ui-map>
              </div>
            </div>
    
            <!-- 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) {
                  <div
                    ui-card
                    class="border-border/80 bg-card text-card-foreground hover:border-border transition-colors hover:shadow-xs"
                  >
                    <div ui-card-content class="p-4 sm:p-4">
                      <div class="flex items-start gap-3">
                        <!-- Icon Box based on category -->
                        <div [class]="poiIconClass(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"> &middot; {{ poi.timeWalk }}</span>
                              }
                              @if (poi.timeDrive) {
                                <span class="text-muted-foreground"> &middot; {{ 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>
                    </div>
                  </div>
                }
              </div>
            } @else {
              <!-- Empty State when filter yields nothing -->
              <div 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)="clearFilters()">
                    Clear Filters
                  </button>
                </div>
              </div>
            }
          </div>
    
          <!-- Section 4: Quick Neighborhood Summary Footnote -->
          <div 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>
          </div>
        </div>
      `,
    })
    export class UiNeighborhoodWalkscoreCardComponent {
      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
    
      protected readonly gaugeCircumference = GAUGE_CIRCUMFERENCE
      protected readonly homeCenter = HOME_CENTER
    
      protected readonly walkshedFillPaint = { 'fill-color': 'rgba(16, 185, 129, 0.12)' }
      protected readonly walkshedLinePaint = {
        'line-color': 'rgba(16, 185, 129, 0.6)',
        'line-width': 1.5,
        'line-dasharray': [2, 2],
      }
    
      @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 = defaultWalkScore
      @Input() transitScore: MobilityScore = defaultTransitScore
      @Input() bikeScore: MobilityScore = defaultBikeScore
      @Input() pois: PoiItem[] = defaultPois
      @Input() commutes: CommuteDestination[] = defaultCommutes
      @Input() accessToken?: string
      @Input('class') className?: string
    
      readonly selectedCategory = signal<PoiCategoryType>('all')
      readonly searchQuery = signal('')
      readonly selectedCommuteMode = signal<CommuteMode>('all')
      readonly isSaved = signal(false)
      readonly isCopied = signal(false)
    
      readonly categoriesList = computed(() => {
        const pois = this.pois
        return [
          { id: 'all' as const, label: 'All Places', count: pois.length },
          { id: 'transit' as const, label: 'Public Transit', count: pois.filter((p) => p.category === 'transit').length },
          { id: 'dining' as const, label: 'Dining & Coffee', count: pois.filter((p) => p.category === 'dining').length },
          {
            id: 'schools' as const,
            label: 'Schools & Education',
            count: pois.filter((p) => p.category === 'schools').length,
          },
          { id: 'parks' as const, label: 'Parks & Rec', count: pois.filter((p) => p.category === 'parks').length },
        ]
      })
    
      readonly filteredPois = computed(() => {
        const pois = this.pois
        const selected = this.selectedCategory()
        const query = this.searchQuery().trim().toLowerCase()
        return pois.filter((poi) => {
          const matchesCat = selected === 'all' || poi.category === selected
          const matchesSearch =
            query === '' ||
            poi.name.toLowerCase().includes(query) ||
            poi.description.toLowerCase().includes(query) ||
            poi.address.toLowerCase().includes(query) ||
            (poi.badge?.toLowerCase().includes(query) ?? false)
          return matchesCat && matchesSearch
        })
      })
    
      readonly walkshedGeoJson = computed(() => {
        const [centerLng, centerLat] = HOME_CENTER
        const radius = 0.011
        const points = 36
        const coordinates: [number, number][] = []
        for (let i = 0; i <= points; i++) {
          const angle = (i * 2 * Math.PI) / points
          coordinates.push([centerLng + Math.cos(angle) * radius * 1.2, centerLat + Math.sin(angle) * radius])
        }
        return {
          type: 'Feature' as const,
          properties: {},
          geometry: { type: 'Polygon' as const, coordinates: [coordinates] },
        }
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      gaugeOffset(score: number): number {
        return getGaugeDashOffset(score)
      }
    
      commutePct(minutes: number): number {
        return Math.min(100, (minutes / 70) * 100)
      }
    
      categoryCount(id: PoiCategoryType): number {
        return this.categoriesList().find((c) => c.id === id)?.count ?? 0
      }
    
      categoryClass(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',
        )
      }
    
      categoryCountClass(id: PoiCategoryType): string {
        return cn('ml-1 font-mono tabular-nums', this.selectedCategory() === id ? 'opacity-80' : 'text-muted-foreground')
      }
    
      commuteModeClass(mode: CommuteMode): 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',
          this.selectedCommuteMode() === mode
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      commuteModeIconClass(mode: CommuteMode): string {
        return cn(
          'focus-visible:ring-ring flex items-center gap-1 rounded-md px-2.5 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.selectedCommuteMode() === mode
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      poiIconClass(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',
        )
      }
    
      poiCoords(poi: PoiItem): [number, number] {
        if (poi.coords) return poi.coords
        return (
          defaultPoiCoords[poi.id] ?? [
            HOME_CENTER[0] + Math.sin(poi.name.length) * 0.008,
            HOME_CENTER[1] + Math.cos(poi.name.length) * 0.008,
          ]
        )
      }
    
      clearFilters(): void {
        this.searchQuery.set('')
        this.selectedCategory.set('all')
      }
    
      handleCopyAddress(): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(`${this.address}, ${this.neighborhood}`)
          this.isCopied.set(true)
          setTimeout(() => this.isCopied.set(false), 2000)
        }
      }
    }
    
  • src/app/components/blocks/neighborhood-walkscore-card/index.ts0.2 kB

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