Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 20 additions & 10 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<p class="fine-print">{{ finePrint }}</p>
<p class="fine-print">
{{ sourceLine }} Issues: <a href="mailto:matt@roxburghm.com">matt@roxburghm.com</a>
{{ sourceLine }} Questions or corrections: <a href="mailto:matt@roxburghm.com">matt@roxburghm.com</a>
<span v-if="buildDateTime"> · Build {{ buildDateTime }}</span>
</p>
</aside>
Expand Down Expand Up @@ -115,7 +115,7 @@
<ranked-list :rows="rankedRows" :selected-name="null" :year="year" @select="openSchool"/>
<p class="fine-print">{{ finePrint }}</p>
<p class="fine-print">
{{ sourceLine }} Issues: <a href="mailto:matt@roxburghm.com">matt@roxburghm.com</a>
{{ sourceLine }} Questions or corrections: <a href="mailto:matt@roxburghm.com">matt@roxburghm.com</a>
<span v-if="buildDateTime"> · Build {{ buildDateTime }}</span>
</p>
</div>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -361,13 +367,13 @@ export default {
return this.home ? `Your chances from ${this.homeName}` : 'Schools';
},
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.';
return `Each mini chart shows the furthest distance offered each year, ${YEARS[0]}–${YEARS[YEARS.length - 1]}, ` +
'with your home as the orange line. Pale full-height bars mean everyone who applied got a place. ' +
'Distances are straight-line unless a school notes otherwise.';
},
sourceLine() {
return `Source: Ealing Council “Starting High School” booklets and on-time offer reports, ${YEARS[0]}–${YEARS[YEARS.length - 1]}. ` +
'Ofsted and GCSE figures from GOV.UK. Past distances don’t guarantee future offers. Use at your own risk.';
return `Figures come from Ealing Council’s offer reports and booklets, ${YEARS[0]}–${YEARS[YEARS.length - 1]}, ` +
'and from GOV.UK. They’re estimates to help you plan; Ealing Council makes the admissions decisions.';
},
// The schools plus about 2 km either side, to steer address suggestions towards Ealing.
searchArea() {
Expand All @@ -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;
},
Expand Down
14 changes: 8 additions & 6 deletions src/Help.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
walking distance along pavements, so its area follows the street network rather than a circle.
</p>
<p>
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 <em>too close to call</em>.
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
<em>too close to call</em>.
</p>
</section>

Expand All @@ -64,7 +64,7 @@
</ul>
<p class="small">
Exam results from the Department for Education. Ofsted grades from reports.ofsted.gov.uk.
Walking routes © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap contributors</a>,
Walking distances from Google Maps. Walking areas on the map © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap contributors</a>,
calculated with <a href="https://www.graphhopper.com/" target="_blank" rel="noopener">GraphHopper</a>.
</p>
</section>
Expand All @@ -73,12 +73,14 @@
<h3>Privacy</h3>
<p>
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.
</p>
</section>

<p class="disclaimer">
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:
<a href="mailto:matt@roxburghm.com">matt@roxburghm.com</a>
</p>
</v-card-text>
Expand Down
2 changes: 1 addition & 1 deletion src/components/IntakeChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
:rx="compact ? 1 : 3" :fill="`url(#${patternId})`" class="all-bar"/>

<template v-if="!compact">
<text v-if="bar.kind !== 'missing'" :x="x(i) + barWidth / 2"
<text v-if="bar.kind !== 'missing' && (!narrow || bar.year === selected)" :x="x(i) + barWidth / 2"
:y="bar.kind === 'value' ? y(bar.value) - 6 : (bar.kind === 'all' ? pad.top - 6 : y(0) - 6)"
text-anchor="middle" class="value">{{ bar.short }}</text>
<text :x="x(i) + barWidth / 2" :y="height - 8" text-anchor="middle"
Expand Down
23 changes: 22 additions & 1 deletion src/components/SchoolDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
<!-- Always present and a fixed height, so changing year doesn't move the content below -->
<p :class="['detail__year', `detail__year--${status.kind}`]" aria-live="polite">{{ yearLine }}</p>

<p class="detail__estimate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>
<span><strong>Estimates only.</strong> These figures are worked out from past offers and our own distance
measurements. Ealing Council’s admissions decisions are final.</span>
</p>

<p v-if="school.notes" class="detail__note"><strong>Note:</strong> {{ school.notes }}</p>

<div class="detail__body">
Expand Down Expand Up @@ -131,7 +138,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;
Expand Down Expand Up @@ -186,6 +197,16 @@ export default {
.detail__year--out { color: #8B1C1C; }
.detail__year--close { color: #8A3A00; }

.detail__estimate {
display: flex;
gap: 8px;
margin: -6px 0 0;
font-size: 12.5px;
line-height: 1.45;
color: var(--ink-3);
}
.detail__estimate svg { flex: none; margin-top: 1px; }
.detail__estimate strong { color: var(--ink-2); }
.detail__note { margin: 0; padding: 10px 12px; background: #FFF4E5; border-radius: 10px; font-size: 13px; color: #6B3A00; }

.detail__body { display: flex; flex-wrap: wrap; gap: 24px; }
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/admission.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/plugins/assess.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`};
Expand Down
47 changes: 40 additions & 7 deletions src/plugins/walking.js
Original file line number Diff line number Diff line change
@@ -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, '');
Expand Down Expand Up @@ -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)) {
Expand All @@ -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};
Loading