
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-neighborhood-walkscore-card.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-neighborhood-walkscore-card.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-neighborhood-walkscore-card.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-neighborhood-walkscore-card.jsonnpx shadcn-vue@latest add @uipkge/leaflet-neighborhood-walkscore-cardInstalls to:app/components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | 'Neighborhood & Location Intelligence' | optional |
address | string | '742 Evergreen Terrace | optional |
neighborhood | string | 'South Lake Commercial & Arts District' | optional |
city | string | 'Pasadena' | optional |
zipCode | string | '91101' | optional |
walkScore | MobilityScore | — | optional |
transitScore | MobilityScore | — | optional |
bikeScore | MobilityScore | — | optional |
pois | PoiItem[] | — | optional |
commutes | CommuteDestination[] | — | optional |
class | HTMLAttributes['class'] | — | optional |
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
}CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--chart-1<script setup lang="ts">
import { ref, computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import {
Footprints,
Bus,
Bike,
MapPin,
Search,
Navigation,
School,
TreePine,
Coffee,
GraduationCap,
UtensilsCrossed,
Car,
Train,
Clock,
Share2,
Bookmark,
Check,
ArrowUpRight,
ShieldCheck,
} from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { LeafletCircle, LeafletMap, LeafletMarker } from '@/components/ui/leaflet-map'
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 interface LeafletNeighborhoodWalkscoreCardProps {
title?: string
address?: string
neighborhood?: string
city?: string
zipCode?: string
walkScore?: MobilityScore
transitScore?: MobilityScore
bikeScore?: MobilityScore
pois?: PoiItem[]
commutes?: CommuteDestination[]
class?: HTMLAttributes['class']
}
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 props = withDefaults(defineProps<LeafletNeighborhoodWalkscoreCardProps>(), {
title: 'Neighborhood & Location Intelligence',
address: '742 Evergreen Terrace, Pasadena, CA 91101',
neighborhood: 'South Lake Commercial & Arts District',
city: 'Pasadena',
zipCode: '91101',
})
const activeWalkScore = computed(() => props.walkScore ?? defaultWalkScore)
const activeTransitScore = computed(() => props.transitScore ?? defaultTransitScore)
const activeBikeScore = computed(() => props.bikeScore ?? defaultBikeScore)
const activePois = computed(() => props.pois ?? defaultPois)
const activeCommutes = computed(() => props.commutes ?? defaultCommutes)
const selectedCategory = ref<PoiCategoryType>('all')
const searchQuery = ref('')
const selectedCommuteMode = ref<CommuteMode>('all')
const isSaved = ref(false)
const isCopied = ref(false)
function resetFilters() {
searchQuery.value = ''
selectedCategory.value = 'all'
}
const categoriesList = computed(() => [
{ id: 'all' as const, label: 'All Places', count: activePois.value.length },
{
id: 'transit' as const,
label: 'Public Transit',
count: activePois.value.filter((p) => p.category === 'transit').length,
},
{
id: 'dining' as const,
label: 'Dining & Coffee',
count: activePois.value.filter((p) => p.category === 'dining').length,
},
{
id: 'schools' as const,
label: 'Schools & Education',
count: activePois.value.filter((p) => p.category === 'schools').length,
},
{
id: 'parks' as const,
label: 'Parks & Rec',
count: activePois.value.filter((p) => p.category === 'parks').length,
},
])
const filteredPois = computed(() => {
return activePois.value.filter((poi) => {
const matchesCat = selectedCategory.value === 'all' || poi.category === selectedCategory.value
const matchesSearch =
searchQuery.value.trim() === '' ||
poi.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
poi.description.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
poi.address.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(poi.badge && poi.badge.toLowerCase().includes(searchQuery.value.toLowerCase()))
return matchesCat && matchesSearch
})
})
function handleCopyAddress() {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
navigator.clipboard.writeText(`${props.address}, ${props.neighborhood}`)
isCopied.value = true
setTimeout(() => {
isCopied.value = false
}, 2000)
}
}
function handleToggleSave() {
isSaved.value = !isSaved.value
}
// Circular gauge calculation helper (radius 38, perimeter ~238.76)
const circumference = 2 * Math.PI * 38
function getGaugeDashOffset(score: number): number {
const clamped = Math.min(100, Math.max(0, score))
return circumference - (clamped / 100) * circumference
}
// Subject property (742 Evergreen Terrace, Pasadena) — map center, home marker,
// and the 15-minute walkshed circle all anchor here.
const HOME_CENTER: [number, number] = [-118.1445, 34.1478]
// ~15-minute pedestrian catchment (~1.2 km at typical walking speed).
const WALK_RADIUS_METERS = 1200
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],
}
function getPoiCoords(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,
]
)
}
</script>
<template>
<div :class="cn('w-full space-y-6', props.class)" data-slot="leaflet-neighborhood-walkscore-card">
<!-- Header Banner Card -->
<Card class="border-border/80 bg-card text-card-foreground shadow-xs">
<CardHeader 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">
<Badge wrap variant="outline" class="gap-1.5 font-medium">
<MapPin class="text-primary size-3.5" />
<span>{{ props.city }} · {{ props.zipCode }}</span>
</Badge>
<Badge wrap variant="success" class="gap-1 font-medium">
<Footprints class="size-3.5" />
<span>Walker's Paradise</span>
</Badge>
<Badge wrap variant="secondary" class="font-medium"> Urban Transit Hub </Badge>
</div>
<!-- Title & Address -->
<div>
<h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
{{ props.title }}
</h1>
<p class="text-foreground mt-1 text-sm font-semibold sm:text-base">
{{ props.address }}
</p>
<p class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
Neighborhood: <span class="text-foreground font-medium">{{ props.neighborhood }}</span>
</p>
</div>
</div>
<!-- Top Actions -->
<div class="flex flex-wrap items-center gap-2 self-start lg:self-auto">
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 text-xs font-medium shadow-xs"
@click="handleCopyAddress"
>
<Check v-if="isCopied" class="text-success size-3.5" />
<Share2 v-else class="size-3.5" />
<span>{{ isCopied ? 'Address Copied' : 'Share Location' }}</span>
</Button>
<Button
:variant="isSaved ? 'default' : 'outline'"
size="sm"
class="h-8 gap-1.5 text-xs font-medium shadow-xs"
@click="handleToggleSave"
>
<Bookmark :class="['size-3.5', isSaved ? 'fill-current' : '']" />
<span>{{ isSaved ? 'Saved Neighborhood' : 'Save Area' }}</span>
</Button>
</div>
</div>
</CardHeader>
</Card>
<!-- Section 1: 3 Primary Mobility Score Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- Card 1: Walk Score -->
<Card class="border-border/80 bg-card text-card-foreground hover:border-success/40 shadow-xs transition-colors">
<CardHeader 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"
>
<Footprints class="size-4" />
</div>
<div>
<CardTitle class="text-sm font-semibold">{{ activeWalkScore.label }}</CardTitle>
<CardDescription class="text-xs">Pedestrian Index</CardDescription>
</div>
</div>
<Badge wrap variant="success" class="text-xs font-semibold"> {{ activeWalkScore.score }}/100 </Badge>
</div>
</CardHeader>
<CardContent 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"
:stroke="activeWalkScore.gaugeColor"
stroke-width="7"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="getGaugeDashOffset(activeWalkScore.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">
{{ activeWalkScore.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">
{{ activeWalkScore.verdict }}
</div>
<p class="text-success text-xs leading-relaxed font-semibold">
{{ activeWalkScore.description }}
</p>
</div>
</div>
<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">
<li
v-for="(item, idx) in activeWalkScore.highlights"
:key="idx"
class="text-foreground flex items-center gap-2"
>
<Check class="text-success size-3.5 shrink-0" />
<span>{{ item }}</span>
</li>
</ul>
</div>
</CardContent>
</Card>
<!-- Card 2: Transit Score -->
<Card class="border-border/80 bg-card text-card-foreground hover:border-info/40 shadow-xs transition-colors">
<CardHeader 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"
>
<Bus class="size-4" />
</div>
<div>
<CardTitle class="text-sm font-semibold">{{ activeTransitScore.label }}</CardTitle>
<CardDescription class="text-xs">Transit Connectivity</CardDescription>
</div>
</div>
<Badge wrap variant="info" class="text-xs font-semibold"> {{ activeTransitScore.score }}/100 </Badge>
</div>
</CardHeader>
<CardContent 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"
:stroke="activeTransitScore.gaugeColor"
stroke-width="7"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="getGaugeDashOffset(activeTransitScore.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">
{{ activeTransitScore.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">
{{ activeTransitScore.verdict }}
</div>
<p class="text-info text-xs leading-relaxed font-semibold">
{{ activeTransitScore.description }}
</p>
</div>
</div>
<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">
<li
v-for="(item, idx) in activeTransitScore.highlights"
:key="idx"
class="text-foreground flex items-center gap-2"
>
<Check class="text-info size-3.5 shrink-0" />
<span>{{ item }}</span>
</li>
</ul>
</div>
</CardContent>
</Card>
<!-- Card 3: Bike Score -->
<Card class="border-border/80 bg-card text-card-foreground hover:border-chart-1/40 shadow-xs transition-colors">
<CardHeader 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"
>
<Bike class="size-4" />
</div>
<div>
<CardTitle class="text-sm font-semibold">{{ activeBikeScore.label }}</CardTitle>
<CardDescription class="text-xs">Cycling Grid</CardDescription>
</div>
</div>
<Badge wrap variant="outline" class="text-xs font-semibold"> {{ activeBikeScore.score }}/100 </Badge>
</div>
</CardHeader>
<CardContent 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"
:stroke="activeBikeScore.gaugeColor"
stroke-width="7"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="getGaugeDashOffset(activeBikeScore.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">
{{ activeBikeScore.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">
{{ activeBikeScore.verdict }}
</div>
<p class="text-chart-1 text-xs leading-relaxed font-semibold">
{{ activeBikeScore.description }}
</p>
</div>
</div>
<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">
<li
v-for="(item, idx) in activeBikeScore.highlights"
:key="idx"
class="text-foreground flex items-center gap-2"
>
<Check class="text-chart-1 size-3.5 shrink-0" />
<span>{{ item }}</span>
</li>
</ul>
</div>
</CardContent>
</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="[
'focus-visible:ring-ring rounded-md px-2.5 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
selectedCommuteMode === 'all'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
]"
@click="selectedCommuteMode = 'all'"
>
All Modes
</button>
<button
type="button"
:class="[
'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',
selectedCommuteMode === 'transit'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
]"
@click="selectedCommuteMode = 'transit'"
>
<Train class="size-3" />
<span>Transit</span>
</button>
<button
type="button"
:class="[
'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',
selectedCommuteMode === 'drive'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
]"
@click="selectedCommuteMode = 'drive'"
>
<Car class="size-3" />
<span>Driving</span>
</button>
</div>
</div>
<Card class="border-border/80 bg-card text-card-foreground overflow-hidden shadow-xs">
<div class="divide-border/60 divide-y">
<div
v-for="commute in activeCommutes"
:key="commute.id"
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 -->
<div
v-if="selectedCommuteMode === 'all' || selectedCommuteMode === 'transit'"
class="border-info/20 bg-info/10 flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
>
<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 -->
<div
v-if="selectedCommuteMode === 'all' || selectedCommuteMode === 'drive'"
class="border-success/20 bg-success/10 flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
>
<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 -->
<div
v-if="commute.peakRushTime"
class="text-muted-foreground flex items-center gap-1 font-mono text-xs tabular-nums"
>
<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">
<div v-if="selectedCommuteMode === 'all' || selectedCommuteMode === 'transit'" 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>
<Progress :model-value="Math.min(100, (commute.transitTime / 70) * 100)" class="bg-muted h-1.5" />
</div>
<div v-if="selectedCommuteMode === 'all' || selectedCommuteMode === 'drive'" 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>
<Progress :model-value="Math.min(100, (commute.driveTime / 70) * 100)" class="bg-muted h-1.5" />
</div>
</div>
</div>
</div>
</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">
<Navigation class="text-primary size-3.5" />
<span class="font-mono tabular-nums">{{ activePois.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">
<button
v-for="cat in categoriesList"
:key="cat.id"
type="button"
:class="[
'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',
selectedCategory === cat.id
? 'border-primary bg-primary text-primary-foreground shadow-xs'
: 'border-border/80 bg-card text-muted-foreground hover:bg-muted hover:text-foreground',
]"
@click="selectedCategory = cat.id"
>
{{ cat.label }}
<span
:class="[
'ml-1 font-mono tabular-nums',
selectedCategory === cat.id ? 'opacity-80' : 'text-muted-foreground',
]"
>
({{ cat.count }})
</span>
</button>
</div>
<!-- Search Input -->
<div class="relative w-full sm:w-64">
<Search class="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 size-3.5" />
<input
v-model="searchQuery"
type="text"
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 -->
<Card class="overflow-hidden">
<CardHeader 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>
<CardTitle class="flex items-center gap-2 text-base font-semibold">
<Navigation class="text-success size-4" />
<span>15-Minute Walkshed & Amenity Locator</span>
</CardTitle>
<CardDescription>
Free OpenStreetMap/Esri basemap — no API key — with a 15-minute walk radius and categorized local
destinations.
</CardDescription>
</div>
<Badge variant="outline" class="w-fit gap-1 font-mono text-xs">
<Footprints class="text-success size-3" />
<span>98 Walk Score Paradise</span>
</Badge>
</div>
</CardHeader>
<div class="relative h-72 w-full sm:h-96">
<LeafletMap variant="light" :center="HOME_CENTER" :zoom="14" class="size-full">
<!-- 15-min Walk Radius Circle -->
<LeafletCircle
:center="HOME_CENTER"
:radius="WALK_RADIUS_METERS"
color="#10b981"
:weight="1.5"
:opacity="0.6"
dash-array="4 4"
:fill="true"
fill-color="#10b981"
:fill-opacity="0.12"
/>
<!-- Home Property Marker -->
<LeafletMarker :lng-lat="HOME_CENTER" anchor="center" :z-index-offset="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" />
<div
class="bg-warning relative flex size-7 items-center justify-center rounded-full border-2 border-white text-white shadow-lg"
>
<MapPin class="size-4" />
</div>
</div>
</LeafletMarker>
<!-- Filtered POI Markers -->
<LeafletMarker v-for="poi in filteredPois" :key="poi.id" :lng-lat="getPoiCoords(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"
:title="poi.name"
>
<Coffee v-if="poi.category === 'dining'" class="text-warning size-3.5" />
<Train v-else-if="poi.category === 'transit'" class="text-info size-3.5" />
<School v-else-if="poi.category === 'schools'" class="text-chart-2 size-3.5" />
<TreePine v-else class="text-success size-3.5" />
</div>
</LeafletMarker>
</LeafletMap>
</div>
</Card>
<!-- POIs Grid -->
<div v-if="filteredPois.length > 0" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<Card
v-for="poi in filteredPois"
:key="poi.id"
class="border-border/80 bg-card text-card-foreground hover:border-border transition-colors hover:shadow-xs"
>
<CardContent class="p-4 sm:p-4">
<div class="flex items-start gap-3">
<!-- Icon Box based on category -->
<div
:class="[
'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'
: 'border-success/30 bg-success/10 text-success',
]"
>
<Train v-if="poi.category === 'transit' && poi.badge === 'Light Rail'" class="size-4" />
<Bus v-else-if="poi.category === 'transit'" class="size-4" />
<Coffee v-else-if="poi.category === 'dining' && poi.name.includes('Coffee')" class="size-4" />
<UtensilsCrossed v-else-if="poi.category === 'dining'" class="size-4" />
<GraduationCap v-else-if="poi.category === 'schools' && poi.name.includes('Caltech')" class="size-4" />
<School v-else-if="poi.category === 'schools'" class="size-4" />
<TreePine v-else 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>
<span v-if="poi.timeWalk" class="text-muted-foreground"> · {{ poi.timeWalk }}</span>
<span v-else-if="poi.timeDrive" 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">
<Badge
wrap
v-if="poi.rating"
variant="secondary"
class="font-mono text-xs font-semibold tabular-nums"
>
{{ poi.rating }}
</Badge>
<Badge wrap v-if="poi.badge" variant="outline" class="text-xs">
{{ poi.badge }}
</Badge>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Empty State when filter yields nothing -->
<Card v-else 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">
<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 variant="outline" size="sm" class="mt-2 text-xs" @click="resetFilters"> Clear Filters </Button>
</div>
</Card>
</div>
<!-- Section 4: Quick Neighborhood Summary Footnote -->
<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">
<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
variant="outline"
size="sm"
class="h-8 gap-1.5 text-xs font-medium shadow-xs"
@click="handleCopyAddress"
>
<ArrowUpRight class="size-3.5" />
<span>Open in Maps</span>
</Button>
</div>
</div>
</Card>
</div>
</template>
Raw manifest:https://uipkge.dev/r/vue/leaflet-neighborhood-walkscore-card.json