205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
'use client';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* CutoffMapPanel — "Where the last place went".
|
|||
|
|
*
|
|||
|
|
* The rings and the postcode check live in one component because they are one
|
|||
|
|
* question asked twice: the map answers "how far is that?" and the form answers
|
|||
|
|
* "how far are we?". Entering a postcode drops a pin on the same rings rather
|
|||
|
|
* than producing a separate verdict somewhere else on the page.
|
|||
|
|
*
|
|||
|
|
* The postcode never leaves the browser except to postcodes.io for a lat/long,
|
|||
|
|
* and nothing is stored — this is a client-side measurement, not a lookup
|
|||
|
|
* against the family.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { useState, useMemo, type FormEvent } from 'react';
|
|||
|
|
import dynamic from 'next/dynamic';
|
|||
|
|
import type { School } from '@/lib/types';
|
|||
|
|
import { geocodePostcode, calculateDistance } from '@/lib/api';
|
|||
|
|
import { isValidPostcode } from '@/lib/utils';
|
|||
|
|
import {
|
|||
|
|
compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT,
|
|||
|
|
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
|
|||
|
|
} from './lastDistanceOffered';
|
|||
|
|
import type { CutoffRing } from '../LeafletCutoffMapInner';
|
|||
|
|
import styles from './schoolSections.module.css';
|
|||
|
|
|
|||
|
|
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
|||
|
|
ssr: false,
|
|||
|
|
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const VERDICT_MARK: Record<CutoffVerdict, string> = {
|
|||
|
|
inside: '✓',
|
|||
|
|
outside: '✕',
|
|||
|
|
'too-close': '≈',
|
|||
|
|
unknown: '–',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
|
|||
|
|
inside: 'Inside that year’s cut-off',
|
|||
|
|
outside: 'Outside that year’s cut-off',
|
|||
|
|
'too-close': 'Too close to the cut-off to say',
|
|||
|
|
unknown: 'No published figure for this year',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Written out rather than composed from the verdict string. A computed
|
|||
|
|
// `styles[...]` key silently yields undefined when a class is renamed, and an
|
|||
|
|
// unstyled "outside" chip would look exactly like an "inside" one.
|
|||
|
|
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
|
|||
|
|
inside: styles.cutoffYearInside,
|
|||
|
|
outside: styles.cutoffYearOutside,
|
|||
|
|
'too-close': styles.cutoffYearTooClose,
|
|||
|
|
unknown: styles.cutoffYearUnknown,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function CutoffMapPanel({
|
|||
|
|
schoolInfo,
|
|||
|
|
rows,
|
|||
|
|
}: {
|
|||
|
|
schoolInfo: School;
|
|||
|
|
rows: CutoffYearRow[];
|
|||
|
|
}) {
|
|||
|
|
const [postcode, setPostcode] = useState('');
|
|||
|
|
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
|
|||
|
|
const [result, setResult] = useState<CutoffCheckResult | null>(null);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [busy, setBusy] = useState(false);
|
|||
|
|
|
|||
|
|
const lat = schoolInfo.latitude;
|
|||
|
|
const lng = schoolInfo.longitude;
|
|||
|
|
|
|||
|
|
const rings: CutoffRing[] = useMemo(() => {
|
|||
|
|
const published = rows.filter((r) => r.distanceM != null);
|
|||
|
|
return published.map((r, i) => ({
|
|||
|
|
year: r.year,
|
|||
|
|
radiusM: r.distanceM as number,
|
|||
|
|
// rows are newest-first, so the first published row is the current year.
|
|||
|
|
latest: i === 0,
|
|||
|
|
}));
|
|||
|
|
}, [rows]);
|
|||
|
|
|
|||
|
|
// Without coordinates there is nothing to draw rings around and nothing to
|
|||
|
|
// measure a postcode against.
|
|||
|
|
if (lat == null || lng == null || rings.length === 0) return null;
|
|||
|
|
|
|||
|
|
const onCheck = async (e: FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const value = postcode.trim();
|
|||
|
|
if (!value) return;
|
|||
|
|
|
|||
|
|
if (!isValidPostcode(value)) {
|
|||
|
|
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
|
|||
|
|
setResult(null);
|
|||
|
|
setHome(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setBusy(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const point = await geocodePostcode(value);
|
|||
|
|
if (!point) {
|
|||
|
|
setError('We could not find that postcode. Check it and try again.');
|
|||
|
|
setResult(null);
|
|||
|
|
setHome(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setHome({ lat: point.latitude, lng: point.longitude });
|
|||
|
|
// calculateDistance returns kilometres; everything here is metres.
|
|||
|
|
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
|
|||
|
|
setResult(compareToCutoffs(metres, rows));
|
|||
|
|
} catch {
|
|||
|
|
setError('Something went wrong looking up that postcode. Try again in a moment.');
|
|||
|
|
setResult(null);
|
|||
|
|
setHome(null);
|
|||
|
|
} finally {
|
|||
|
|
setBusy(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const latestRing = rings[0];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className={styles.cutoffMapPanel}>
|
|||
|
|
<h3 className={styles.cutoffMapTitle}>Where the last place went</h3>
|
|||
|
|
|
|||
|
|
<div className={styles.cutoffMapFigure}>
|
|||
|
|
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ul className={styles.cutoffMapLegend}>
|
|||
|
|
<li>
|
|||
|
|
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
|||
|
|
{latestRing.year} cut-off
|
|||
|
|
</li>
|
|||
|
|
{rings.length > 1 && (
|
|||
|
|
<li>
|
|||
|
|
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
|
|||
|
|
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
|
|||
|
|
</li>
|
|||
|
|
)}
|
|||
|
|
{home && (
|
|||
|
|
<li>
|
|||
|
|
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
|
|||
|
|
Your postcode
|
|||
|
|
</li>
|
|||
|
|
)}
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
<p className={styles.cutoffMapCaveat}>{CUTOFF_MAP_CAVEAT}</p>
|
|||
|
|
|
|||
|
|
<div className={styles.cutoffCheck}>
|
|||
|
|
<h4 className={styles.cutoffCheckTitle}>How far away are you?</h4>
|
|||
|
|
<p className={styles.cutoffCheckSub}>
|
|||
|
|
We measure a straight line from your postcode to the school and compare
|
|||
|
|
it with each year’s cut-off. Your postcode is not stored.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
|
|||
|
|
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
|
|||
|
|
<input
|
|||
|
|
id="cutoff-postcode"
|
|||
|
|
type="text"
|
|||
|
|
inputMode="text"
|
|||
|
|
autoComplete="postal-code"
|
|||
|
|
spellCheck={false}
|
|||
|
|
placeholder="e.g. SE23 3NA"
|
|||
|
|
value={postcode}
|
|||
|
|
onChange={(e) => setPostcode(e.target.value)}
|
|||
|
|
className={styles.cutoffCheckInput}
|
|||
|
|
/>
|
|||
|
|
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
|
|||
|
|
{busy ? 'Checking…' : 'Check'}
|
|||
|
|
</button>
|
|||
|
|
</form>
|
|||
|
|
|
|||
|
|
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
|
|||
|
|
|
|||
|
|
{result && (
|
|||
|
|
<div className={styles.cutoffCheckResult} role="status">
|
|||
|
|
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
|
|||
|
|
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
|
|||
|
|
|
|||
|
|
<ul className={styles.cutoffYearStrip}>
|
|||
|
|
{[...result.years].reverse().map((y) => (
|
|||
|
|
<li
|
|||
|
|
key={y.year}
|
|||
|
|
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
|
|||
|
|
title={VERDICT_TITLE[y.verdict]}
|
|||
|
|
>
|
|||
|
|
<span className={styles.cutoffYearChipYear}>{y.year}</span>
|
|||
|
|
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
|
|||
|
|
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|