Skip to content
GuidesBlogPlaygroundDashboard

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.

  • Geocoding to turn an address into coordinates
  • PlazaQL to count amenities in each walking zone
  • Distance decay weighting so closer amenities count more

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.

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 timeDistanceMultiplier
5 min400m3x
10 min800m2x
15 min1200m1x

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.

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".

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.