From 4ab5e3dfafdd1ec60c034d4b7e3caaf523619258 Mon Sep 17 00:00:00 2001 From: roxburghm Date: Fri, 13 Nov 2026 21:31:37 +0000 Subject: [PATCH 1/3] Use Google walking directions for walking-distance schools Until now a walking-distance school (Drayton Manor) compared the walking cut-off with your straight-line distance, which is always shorter, so homes looked closer than they are. When an address is set the page asks Google's Distance Matrix (walking) for the route from home to each walking-distance school, once per address, and uses that exact distance for the verdicts, charts and wording. Near misses still read "too close to call". The map's walking areas are unchanged. The About page and README say where walking distances come from, and that Google sees the chosen location. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++-- src/App.vue | 16 ++++++++--- src/Help.vue | 11 ++++---- src/components/SchoolDetail.vue | 6 ++++- src/plugins/admission.js | 3 +++ src/plugins/assess.js | 1 + src/plugins/walking.js | 47 ++++++++++++++++++++++++++++----- 7 files changed, 71 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a3a66ad..457d0b9 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ See [Configuration Reference](https://cli.vuejs.org/config/). ### Walking distances Schools with `"measure": "walk"` in `src/plugins/schools.js` are judged on walking distance rather than a straight line. -Their walking data is built from GraphHopper isochrones and saved to `public/data/walk/`, so a visitor's address never -leaves their browser. +The walk from a visitor's home is looked up with Google Maps walking directions (Distance Matrix, using the same Maps +key) when they set an address. The walking *areas* drawn on the map are built from GraphHopper isochrones and saved to +`public/data/walk/`; until those exist the hand-drawn `intakePath` outlines are used. ``` GRAPHHOPPER_API_KEY=... node scripts/walking-bands.mjs # only files whose inputs changed diff --git a/src/App.vue b/src/App.vue index baf2c9a..eeaad01 100644 --- a/src/App.vue +++ b/src/App.vue @@ -256,6 +256,12 @@ export default { // storage unavailable – keep the in-memory value } }, + fetchWalks() { + if (!this.home) return; + const walkSchools = this.schools.filter(s => admission.measure(s) === 'walk'); + walkSchools.forEach(walking.load); + if (this.mapsReady) walking.lookupWalks(walkSchools, this.home); + }, matchesName(school) { const query = this.nameFilter.trim().toLowerCase(); return !query || school.name.toLowerCase().includes(query) || school.shortName.toLowerCase().includes(query); @@ -385,13 +391,17 @@ export default { }, }, watch: { - // Walking bands are only needed once there's a home to measure from. + // Walking data is only needed once there's a home to measure from: the + // walk itself from Google (once its script has loaded), outlines from CI. home: { immediate: true, - handler(home) { - if (home) this.schools.filter(s => admission.measure(s) === 'walk').forEach(walking.load); + handler() { + this.fetchWalks(); } }, + mapsReady() { + this.fetchWalks(); + }, rankedRows(rows) { if (!this.selected && rows.length) this.selected = rows[0].school; }, diff --git a/src/Help.vue b/src/Help.vue index 1843ada..963d9f3 100644 --- a/src/Help.vue +++ b/src/Help.vue @@ -37,9 +37,9 @@ walking distance along pavements, so its area follows the street network rather than a circle.

- Walking distances are worked out in advance from OpenStreetMap data using GraphHopper, then looked up on your - device. They're estimates: the council uses its own address points and routes. Results near the line are - marked too close to call. + Your walking distance comes from Google Maps walking directions to the school. It's an estimate: the + council uses its own address points and routes, so results within about 50 metres of the line are marked + too close to call.

@@ -64,7 +64,7 @@

Exam results from the Department for Education. Ofsted grades from reports.ofsted.gov.uk. - Walking routes © OpenStreetMap contributors, + Walking distances from Google Maps. Walking areas on the map © OpenStreetMap contributors, calculated with GraphHopper.

@@ -73,7 +73,8 @@

Privacy

Your address is saved only in this browser so it's there next time. Google sees what you type in the address - box to suggest matches. Nothing else is sent anywhere. + box to suggest matches, and the location you pick, to work out walking distances to schools that use them. + Nothing is sent anywhere else.

diff --git a/src/components/SchoolDetail.vue b/src/components/SchoolDetail.vue index 8f09e14..1e4d891 100644 --- a/src/components/SchoolDetail.vue +++ b/src/components/SchoolDetail.vue @@ -131,7 +131,11 @@ export default { if (s.margin === null || s.lowerBound) { return `In ${this.year} your home was ${s.kind === 'in' ? 'inside' : 'outside'} the ${m(s.cutoff)} cut-off.`; } - return `In ${this.year} your home was ${m(Math.abs(s.margin))} ${s.margin >= 0 ? 'inside' : 'outside'} the ${m(s.cutoff)} cut-off.`; + const side = `${m(Math.abs(s.margin))} ${s.margin >= 0 ? 'inside' : 'outside'}`; + if (s.measure === 'walk') { + return `In ${this.year} your ${m(s.homeMiles)} walk was ${side} the ${m(s.cutoff)} walking cut-off.`; + } + return `In ${this.year} your home was ${side} the ${m(s.cutoff)} cut-off.`; }, p8() { const v = this.school.progress8; diff --git a/src/plugins/admission.js b/src/plugins/admission.js index 34a7086..56bd7b4 100644 --- a/src/plugins/admission.js +++ b/src/plugins/admission.js @@ -36,6 +36,9 @@ export function status(school, year, home) { const walk = walking.estimate(school, home); const exact = walking.insideCutoff(school, year, home); + // An exact walk from Google: compare it directly. A near miss stays "too + // close to call" because the council's own route may differ. + if (walk?.routed) return classify(cutoff, walk, how, straight); if (exact !== null && walk) { const result = classify(cutoff, walk, how, straight); if (result.kind === 'close') result.kind = exact ? 'in' : 'out'; diff --git a/src/plugins/assess.js b/src/plugins/assess.js index ad6cb09..931a2a3 100644 --- a/src/plugins/assess.js +++ b/src/plugins/assess.js @@ -22,6 +22,7 @@ function distance(school, home) { const straight = DistanceUtils.getDistanceInMiles(home, school.centre); if (admission.measure(school) !== 'walk') return {dist: straight, label: `${formatMiles(straight)} away`}; const walk = walking.estimate(school, home); + if (walk?.routed) return {dist: walk.low, label: `${formatMiles(walk.low)} walk`}; if (walk && !walk.beyond) { const mid = (walk.low + walk.high) / 2; return {dist: mid, label: `~${formatMiles(mid)} walk`}; diff --git a/src/plugins/walking.js b/src/plugins/walking.js index dd6a0e2..d291c6a 100644 --- a/src/plugins/walking.js +++ b/src/plugins/walking.js @@ -1,14 +1,18 @@ import {reactive} from 'vue'; -// Walking distances are precomputed in CI (scripts/walking-bands.mjs) as nested -// isochrone bands around each school, so a visitor's address never leaves the -// browser. Each file in /data/walk holds: +// Walking distance from home comes from Google Maps walking directions (one +// Distance Matrix request when an address is set). Walking *areas* for the map +// come from CI (scripts/walking-bands.mjs), and its bands are a fallback for the +// distance. Each file in /data/walk holds: // bands: [{metres, polygon}] ascending; polygon = GeoJSON Polygon coordinates // cutoffs: {year: {metres, polygon}} exact outline at that year's cut-off export const METRES_PER_MILE = 1609.344; const cache = reactive({}); +// Google walking distances: `${school}|${home}` → {miles}, null if Google +// couldn't route it, undefined while the request is out. +const routes = reactive({}); export function schoolKey(school) { return school.shortName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); @@ -46,11 +50,40 @@ export function inPolygon(point, polygon) { return inRing(point, outer) && !holes.some(hole => inRing(point, hole)); } -// Walking distance from home to school as a band in miles, e.g. {low: 0.9, high: 0.96}. -// `beyond` is set when home is further than the furthest band computed. +function routeKey(school, home) { + return `${schoolKey(school)}|${home.lat.toFixed(5)},${home.lng.toFixed(5)}`; +} + +// Ask Google for the walk from home to each school not already looked up for this home. +export function lookupWalks(schools, home) { + const google = window.google; + if (!home || !google?.maps?.DistanceMatrixService) return; + const todo = schools.filter(school => !(routeKey(school, home) in routes)); + if (!todo.length) return; + todo.forEach(school => routes[routeKey(school, home)] = undefined); + new google.maps.DistanceMatrixService().getDistanceMatrix({ + origins: [home], + destinations: todo.map(school => school.centre), + travelMode: google.maps.TravelMode.WALKING, + }, (response, status) => { + todo.forEach((school, i) => { + const element = status === 'OK' ? response.rows[0]?.elements[i] : null; + routes[routeKey(school, home)] = element?.status === 'OK' + ? {miles: element.distance.value / METRES_PER_MILE} + : null; + }); + }); +} + +// Walking distance from home to school as a band in miles. From Google it's +// exact ({low: 1.2, high: 1.2, routed: true}); from CI bands it's a range, with +// `beyond` set when home is further than the furthest band computed. export function estimate(school, home) { + if (!home) return null; + const route = routes[routeKey(school, home)]; + if (route) return {low: route.miles, high: route.miles, routed: true}; const walk = data(school); - if (!walk || !home) return null; + if (!walk) return null; let low = 0; for (const band of walk.bands) { if (inPolygon(home, band.polygon)) { @@ -74,4 +107,4 @@ export function cutoffOutline(school, year) { return data(school)?.cutoffs?.[year]?.polygon?.[0] || null; } -export default {schoolKey, load, data, estimate, insideCutoff, cutoffOutline, inPolygon, METRES_PER_MILE}; +export default {schoolKey, load, data, lookupWalks, estimate, insideCutoff, cutoffOutline, inPolygon, METRES_PER_MILE}; From e0b9b05b62849140c98b0b8f91f673a7d7c98992 Mon Sep 17 00:00:00 2001 From: roxburghm Date: Fri, 13 Nov 2026 21:46:19 +0000 Subject: [PATCH 2/3] Say plainly that figures are estimates and the council's decision is final - Every school's details carry an "Estimates only" note under the year line: figures come from past offers and our own distance measurements, and Ealing Council's admissions decisions are final - Same message in the list's small print and the About page - Narrow cut-off charts label only the chosen year so labels don't collide Co-Authored-By: Claude Opus 5.5 --- src/App.vue | 3 ++- src/Help.vue | 3 ++- src/components/IntakeChart.vue | 2 +- src/components/SchoolDetail.vue | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/App.vue b/src/App.vue index eeaad01..0e10c11 100644 --- a/src/App.vue +++ b/src/App.vue @@ -369,7 +369,8 @@ export default { finePrint() { return `Mini charts: each bar is the furthest distance offered in a year, ${YEARS[0]}–${YEARS[YEARS.length - 1]}. ` + 'The orange line is your home. Pale full-height bars mean everyone who applied got a place. ' + - 'Distances are straight-line unless noted. Check each school’s admission rules before you apply.'; + 'Distances are straight-line unless noted. All figures are estimates; Ealing Council’s admissions ' + + 'decisions are final. Check each school’s admission rules before you apply.'; }, sourceLine() { return `Source: Ealing Council “Starting High School” booklets and on-time offer reports, ${YEARS[0]}–${YEARS[YEARS.length - 1]}. ` + diff --git a/src/Help.vue b/src/Help.vue index 963d9f3..aa08c0e 100644 --- a/src/Help.vue +++ b/src/Help.vue @@ -79,7 +79,8 @@

- Figures are approximate and for guidance only. Problems or corrections: + Every figure here is an estimate, for guidance only. Ealing Council’s admissions decisions are final. + Problems or corrections: matt@roxburghm.com

diff --git a/src/components/IntakeChart.vue b/src/components/IntakeChart.vue index bd171d0..f3afdc6 100644 --- a/src/components/IntakeChart.vue +++ b/src/components/IntakeChart.vue @@ -25,7 +25,7 @@ :rx="compact ? 1 : 3" :fill="`url(#${patternId})`" class="all-bar"/>