Leaflet Neighborhood Walkscore Card
blockreal-estateReal 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
$pnpm dlx uipkge-ng@latest add leaflet-neighborhood-walkscore-card$npx uipkge-ng@latest add leaflet-neighborhood-walkscore-card$yarn dlx uipkge-ng@latest add leaflet-neighborhood-walkscore-card$bunx uipkge-ng@latest add leaflet-neighborhood-walkscore-cardManifest:
https://uipkge.dev/r/angular/leaflet-neighborhood-walkscore-card.jsonInstalls to:src/app/components/blocks/leaflet-neighborhood-walkscore-card/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
MobilityScoreinterface MobilityScore {
score: number
maxScore?: number
label: string
verdict: string
description: string
verdictClass: string
gaugeColor: string
subtext: string
highlights: string[]
}PoiIteminterface 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]
}CommuteDestinationinterface CommuteDestination {
id: string
name: string
region: string
distance: string
transitTime: number
transitLabel: string
transitRoute: string
driveTime: number
driveLabel: string
driveRoute: string
peakRushTime?: string
}PoiCategoryinterface PoiCategory {
id: PoiCategoryType
label: string
}npm dependencies
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--chart-1Files 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
export { UiLeafletNeighborhoodWalkscoreCardComponent, HOME_CENTER, WALK_RADIUS_METERS, DEFAULT_WALK_SCORE, DEFAULT_TRANSIT_SCORE, DEFAULT_BIKE_SCORE, DEFAULT_POIS, DEFAULT_COMMUTES, GAUGE_CIRCUMFERENCE, getGaugeDashOffset, type MobilityScore, type PoiItem, type PoiCategoryType, type CommuteDestination, type CommuteMode, } from './leaflet-neighborhood-walkscore-card.component'
Raw manifest:https://uipkge.dev/r/angular/leaflet-neighborhood-walkscore-card.json