Calculate a Walkability Score
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
Section titled “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
Section titled “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 }; // metersconst 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
Section titled “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
Section titled “Full source”The complete TypeScript CLI with all scoring logic and formatted output is on GitHub:
plazafyi/example-apps/walkability-score
Stack: TypeScript, Node.js CLI (tsx). Run with npx tsx src/index.ts "350 5th Ave, New York, NY".
Variations to try
Section titled “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.