Neighborhood Walkscore Card
blockreal-estateReal estate neighborhood intelligence widget with Walk Score, Transit Score, Bike Score, categorized points of interest (transit, dining, schools, parks), and commute time estimator.
Installation
$pnpm dlx uipkge-ng@latest add neighborhood-walkscore-card$npx uipkge-ng@latest add neighborhood-walkscore-card$yarn dlx uipkge-ng@latest add neighborhood-walkscore-card$bunx uipkge-ng@latest add neighborhood-walkscore-cardManifest:
https://uipkge.dev/r/angular/neighborhood-walkscore-card.jsonInstalls to:src/app/components/blocks/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
}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/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 }} · {{ 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"> · {{ 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"> · {{ poi.timeWalk }}</span> } @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> </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
export { UiNeighborhoodWalkscoreCardComponent, getGaugeDashOffset, GAUGE_CIRCUMFERENCE, type PoiCategoryType, type CommuteMode, type MobilityScore, type PoiItem, type CommuteDestination, } from './neighborhood-walkscore-card.component'
Raw manifest:https://uipkge.dev/r/angular/neighborhood-walkscore-card.json