--- title: Calculate a Walkability Score | Plaza Docs description: A TypeScript CLI that geocodes any address, counts nearby amenities by category with distance decay, and computes a weighted walkability score from 0 to 100. --- Walk Score charges money and it’s a black box. Let’s build our own — transparent, tweakable, and free. Given any address, we count amenities within walking distance across six categories and compute a weighted score from 0 to 100. ## What you’ll use - **Geocoding** to turn an address into coordinates - **PlazaQL** to count amenities in each walking zone - Distance decay weighting so closer amenities count more ## Key code Six amenity categories define what matters for daily life on foot. Each has tags to query and a weight in the final score: ``` const CATEGORIES = [ { name: "Grocery", weight: 20, tags: ["shop=supermarket", "shop=convenience", "shop=greengrocer"] }, { name: "Transit", weight: 20, tags: ["highway=bus_stop", "railway=station", "railway=tram_stop"] }, { name: "Dining", weight: 15, tags: ["amenity=restaurant", "amenity=cafe", "amenity=fast_food"] }, { name: "Parks", weight: 15, tags: ["leisure=park", "leisure=playground", "leisure=garden"] }, { name: "Schools", weight: 15, tags: ["amenity=school", "amenity=kindergarten", "amenity=library"] }, { name: "Errands", weight: 15, tags: ["amenity=pharmacy", "amenity=bank", "amenity=post_office"] }, ]; ``` For each category, a PlazaQL query finds all matching nodes within a 1200m radius (a 15-minute walk). All six queries run in parallel: ``` const categoryResults = await Promise.all( CATEGORIES.map(async (cat) => { const searches = cat.tags.map((tag) => { const [key, value] = tag.split("="); return `search(node, ${key}: "${value}").around(distance: 1200, geometry: point(${lat}, ${lng}))`; }); const query = searches .map((s, i) => `$s${i} = ${s};`) .join("\n") + `\n$$ = ${searches.map((_, i) => `$s${i}`).join(" + ")};`; const result = await plaza.query(query); return { category: cat, features: result.features }; }) ); ``` Each result is bucketed into walking zones using haversine distance, with closer amenities weighted higher: ``` const ZONE_RADII = { near: 400, medium: 800, far: 1200 }; // meters const ZONE_WEIGHTS = { near: 3, medium: 2, far: 1 }; // multipliers for (const feature of features) { const dist = haversine(lat, lng, featureLat, featureLng); if (dist <= 400) counts.near++; else if (dist <= 800) counts.medium++; else if (dist <= 1200) counts.far++; } const weightedCount = counts.near * 3 + counts.medium * 2 + counts.far * 1; ``` Category scores use an exponential curve for diminishing returns — 20 grocery stores isn’t meaningfully better than 3: ``` const score = Math.min(100, 100 * (1 - Math.exp(-weightedCount / 15))); ``` The final walkability score is a weighted average across all categories. ## How it works The core idea is **distance decay**. A grocery store across the street (5-minute walk, 3x weight) is far more useful than one 15 minutes away (1x weight). The exponential scoring curve means the first few amenities in a category matter most, and additional ones have diminishing returns. | Walk time | Distance | Multiplier | | --------- | -------- | ---------- | | 5 min | 400m | 3x | | 10 min | 800m | 2x | | 15 min | 1200m | 1x | Walking distances assume \~80m/min. The PlazaQL `.around()` query uses straight-line distance, which is a reasonable approximation. For higher precision, you could do point-in-polygon checks against isochrone geometry from the `/isochrone` endpoint. ## Full source The complete TypeScript CLI with all scoring logic and formatted output is on GitHub: [**plazafyi/example-apps/walkability-score**](https://github.com/plazafyi/example-apps/tree/main/walkability-score) Stack: TypeScript, Node.js CLI (`tsx`). Run with `npx tsx src/index.ts "350 5th Ave, New York, NY"`. ## Variations to try **Bike Score.** Swap the walking radii for biking distances (\~1.5km for 5 minutes) and add bike-specific amenities: `amenity=bicycle_parking`, `shop=bicycle`. **Custom weights.** Families might weight schools and parks higher. Young professionals might weight dining and transit higher. Let users pass a profile name to shift the weights. **Neighborhood heat map.** Run the score for every block in a neighborhood and generate a heat map. Use Plaza’s batch endpoints to handle the volume. **Time-of-day adjustment.** Some amenities have `opening_hours` tags in OSM. A pharmacy that closes at 6pm is less useful for a night owl. Parse opening hours and downweight closed amenities. **Server-side counting with aggregation.** Instead of fetching all features and counting client-side, use `.group_by().count()` to get category counts directly: `search(node, amenity: *).around(distance: 1200, geometry: point(${lat}, ${lng})).group_by(t["amenity"]).count()`. This returns `{"cafe": 12, "pharmacy": 3, ...}` without transferring individual features.