feat(search): align the map view with the mockup and open it on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The map view now follows option B of the results-controls mockups: - The list sits in a pane on the left, under the result count and sort, and the map fills the rest of the screen below the pinned toolbar. The toolbar's height is measured, so the split ends 1rem above the bottom of the screen however the controls wrap. - Pins are brand-teal dots and the selected one is coral. The search location is an ink dot, the search radius a dashed circle with its distance, and the view fits that circle. Tiles are muted and the zoom sits under the fullscreen button. - A school picked on the map or in the list opens a card on the map (View, + Compare, following the basket), and its list card is ringed and scrolled into view. Phones keep the bottom sheet. - The list cards show the full name, Ofsted and school type, the headline figure and pupils. The map cards now follow the list rows: no England benchmark for special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead showed "0% RWM -62 pts vs national"). That rule moves to a shared listRwmValue helper. A postcode search opens on the map for desktop browsers, chosen on the server from the user agent so the list never paints first. The client falls back to the list below 1024px, and follows the default through client-side navigation from the hero until the reader picks a view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e177ca2ec
commit
4e4b30e812
14 files changed
+1118
-440
No files matched your search
@@ -532,7 +532,10 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
|||||||
|
|
||||||
const view = page.getByRole('group', { name: 'Results view' });
|
const view = page.getByRole('group', { name: 'Results view' });
|
||||||
await expect(view).toBeVisible();
|
await expect(view).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
// Desktop opens on the map; the list is the long page that scrolls.
|
||||||
|
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'List', exact: true }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 1500));
|
await page.evaluate(() => window.scrollTo(0, 1500));
|
||||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
||||||
@@ -548,6 +551,35 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
|||||||
.toHaveAttribute('aria-pressed', 'true');
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Desktop opens a postcode search on the map (mockup B): the list in a pane on
|
||||||
|
* the left, the map filling the rest of the screen, and a card on the map for
|
||||||
|
* the school picked in either.
|
||||||
|
*/
|
||||||
|
test('a desktop postcode search opens on the map with the list beside it', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Map', exact: true }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 });
|
||||||
|
const pane = page.locator('[class*="mapListPane"]');
|
||||||
|
const card = pane.locator('[data-urn]').first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator('.sc-pin').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The split runs to the bottom of the screen rather than stopping short.
|
||||||
|
const split = await page.locator('[class*="mapViewContainer"]').boundingBox();
|
||||||
|
expect(split!.y + split!.height).toBeGreaterThan(800 - 40);
|
||||||
|
expect(split!.y + split!.height).toBeLessThanOrEqual(800);
|
||||||
|
|
||||||
|
// A card picks its pin and opens its card on the map. Clicked in its padding,
|
||||||
|
// clear of the name link and the Compare button.
|
||||||
|
const name = (await card.locator('a').first().innerText()).trim();
|
||||||
|
await card.click({ position: { x: 6, y: 6 } });
|
||||||
|
await expect(page.locator('.sc-pin--selected')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.sc-popup')).toContainText(name);
|
||||||
|
});
|
||||||
|
|
||||||
for (const width of [360, 390, 430]) {
|
for (const width of [360, 390, 430]) {
|
||||||
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
|
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width, height: 800 });
|
await page.setViewportSize({ width, height: 800 });
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { act, fireEvent, render } from '@testing-library/react';
|
||||||
|
import LeafletMapInner from '@/components/LeafletMapInner';
|
||||||
|
import { primaryFixture } from '../support/schoolFixtures';
|
||||||
|
import type { School } from '@/lib/types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The results map's own logic, against real Leaflet in jsdom: which pin is
|
||||||
|
* selected, whether the card opens, and what the card offers. jsdom lays out
|
||||||
|
* nothing, so this pins behaviour, never positions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const base = primaryFixture.schoolInfo;
|
||||||
|
const a: School = { ...base, urn: 1, school_name: 'Southmead Primary School', latitude: 51.43, longitude: -0.21, distance: 0.2, rwm_expected_pct: 52 };
|
||||||
|
const b: School = { ...base, urn: 2, school_name: 'Greenmead School', latitude: 51.431, longitude: -0.205, distance: 0.2,
|
||||||
|
school_type: 'Community special school', rwm_expected_pct: 0, reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0 };
|
||||||
|
const schools = [a, b];
|
||||||
|
const centre: [number, number] = [51.43, -0.21];
|
||||||
|
|
||||||
|
function setWide(wide: boolean) {
|
||||||
|
window.matchMedia = ((q: string) => ({
|
||||||
|
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap(props: Partial<React.ComponentProps<typeof LeafletMapInner>> = {}) {
|
||||||
|
const all = {
|
||||||
|
schools, center: centre, zoom: 13, referencePoint: centre, radiusMiles: 1,
|
||||||
|
nationalAvgRwm: 62, ...props,
|
||||||
|
};
|
||||||
|
const view = render(<LeafletMapInner {...all} />);
|
||||||
|
return { ...view, rerender: (next: Partial<typeof all>) => view.rerender(<LeafletMapInner {...all} {...next} />) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setWide(true));
|
||||||
|
|
||||||
|
it('draws a pin per school, the search location and the radius', () => {
|
||||||
|
const { container } = renderMap();
|
||||||
|
expect(container.querySelectorAll('.sc-pin')).toHaveLength(2);
|
||||||
|
expect(container.querySelector('.sc-home')).not.toBeNull();
|
||||||
|
expect(container.querySelector('.sc-radius-label')).toHaveTextContent('1 mile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a pin click, and marks and opens the selected school', () => {
|
||||||
|
const onMarkerClick = jest.fn();
|
||||||
|
const { container, rerender } = renderMap({ onMarkerClick });
|
||||||
|
fireEvent.click(container.querySelectorAll('.sc-pin')[0]);
|
||||||
|
expect(onMarkerClick).toHaveBeenCalledWith(a);
|
||||||
|
|
||||||
|
rerender({ onMarkerClick, selectedUrn: 1 });
|
||||||
|
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||||
|
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||||
|
expect(container.querySelector('.sc-popup')).toHaveTextContent('52% RWM -10 pts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the special-school card free of a benchmark and a placeholder 0%', () => {
|
||||||
|
const { container } = renderMap({ selectedUrn: 2 });
|
||||||
|
const card = container.querySelector('.sc-popup')!;
|
||||||
|
expect(card).toHaveTextContent('Greenmead School');
|
||||||
|
expect(card).not.toHaveTextContent(/%|pts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds to compare from the card, and the card follows the basket', () => {
|
||||||
|
const onAddToCompare = jest.fn();
|
||||||
|
const { container, rerender } = renderMap({ onAddToCompare, selectedUrn: 1, compareUrns: [] });
|
||||||
|
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||||
|
expect(onAddToCompare).toHaveBeenCalledWith(a);
|
||||||
|
|
||||||
|
rerender({ onAddToCompare, selectedUrn: 1, compareUrns: [1] });
|
||||||
|
expect(container.querySelector('[data-compare]')).toHaveTextContent('✓ Comparing');
|
||||||
|
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||||
|
expect(onAddToCompare).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the page when the card is closed from the map, but not when replaced', () => {
|
||||||
|
const onDeselect = jest.fn();
|
||||||
|
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||||
|
rerender({ onDeselect, selectedUrn: 2 });
|
||||||
|
expect(onDeselect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector('.leaflet-popup-close-button')!);
|
||||||
|
expect(onDeselect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the selection when the results reload under it', () => {
|
||||||
|
const onDeselect = jest.fn();
|
||||||
|
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||||
|
act(() => rerender({ onDeselect, selectedUrn: 1, schools: [...schools] }));
|
||||||
|
expect(onDeselect).not.toHaveBeenCalled();
|
||||||
|
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens no card on a narrow screen, where the page shows a bottom sheet', () => {
|
||||||
|
setWide(false);
|
||||||
|
const { container } = renderMap({ selectedUrn: 1 });
|
||||||
|
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||||
|
expect(container.querySelector('.sc-popup')).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { HomeView } from '@/components/HomeView';
|
||||||
|
import { fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||||
|
import { primaryFixture } from '../support/schoolFixtures';
|
||||||
|
import type { School, SchoolsResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The map view: the list beside the map (mockup B), opening on the map for
|
||||||
|
* desktop. The map itself is Leaflet and mocked here; what is pinned is what
|
||||||
|
* HomeView hands it and the list it draws beside it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useSearchParams: () => params,
|
||||||
|
usePathname: () => '/',
|
||||||
|
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
||||||
|
}));
|
||||||
|
jest.mock('@/context/ComparisonContext', () => ({
|
||||||
|
useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchSchools: jest.fn(),
|
||||||
|
fetchNationalAverages: jest.fn(),
|
||||||
|
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||||
|
}));
|
||||||
|
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
|
||||||
|
jest.mock('@/components/SchoolMap', () => ({
|
||||||
|
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
||||||
|
selectedUrn: number | null; radiusMiles?: number;
|
||||||
|
onMarkerClick: (s: School) => void; schools: School[];
|
||||||
|
}) => (
|
||||||
|
<div data-testid="map" data-selected={selectedUrn ?? ''} data-radius={radiusMiles}>
|
||||||
|
<button type="button" onClick={() => onMarkerClick(schools[1])}>pin</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const base = primaryFixture.schoolInfo;
|
||||||
|
const southmead: School = {
|
||||||
|
...base, urn: 2, school_name: 'Southmead Primary School', distance: 0.2,
|
||||||
|
school_type: 'Community school', rwm_expected_pct: 52, total_pupils: 269,
|
||||||
|
};
|
||||||
|
const greenmead: School = {
|
||||||
|
...base, urn: 3, school_name: 'Greenmead School', distance: 0.2,
|
||||||
|
school_type: 'Community special school', rwm_expected_pct: 0,
|
||||||
|
reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0, total_pupils: 62,
|
||||||
|
};
|
||||||
|
const ourLady: School = {
|
||||||
|
...base, urn: 1, school_name: 'Our Lady Queen of Heaven RC School', distance: 0,
|
||||||
|
school_type: 'Voluntary aided school', rwm_expected_pct: 70, total_pupils: 224,
|
||||||
|
};
|
||||||
|
|
||||||
|
function results(): SchoolsResponse {
|
||||||
|
return {
|
||||||
|
schools: [ourLady, southmead, greenmead], total: 3, page: 1, page_size: 25, total_pages: 1,
|
||||||
|
location_info: { postcode: 'SW196AR', radius: 1.60934, coordinates: [51.42, -0.21] },
|
||||||
|
} as SchoolsResponse;
|
||||||
|
}
|
||||||
|
const filters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||||
|
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
|
||||||
|
jest.mocked(fetchNationalAverages).mockResolvedValue({ primary: { rwm_expected_pct: 62 } } as never);
|
||||||
|
window.innerWidth = 1440;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderMap() {
|
||||||
|
const view = render(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||||
|
await act(async () => {});
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens on the map when the server says desktop', async () => {
|
||||||
|
await renderMap();
|
||||||
|
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '1');
|
||||||
|
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the list in a window too narrow for the split', async () => {
|
||||||
|
window.innerWidth = 800;
|
||||||
|
await renderMap();
|
||||||
|
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the count and the sort in the list beside the map, once', async () => {
|
||||||
|
await renderMap();
|
||||||
|
expect(screen.getAllByRole('heading', { name: /3 schools within 1.0 miles of SW196AR/ })).toHaveLength(1);
|
||||||
|
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects the pin from the card, and the card from the pin', async () => {
|
||||||
|
const { container } = await renderMap();
|
||||||
|
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.click(within(card(1)).getByText(/pupils/));
|
||||||
|
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1');
|
||||||
|
expect(card(1).className).toMatch(/compactItemSelected/);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'pin' }));
|
||||||
|
expect(card(2).className).toMatch(/compactItemSelected/);
|
||||||
|
expect(card(1).className).not.toMatch(/compactItemSelected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a card\'s link or button does not also select it', async () => {
|
||||||
|
const { container } = await renderMap();
|
||||||
|
const card = container.querySelector('[data-urn="1"]') as HTMLElement;
|
||||||
|
fireEvent.click(within(card).getByRole('button', { name: '+ Compare' }));
|
||||||
|
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => {
|
||||||
|
const { container } = await renderMap();
|
||||||
|
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||||
|
expect(card(2)).toHaveTextContent('52% RWM -10 pts · 269 pupils');
|
||||||
|
expect(card(3)).toHaveTextContent('62 pupils');
|
||||||
|
expect(card(3)).not.toHaveTextContent(/%|pts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a new default after a client-side search, until the reader picks', async () => {
|
||||||
|
params = new URLSearchParams('');
|
||||||
|
const empty = { schools: [], total: 0, page: 1, page_size: 25, total_pages: 0 } as SchoolsResponse;
|
||||||
|
const view = render(<HomeView initialSchools={empty} filters={filters} initialView="list" />);
|
||||||
|
|
||||||
|
// Hero search: same component, new props.
|
||||||
|
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||||
|
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||||
|
await act(async () => {});
|
||||||
|
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Chosen: a later search keeps the reader's view.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||||
|
params = new URLSearchParams('postcode=SW170AA&radius=1');
|
||||||
|
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="list" />);
|
||||||
|
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||||
|
await act(async () => {});
|
||||||
|
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -119,10 +119,13 @@ describe('third-party surfaces under themed text', () => {
|
|||||||
|
|
||||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||||
// Guards the rule below against passing vacuously if the popups are ever
|
// Guards the rule below against passing vacuously if the popups are ever
|
||||||
// rewritten as React components rather than HTML strings.
|
// rewritten as React components rather than HTML strings. The results
|
||||||
|
// map's card themes its text through classes in SchoolMap.module.css
|
||||||
|
// rather than inline, so a class-built popup counts as well.
|
||||||
const themed = sources(COMPONENTS).filter((file) => {
|
const themed = sources(COMPONENTS).filter((file) => {
|
||||||
const src = fs.readFileSync(file, 'utf8');
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
return /bindPopup\(|L\.popup\(/.test(src)
|
||||||
|
&& (/color:var\(--|color: var\(--/.test(src) || /class="sc-card/.test(src));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(themed.length).toBeGreaterThan(0);
|
expect(themed.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { isHandheldUserAgent } from '@/lib/device';
|
||||||
|
|
||||||
|
describe('isHandheldUserAgent', () => {
|
||||||
|
it.each([
|
||||||
|
['iPhone Safari', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'],
|
||||||
|
['Android Chrome', 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36'],
|
||||||
|
['Android tablet', 'Mozilla/5.0 (Linux; Android 13; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'],
|
||||||
|
])('reads %s as handheld', (_, ua) => {
|
||||||
|
expect(isHandheldUserAgent(ua)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['desktop Chrome', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'],
|
||||||
|
['Windows Edge', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 Edg/126.0'],
|
||||||
|
['missing', null],
|
||||||
|
])('reads %s as not handheld', (_, ua) => {
|
||||||
|
expect(isHandheldUserAgent(ua)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { isHandheldUserAgent } from '@/lib/device';
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
@@ -114,9 +116,20 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
// endpoint returns, and reading it silently yielded null on every request.
|
// endpoint returns, and reading it silently yielded null on every request.
|
||||||
const total = dataInfo?.unique_schools ?? null;
|
const total = dataInfo?.unique_schools ?? null;
|
||||||
const years = dataInfo?.years_available ?? [];
|
const years = dataInfo?.years_available ?? [];
|
||||||
|
/*
|
||||||
|
* A postcode search opens on the map for desktop browsers and on the list
|
||||||
|
* for phones and tablets. The user agent is the only signal available
|
||||||
|
* before the first paint; HomeView falls back to the list if the window
|
||||||
|
* turns out too narrow, so a wrong guess costs one swap, not a broken page.
|
||||||
|
*/
|
||||||
|
const initialView = params.postcode
|
||||||
|
&& !isHandheldUserAgent((await headers()).get('user-agent'))
|
||||||
|
? 'map' : 'list';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
autosuggest={autosuggest}
|
autosuggest={autosuggest}
|
||||||
|
initialView={initialView}
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={resolvedFilters}
|
filters={resolvedFilters}
|
||||||
totalSchools={total}
|
totalSchools={total}
|
||||||
|
|||||||
@@ -572,133 +572,174 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map View Layout */
|
/* ── Map view ──────────────────────────────────────────────────────────────
|
||||||
|
The list on the left, the map filling the rest, in one card that runs from
|
||||||
|
under the pinned toolbar to the bottom of the screen. --toolbar-h is the
|
||||||
|
toolbar's measured height, written by HomeView. */
|
||||||
|
/* Where the split starts before any scrolling: header, the page's top
|
||||||
|
padding (globals.css .main), the toolbar and its margin. */
|
||||||
|
.homeView {
|
||||||
|
--map-top: calc(64px + 1.25rem + var(--toolbar-h, 76px) + 1.25rem);
|
||||||
|
--map-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.homeView {
|
||||||
|
--map-top: calc(64px + 1rem + var(--toolbar-h, 76px) + 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mapViewContainer {
|
.mapViewContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 340px;
|
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
|
||||||
gap: 1rem;
|
height: calc(100dvh - var(--map-top) - var(--map-bottom));
|
||||||
height: calc(100dvh - 280px);
|
min-height: 480px;
|
||||||
min-height: 520px;
|
background: var(--bg-card);
|
||||||
max-height: 800px;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapListPane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapListPane .resultsHeader {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapListPane .resultsHeader h2 {
|
||||||
|
font-size: var(--step-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapContainer {
|
.mapContainer {
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactList {
|
.compactList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.625rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
padding: 0.125rem 1rem 1rem;
|
||||||
padding-right: 0.375rem;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactList::-webkit-scrollbar {
|
/* Compact School Item: the list pane's card, and the phone's bottom sheet. */
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactList::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactList::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactList::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compact School Item */
|
|
||||||
.compactItem {
|
.compactItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 0.4375rem;
|
||||||
gap: 0.75rem;
|
padding: 0.75rem 0.875rem;
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s ease;
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItem:hover {
|
.compactItem:hover {
|
||||||
border-color: var(--brand);
|
border-color: var(--border-strong);
|
||||||
box-shadow: 0 2px 6px rgba(var(--shadow-rgb), 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemContent {
|
.compactItemSelected,
|
||||||
flex: 1;
|
.compactItemSelected:hover {
|
||||||
min-width: 0;
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemHeader {
|
.compactItemHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
gap: 0.375rem;
|
align-items: flex-start;
|
||||||
margin-bottom: 0.125rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemName {
|
.compactItemName {
|
||||||
font-weight: 600;
|
font-family: var(--font-display);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemName:hover {
|
.compactItemName:hover {
|
||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distanceBadge {
|
.distanceBadge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.0625rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
color: var(--brand-on);
|
color: var(--brand-on);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemMeta {
|
.compactItemTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactBadge,
|
||||||
|
.compactTag {
|
||||||
|
padding: 0.125rem 0.4375rem;
|
||||||
|
border-radius: 4px;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactTag {
|
||||||
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 0.25rem;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactItemMeta span:not(:last-child)::after {
|
.ofsted1,
|
||||||
content: '·';
|
.ofsted2 { background: var(--status-above-bg); color: var(--status-above); }
|
||||||
margin-left: 0.375rem;
|
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||||
color: var(--text-muted);
|
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||||
}
|
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||||
|
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||||
|
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||||
|
|
||||||
.compactItemStats {
|
.compactItemFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
font-size: 0.6875rem;
|
}
|
||||||
|
|
||||||
|
.compactStat {
|
||||||
|
font-size: 0.8125rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactStat strong {
|
.compactStat strong {
|
||||||
|
font-size: 0.9375rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deltaUp { color: var(--status-above); font-weight: 600; }
|
||||||
|
.deltaDown { color: var(--status-below); font-weight: 600; }
|
||||||
|
|
||||||
.compactItemActions {
|
.compactItemActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 0.5rem;
|
||||||
gap: 0.25rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,49 +836,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapViewContainer {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 260px auto;
|
|
||||||
height: auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mapContainer {
|
|
||||||
height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactList {
|
|
||||||
height: auto;
|
|
||||||
max-height: 350px;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactItem {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactItemActions {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactItemActions > * {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
padding: 2rem 1.25rem;
|
padding: 2rem 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Highlighted List Item */
|
|
||||||
.highlightedItem .compactItem {
|
|
||||||
border-color: var(--brand);
|
|
||||||
box-shadow: 0 0 0 1px var(--brand);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile dock ───────────────────────────────────────────────────────────
|
/* ── Mobile dock ───────────────────────────────────────────────────────────
|
||||||
The floating List/Map button (phones only) above the selected pin's card.
|
The floating List/Map button (phones only) above the selected pin's card.
|
||||||
A column, so the button always clears the card instead of covering it. */
|
A column, so the button always clears the card instead of covering it. */
|
||||||
@@ -883,10 +886,7 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
cursor: default;
|
||||||
|
|
||||||
.bottomSheet .compactItem:hover {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeSheetBtn {
|
.closeSheetBtn {
|
||||||
@@ -917,21 +917,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When map view on mobile, expand map and hide list */
|
/* Map only: the pane keeps its heading, the cards give way to the pins and
|
||||||
|
the bottom sheet. */
|
||||||
.mapViewContainer {
|
.mapViewContainer {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
height: calc(100dvh - 280px);
|
border-radius: var(--radius-md);
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapContainer {
|
.mapListPane {
|
||||||
height: 100%;
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapListPane .sortSelect,
|
||||||
.compactList {
|
.compactList {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapListPane .resultsHeader {
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
|
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
|
||||||
@@ -964,6 +970,16 @@
|
|||||||
.hasViewFab {
|
.hasViewFab {
|
||||||
padding-bottom: 4.5rem;
|
padding-bottom: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The map runs down to the tab bar; the button floats over the map. */
|
||||||
|
.mapViewResults.hasViewFab {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeView {
|
||||||
|
--map-top: calc(56px + var(--toolbar-h, 124px) + 1rem);
|
||||||
|
--map-bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 1rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickSearches {
|
.quickSearches {
|
||||||
|
|||||||
+228
-143
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import { FilterBar } from './FilterBar';
|
import { FilterBar } from './FilterBar';
|
||||||
import { SchoolRow } from './SchoolRow';
|
import { SchoolRow } from './SchoolRow';
|
||||||
@@ -16,7 +16,7 @@ import { HeroIllustration } from './Illustration';
|
|||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
import { schoolUrl, buildOfstedListBadge, isSpecialSchool, listRwmValue } from '@/lib/utils';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
@@ -31,8 +31,17 @@ interface HomeViewProps {
|
|||||||
editorial?: React.ReactNode;
|
editorial?: React.ReactNode;
|
||||||
/** Server-read feature flag, threaded to both FilterBar instances. */
|
/** Server-read feature flag, threaded to both FilterBar instances. */
|
||||||
autosuggest?: boolean;
|
autosuggest?: boolean;
|
||||||
|
/**
|
||||||
|
* Which view a location search opens in, chosen by the server from the
|
||||||
|
* user agent: the map on desktop, the list on phones and tablets. Deciding
|
||||||
|
* it server-side means a desktop reader never sees the list flash first.
|
||||||
|
*/
|
||||||
|
initialView?: 'list' | 'map';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Below this the list-beside-map layout has no room; open on the list. */
|
||||||
|
const MAP_DEFAULT_MIN_WIDTH = 1024;
|
||||||
|
|
||||||
function daysUntil(month: number, day: number): number {
|
function daysUntil(month: number, day: number): number {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -217,12 +226,38 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
|
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false, initialView = 'list' }: HomeViewProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
const [resultsView, setResultsView] = useState<'list' | 'map'>(initialView);
|
||||||
|
// Once someone picks a view it is theirs; the default never overrides it.
|
||||||
|
const [viewChosen, setViewChosen] = useState(false);
|
||||||
|
/*
|
||||||
|
* Follow the server's default until then. Most searches start in the hero
|
||||||
|
* and reach the results by client-side navigation, which hands this same
|
||||||
|
* component a new initialView rather than mounting a fresh one, so the
|
||||||
|
* initial state alone would leave a desktop reader on the list. Adjusted
|
||||||
|
* during render, not in an effect, so the list never paints first.
|
||||||
|
*/
|
||||||
|
const [defaultFor, setDefaultFor] = useState(initialView);
|
||||||
|
if (defaultFor !== initialView) {
|
||||||
|
setDefaultFor(initialView);
|
||||||
|
if (!viewChosen) {
|
||||||
|
setResultsView(initialView === 'map' && window.innerWidth < MAP_DEFAULT_MIN_WIDTH ? 'list' : initialView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The user agent said desktop, but the window is too narrow for the split
|
||||||
|
// layout (a narrow desktop window, or a test at phone width): fall back to
|
||||||
|
// the list before anyone has chosen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialView === 'map' && window.innerWidth < MAP_DEFAULT_MIN_WIDTH) {
|
||||||
|
setResultsView('list');
|
||||||
|
}
|
||||||
|
// Mount only; later defaults are handled during render above.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||||||
const sortOrder = searchParams.get('sort') || 'default';
|
const sortOrder = searchParams.get('sort') || 'default';
|
||||||
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||||||
@@ -236,6 +271,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const resultsRef = useRef<HTMLElement>(null);
|
const resultsRef = useRef<HTMLElement>(null);
|
||||||
|
const mapListRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const loadMoreController = useRef<AbortController | null>(null);
|
const loadMoreController = useRef<AbortController | null>(null);
|
||||||
// Identity changes even for A → B → A, so an old A response stays stale.
|
// Identity changes even for A → B → A, so an old A response stays stale.
|
||||||
@@ -284,6 +321,17 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
|| (!currentPhase && secondaryCount > primaryCount);
|
|| (!currentPhase && secondaryCount > primaryCount);
|
||||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||||
|
|
||||||
|
// The map view fills the screen below the pinned toolbar, whose height
|
||||||
|
// depends on how its controls wrap. Measure it rather than guess.
|
||||||
|
const [toolbarHeight, setToolbarHeight] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = toolbarRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const observer = new ResizeObserver(() => setToolbarHeight(Math.round(el.getBoundingClientRect().height)));
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isSearchActive]);
|
||||||
|
|
||||||
// Reset pagination and map cache when search params change
|
// Reset pagination and map cache when search params change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParamsStr = searchParams.toString();
|
const newParamsStr = searchParams.toString();
|
||||||
@@ -419,7 +467,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
);
|
);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const sortedSchools = [...allSchools].sort((a, b) => {
|
const sortSchools = (list: School[]) => [...list].sort((a, b) => {
|
||||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||||
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
||||||
@@ -428,6 +476,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
const sortedSchools = sortSchools(allSchools);
|
||||||
|
// The map gets a stable array (a new one rebuilds every pin and closes the
|
||||||
|
// open card); only the list beside it is sorted.
|
||||||
|
const mapResultSchools = mapSchools.length > 0 ? mapSchools : initialSchools.schools;
|
||||||
|
const mapListSchools = sortSchools(mapResultSchools);
|
||||||
|
const radiusMiles = initialSchools.location_info
|
||||||
|
? Number((initialSchools.location_info.radius / 1.60934).toFixed(1))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Empty-results sentinel: track when a search returns nothing.
|
// Empty-results sentinel: track when a search returns nothing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -473,6 +529,15 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}, [addSchool, selectedSchools.length]);
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
||||||
|
const compareUrns = useMemo(() => selectedSchools.map(s => s.urn), [selectedSchools]);
|
||||||
|
const clearMapSelection = useCallback(() => setSelectedMapSchool(null), []);
|
||||||
|
|
||||||
|
// A pin chosen on the map brings its card into view in the list beside it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMapSchool) return;
|
||||||
|
const card = mapListRef.current?.querySelector<HTMLElement>(`[data-urn="${selectedMapSchool.urn}"]`);
|
||||||
|
if (card?.offsetParent) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}, [selectedMapSchool]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Switching view from far down the list would otherwise leave the reader
|
* Switching view from far down the list would otherwise leave the reader
|
||||||
@@ -483,6 +548,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
||||||
if (view === resultsView) return;
|
if (view === resultsView) return;
|
||||||
setResultsView(view);
|
setResultsView(view);
|
||||||
|
setViewChosen(true);
|
||||||
track('results_view_changed', { view, via });
|
track('results_view_changed', { view, via });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const results = resultsRef.current;
|
const results = resultsRef.current;
|
||||||
@@ -496,11 +562,52 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMapView = initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch;
|
||||||
|
|
||||||
|
// The count and the sort. Above the list in list view; at the top of the
|
||||||
|
// list pane, beside the map, in map view.
|
||||||
|
const resultsHeader = (
|
||||||
|
<div className={styles.resultsHeader}>
|
||||||
|
<h2 aria-live="polite" aria-atomic="true">
|
||||||
|
{isLocationSearch && initialSchools.location_info
|
||||||
|
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||||||
|
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
<div className={styles.resultsHeaderActions}>
|
||||||
|
<select
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={e => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (e.target.value === 'default') {
|
||||||
|
params.delete('sort');
|
||||||
|
} else {
|
||||||
|
params.set('sort', e.target.value);
|
||||||
|
}
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
className={styles.sortSelect}
|
||||||
|
>
|
||||||
|
<option value="default">Sort: Relevance</option>
|
||||||
|
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||||||
|
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
||||||
|
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||||||
|
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||||
|
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||||
|
<option value="name_asc">Name A–Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||||
CSS); the search arrangement is a filter bar directly above its results
|
CSS); the search arrangement is a filter bar directly above its results
|
||||||
and wants none of it. */
|
and wants none of it. */
|
||||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
<div
|
||||||
|
className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}
|
||||||
|
style={toolbarHeight ? { '--toolbar-h': `${toolbarHeight}px` } as React.CSSProperties : undefined}
|
||||||
|
>
|
||||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||||
the brand landscape bleeding to the panel edge on the right. The
|
the brand landscape bleeding to the panel edge on the right. The
|
||||||
search lives inside the panel here and above the results elsewhere,
|
search lives inside the panel here and above the results elsewhere,
|
||||||
@@ -669,43 +776,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSearch && (
|
{hasSearch && !isMapView && resultsHeader}
|
||||||
<div className={styles.resultsHeader}>
|
|
||||||
<h2 aria-live="polite" aria-atomic="true">
|
|
||||||
{isLocationSearch && initialSchools.location_info
|
|
||||||
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
|
||||||
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
|
||||||
}
|
|
||||||
</h2>
|
|
||||||
<div className={styles.resultsHeaderActions}>
|
|
||||||
{resultsView === 'list' && (
|
|
||||||
<select
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={e => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
if (e.target.value === 'default') {
|
|
||||||
params.delete('sort');
|
|
||||||
} else {
|
|
||||||
params.set('sort', e.target.value);
|
|
||||||
}
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
|
||||||
}}
|
|
||||||
className={styles.sortSelect}
|
|
||||||
>
|
|
||||||
<option value="default">Sort: Relevance</option>
|
|
||||||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
|
||||||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
|
||||||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
|
||||||
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
|
||||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
|
||||||
<option value="name_asc">Name A–Z</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSearchActive && (
|
{isSearchActive && !isMapView && (
|
||||||
<div className={styles.activeFilters}>
|
<div className={styles.activeFilters}>
|
||||||
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
||||||
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||||||
@@ -724,35 +797,41 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
|
) : isMapView ? (
|
||||||
/* Map View Layout */
|
/* Map View Layout: the list beside the map, as one card. */
|
||||||
<div className={styles.mapViewContainer}>
|
<div className={styles.mapViewContainer}>
|
||||||
|
<div className={styles.mapListPane}>
|
||||||
|
{resultsHeader}
|
||||||
|
<div ref={mapListRef} className={styles.compactList}>
|
||||||
|
{mapListSchools.map((school) => (
|
||||||
|
<CompactSchoolItem
|
||||||
|
key={school.urn}
|
||||||
|
school={school}
|
||||||
|
onAddToCompare={addSchoolFromSearch}
|
||||||
|
isInCompare={compareUrns.includes(school.urn)}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
laAverages={laAverages}
|
||||||
|
isSelected={selectedMapSchool?.urn === school.urn}
|
||||||
|
onSelect={setSelectedMapSchool}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
<SchoolMap
|
<SchoolMap
|
||||||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
schools={mapResultSchools}
|
||||||
center={initialSchools.location_info?.coordinates}
|
center={initialSchools.location_info?.coordinates}
|
||||||
referencePoint={initialSchools.location_info?.coordinates}
|
referencePoint={initialSchools.location_info?.coordinates}
|
||||||
|
radiusMiles={radiusMiles}
|
||||||
|
selectedUrn={selectedMapSchool?.urn ?? null}
|
||||||
onMarkerClick={setSelectedMapSchool}
|
onMarkerClick={setSelectedMapSchool}
|
||||||
|
onDeselect={clearMapSelection}
|
||||||
|
onAddToCompare={addSchoolFromSearch}
|
||||||
|
compareUrns={compareUrns}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
laAverages={laAverages}
|
laAverages={laAverages}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactList}>
|
|
||||||
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
|
||||||
<div
|
|
||||||
key={school.urn}
|
|
||||||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
|
||||||
>
|
|
||||||
<CompactSchoolItem
|
|
||||||
school={school}
|
|
||||||
onAddToCompare={addSchoolFromSearch}
|
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* List View Layout */
|
/* List View Layout */
|
||||||
@@ -829,8 +908,10 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
<CompactSchoolItem
|
<CompactSchoolItem
|
||||||
school={selectedMapSchool}
|
school={selectedMapSchool}
|
||||||
onAddToCompare={addSchoolFromSearch}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
isInCompare={compareUrns.includes(selectedMapSchool.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
laAverages={laAverages}
|
||||||
|
showView
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -840,100 +921,104 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact School Item for Map View */
|
/* Compact School Item: a card in the map view's list, and the phone's bottom sheet. */
|
||||||
interface CompactSchoolItemProps {
|
interface CompactSchoolItemProps {
|
||||||
school: School;
|
school: School;
|
||||||
onAddToCompare: (school: School) => void;
|
onAddToCompare: (school: School) => void;
|
||||||
isInCompare: boolean;
|
isInCompare: boolean;
|
||||||
nationalAvgRwm?: number | null;
|
nationalAvgRwm?: number | null;
|
||||||
|
laAverages?: Record<string, number>;
|
||||||
|
isSelected?: boolean;
|
||||||
|
/** Clicking the card (not its link or button) picks its pin on the map. */
|
||||||
|
onSelect?: (school: School) => void;
|
||||||
|
/** The bottom sheet has no list around it, so it carries its own View. */
|
||||||
|
showView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
|
function CompactSchoolItem({
|
||||||
|
school, onAddToCompare, isInCompare, nationalAvgRwm, laAverages, isSelected, onSelect, showView,
|
||||||
|
}: CompactSchoolItemProps) {
|
||||||
const ofstedBadge = buildOfstedListBadge(school);
|
const ofstedBadge = buildOfstedListBadge(school);
|
||||||
const isSecondary = school.attainment_8_score != null;
|
const special = isSpecialSchool(school);
|
||||||
|
const href = schoolUrl(school.urn, school.school_name);
|
||||||
|
|
||||||
// vs-national delta for primary schools
|
/*
|
||||||
const rwmDelta =
|
* The headline figure, then its comparison. Same rules as the list rows:
|
||||||
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
|
* no placeholder all-zero RWM, and no mainstream benchmark for special
|
||||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
* schools, PRUs or AP.
|
||||||
: null;
|
*/
|
||||||
|
let figure: React.ReactNode = null;
|
||||||
|
if (school.attainment_8_score != null) {
|
||||||
|
const laAvg = school.local_authority ? laAverages?.[school.local_authority] : undefined;
|
||||||
|
const diff = !special && laAvg != null
|
||||||
|
? Math.round((school.attainment_8_score - laAvg) * 10) / 10 : null;
|
||||||
|
figure = (
|
||||||
|
<>
|
||||||
|
<strong>{school.attainment_8_score.toFixed(1)}</strong> Att 8
|
||||||
|
{diff != null && (
|
||||||
|
<span className={diff >= 0.5 ? styles.deltaUp : diff <= -0.5 ? styles.deltaDown : undefined}>
|
||||||
|
{' '}{diff >= 0 ? '+' : ''}{diff} vs LA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const rwm = listRwmValue(school);
|
||||||
|
if (rwm != null) {
|
||||||
|
const diff = !special && nationalAvgRwm != null ? Math.round(rwm - nationalAvgRwm) : null;
|
||||||
|
figure = (
|
||||||
|
<>
|
||||||
|
<strong>{rwm}%</strong> RWM
|
||||||
|
{diff != null && (
|
||||||
|
<span className={diff >= 2 ? styles.deltaUp : diff <= -2 ? styles.deltaDown : undefined}>
|
||||||
|
{' '}{diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deltaStyle: React.CSSProperties =
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
rwmDelta == null
|
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||||
? {}
|
onSelect?.(school);
|
||||||
: rwmDelta >= 2
|
};
|
||||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
|
||||||
: rwmDelta <= -2
|
|
||||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
|
||||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.compactItem}>
|
<div
|
||||||
<div className={styles.compactItemContent}>
|
data-urn={school.urn}
|
||||||
<div className={styles.compactItemHeader}>
|
className={`${styles.compactItem} ${isSelected ? styles.compactItemSelected : ''}`}
|
||||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
onClick={onSelect ? handleClick : undefined}
|
||||||
{school.school_name}
|
>
|
||||||
</a>
|
<div className={styles.compactItemHeader}>
|
||||||
{school.distance !== undefined && school.distance !== null && (
|
<a href={href} className={styles.compactItemName}>{school.school_name}</a>
|
||||||
<span className={styles.distanceBadge}>
|
{school.distance != null && (
|
||||||
{school.distance.toFixed(1)} mi
|
<span className={styles.distanceBadge}>{school.distance.toFixed(1)} mi</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Ofsted badge */}
|
|
||||||
<div style={{ marginBottom: '0.25rem' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '0.0625rem 0.375rem',
|
|
||||||
fontSize: '0.625rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: '3px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
|
||||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
|
||||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
|
||||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
|
||||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
|
||||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
|
||||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ofstedBadge.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Headline metric + delta */}
|
|
||||||
<div className={styles.compactItemStats}>
|
|
||||||
<span className={styles.compactStat}>
|
|
||||||
<strong>
|
|
||||||
{isSecondary
|
|
||||||
? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
|
|
||||||
: (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
|
|
||||||
</strong>
|
|
||||||
{' '}
|
|
||||||
{isSecondary ? 'Att 8' : 'RWM'}
|
|
||||||
</span>
|
|
||||||
{rwmDelta != null && (
|
|
||||||
<span style={deltaStyle}>
|
|
||||||
{rwmDelta >= 2
|
|
||||||
? `+${rwmDelta} pts vs national`
|
|
||||||
: rwmDelta <= -2
|
|
||||||
? `${rwmDelta} pts vs national`
|
|
||||||
: '≈ national avg'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactItemActions}>
|
<div className={styles.compactItemTags}>
|
||||||
<button
|
<span className={`${styles.compactBadge} ${styles[ofstedBadge.cssClass] ?? ''}`}>
|
||||||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
{ofstedBadge.label}
|
||||||
onClick={() => onAddToCompare(school)}
|
</span>
|
||||||
>
|
{school.school_type && <span className={styles.compactTag}>{school.school_type}</span>}
|
||||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
</div>
|
||||||
</button>
|
<div className={styles.compactItemFooter}>
|
||||||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
<span className={styles.compactStat}>
|
||||||
View
|
{figure}
|
||||||
</a>
|
{school.total_pupils != null && (
|
||||||
|
<>{figure ? ' · ' : ''}{school.total_pupils.toLocaleString('en-GB')} pupils</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className={styles.compactItemActions}>
|
||||||
|
{showView && <a href={href} className="btn btn-tertiary btn-sm">View</a>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||||||
|
onClick={() => onAddToCompare(school)}
|
||||||
|
>
|
||||||
|
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,209 +9,274 @@ import { useEffect, useRef } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { schoolUrl } from '@/lib/utils';
|
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
||||||
|
|
||||||
// Fix for default marker icons in Next.js
|
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
||||||
L.Icon.Default.mergeOptions({
|
|
||||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
|
||||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
|
||||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface LeafletMapInnerProps {
|
interface LeafletMapInnerProps {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
zoom: number;
|
zoom: number;
|
||||||
referencePoint?: [number, number];
|
referencePoint?: [number, number];
|
||||||
|
/** Search radius in miles. Drawn as a dashed circle the view fits to. */
|
||||||
|
radiusMiles?: number;
|
||||||
|
/** The school whose pin is highlighted and, on wide screens, whose card is open. */
|
||||||
|
selectedUrn?: number | null;
|
||||||
onMarkerClick?: (school: School) => void;
|
onMarkerClick?: (school: School) => void;
|
||||||
|
/** The open card was closed from the map itself (its × or a click elsewhere). */
|
||||||
|
onDeselect?: () => void;
|
||||||
|
onAddToCompare?: (school: School) => void;
|
||||||
|
compareUrns?: number[];
|
||||||
nationalAvgRwm?: number | null;
|
nationalAvgRwm?: number | null;
|
||||||
laAverages?: Record<string, number | null>;
|
laAverages?: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METRES_PER_MILE = 1609.344;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wide screens show the selected school as a card on the map. Narrow ones do
|
||||||
|
* not: HomeView shows the same card as a bottom sheet there, and a popup on a
|
||||||
|
* 390px map would cover most of it.
|
||||||
|
*/
|
||||||
|
const CARD_ON_MAP_QUERY = '(min-width: 769px)';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
|
// Card helpers (must work in plain JS string templates — no React / CSS Modules)
|
||||||
|
// Class names are global and styled in SchoolMap.module.css.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupBadge {
|
function pinIcon(selected: boolean): L.DivIcon {
|
||||||
label: string;
|
const size = selected ? 32 : 22;
|
||||||
style: string;
|
return L.divIcon({
|
||||||
|
className: selected ? 'sc-pin sc-pin--selected' : 'sc-pin',
|
||||||
|
html: '',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPopupBadge(school: School): PopupBadge {
|
interface CardContext {
|
||||||
// A report card wins over any carried-forward legacy grade — its presence is
|
inCompare: boolean;
|
||||||
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
nationalAvgRwm?: number | null;
|
||||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
laAverages?: Record<string, number | null>;
|
||||||
if (school.ofsted_rc_date) {
|
|
||||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
|
||||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
|
||||||
}
|
|
||||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
|
||||||
const yearStr = year ? ` · ${year}` : '';
|
|
||||||
if (school.ofsted_grade) {
|
|
||||||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
|
||||||
const colours: Record<number, string> = {
|
|
||||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
|
||||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
|
||||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
|
||||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
|
||||||
};
|
|
||||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
|
||||||
}
|
|
||||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
/** The headline figure and its comparison, the same rule as the list rows. */
|
||||||
|
function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext): string {
|
||||||
|
const special = isSpecialSchool(school);
|
||||||
|
if (school.attainment_8_score != null) {
|
||||||
|
const score = school.attainment_8_score;
|
||||||
|
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||||||
|
let delta = '';
|
||||||
|
if (!special && laAvg != null) {
|
||||||
|
const diff = Math.round((score - laAvg) * 10) / 10;
|
||||||
|
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2.
|
||||||
|
const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
|
||||||
|
delta = ` <span class="${cls}">${diff >= 0 ? '+' : ''}${diff} vs LA</span>`;
|
||||||
|
}
|
||||||
|
return `<strong>${score.toFixed(1)}</strong> Att 8${delta}`;
|
||||||
|
}
|
||||||
|
const rwm = listRwmValue(school);
|
||||||
|
if (rwm != null) {
|
||||||
|
let delta = '';
|
||||||
|
if (!special && nationalAvgRwm != null) {
|
||||||
|
const diff = Math.round(rwm - nationalAvgRwm);
|
||||||
|
const cls = diff >= 2 ? 'sc-up' : diff <= -2 ? 'sc-down' : '';
|
||||||
|
const text = diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national';
|
||||||
|
delta = ` <span class="${cls}">${text}</span>`;
|
||||||
|
}
|
||||||
|
return `<strong>${rwm}%</strong> RWM${delta}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(school: School, ctx: CardContext): string {
|
||||||
|
const badge = buildOfstedListBadge(school);
|
||||||
|
const href = schoolUrl(school.urn, school.school_name);
|
||||||
|
const distance = school.distance != null
|
||||||
|
? `<span class="sc-dist">${school.distance.toFixed(1)} mi</span>` : '';
|
||||||
|
const type = school.school_type
|
||||||
|
? `<span class="sc-tag">${escapeHtml(school.school_type)}</span>` : '';
|
||||||
|
const metric = metricHtml(school, ctx);
|
||||||
|
const pupils = school.total_pupils != null
|
||||||
|
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
|
||||||
|
return `<div class="sc-card">
|
||||||
|
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
|
||||||
|
<div class="sc-card-tags"><span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>${type}</div>
|
||||||
|
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
|
||||||
|
<div class="sc-card-actions">
|
||||||
|
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
|
||||||
|
<button type="button" class="btn ${ctx.inCompare ? 'btn-active' : 'btn-secondary'} btn-sm" data-compare>${ctx.inCompare ? '✓ Comparing' : '+ Compare'}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeafletMapInner({
|
||||||
|
schools, center, zoom, referencePoint, radiusMiles, selectedUrn,
|
||||||
|
onMarkerClick, onDeselect, onAddToCompare, compareUrns,
|
||||||
|
nationalAvgRwm, laAverages,
|
||||||
|
}: LeafletMapInnerProps) {
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const refMarkerRef = useRef<L.Marker | null>(null);
|
const markersRef = useRef<Map<number, L.Marker>>(new Map());
|
||||||
|
const overlayRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const popupRef = useRef<L.Popup | null>(null);
|
||||||
|
const selectedRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// The popup is plain HTML outside React, so its handlers read the latest
|
||||||
|
// props through a ref rather than closing over the render they were made in.
|
||||||
|
const latest = useRef({ onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages });
|
||||||
|
latest.current = { onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages };
|
||||||
|
|
||||||
|
const cardHtmlFor = (school: School) => cardHtml(school, {
|
||||||
|
inCompare: !!latest.current.compareUrns?.includes(school.urn),
|
||||||
|
nationalAvgRwm: latest.current.nationalAvgRwm,
|
||||||
|
laAverages: latest.current.laAverages,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map, tiles and controls: once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current) return;
|
if (!mapContainerRef.current || mapRef.current) return;
|
||||||
|
const map = L.map(mapContainerRef.current, { zoomControl: false }).setView(center, zoom);
|
||||||
|
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
overlayRef.current = L.layerGroup().addTo(map);
|
||||||
|
mapRef.current = map;
|
||||||
|
// center/zoom only seed the first view; later changes go through the
|
||||||
|
// markers effect, which fits the view to the results.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize map
|
// Search location, radius and school pins.
|
||||||
if (!mapRef.current) {
|
useEffect(() => {
|
||||||
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
|
const map = mapRef.current;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
if (!map || !overlay) return;
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
// Forget the open card before closing it, so its close handler does not
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
// read this rebuild as the reader deselecting; the selection effect below
|
||||||
maxZoom: 19,
|
// reopens it on the new pin.
|
||||||
}).addTo(mapRef.current);
|
popupRef.current = null;
|
||||||
}
|
map.closePopup();
|
||||||
|
overlay.clearLayers();
|
||||||
|
markersRef.current.clear();
|
||||||
|
|
||||||
// Clear existing school markers (not the reference pin)
|
let radiusBounds: L.LatLngBounds | null = null;
|
||||||
mapRef.current.eachLayer((layer) => {
|
if (referencePoint) {
|
||||||
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
if (radiusMiles) {
|
||||||
mapRef.current!.removeLayer(layer);
|
const circle = L.circle(referencePoint, {
|
||||||
|
radius: radiusMiles * METRES_PER_MILE,
|
||||||
|
className: 'sc-radius',
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(overlay);
|
||||||
|
radiusBounds = circle.getBounds();
|
||||||
|
const label = radiusMiles === 1 ? '1 mile' : `${radiusMiles} miles`;
|
||||||
|
L.marker([radiusBounds.getNorth(), referencePoint[1]], {
|
||||||
|
icon: L.divIcon({ className: 'sc-radius-label', html: label, iconSize: undefined }),
|
||||||
|
interactive: false,
|
||||||
|
keyboard: false,
|
||||||
|
}).addTo(overlay);
|
||||||
}
|
}
|
||||||
});
|
L.marker(referencePoint, {
|
||||||
|
icon: L.divIcon({ className: 'sc-home', html: '', iconSize: [16, 16], iconAnchor: [8, 8] }),
|
||||||
// Add reference pin (search location)
|
zIndexOffset: 500,
|
||||||
if (refMarkerRef.current) {
|
title: 'Search location',
|
||||||
refMarkerRef.current.remove();
|
alt: 'Search location',
|
||||||
refMarkerRef.current = null;
|
}).addTo(overlay);
|
||||||
}
|
|
||||||
if (referencePoint && mapRef.current) {
|
|
||||||
const refIcon = L.divIcon({
|
|
||||||
html: `<div style="
|
|
||||||
width: 20px; height: 20px;
|
|
||||||
background: var(--brand);
|
|
||||||
border: 3px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
|
||||||
"></div>`,
|
|
||||||
iconSize: [20, 20],
|
|
||||||
iconAnchor: [10, 10],
|
|
||||||
className: '',
|
|
||||||
});
|
|
||||||
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
|
||||||
.addTo(mapRef.current)
|
|
||||||
.bindPopup('<strong>Search location</strong>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add markers for schools
|
for (const school of schools) {
|
||||||
schools.forEach((school) => {
|
if (!school.latitude || !school.longitude) continue;
|
||||||
if (school.latitude && school.longitude && mapRef.current) {
|
const selected = school.urn === selectedRef.current;
|
||||||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
const marker = L.marker([school.latitude, school.longitude], {
|
||||||
|
icon: pinIcon(selected),
|
||||||
|
zIndexOffset: selected ? 1000 : 0,
|
||||||
|
title: school.school_name,
|
||||||
|
alt: school.school_name,
|
||||||
|
riseOnHover: true,
|
||||||
|
}).addTo(overlay);
|
||||||
|
marker.on('click', () => latest.current.onMarkerClick?.(school));
|
||||||
|
markersRef.current.set(school.urn, marker);
|
||||||
|
}
|
||||||
|
|
||||||
// Create popup content
|
// Fit the search radius when there is one: every result lies inside it,
|
||||||
const badge = buildPopupBadge(school);
|
// and it keeps the view the same shape whatever the pins happen to be.
|
||||||
const isSecondary = school.attainment_8_score != null;
|
if (radiusBounds) {
|
||||||
|
map.fitBounds(radiusBounds, { padding: [16, 16] });
|
||||||
// Phase label
|
} else if (schools.length > 1) {
|
||||||
const rawPhase = (school.phase ?? '').toLowerCase();
|
const points = schools
|
||||||
const phaseLabel =
|
.filter(s => s.latitude && s.longitude)
|
||||||
rawPhase.includes('secondary') ? 'Secondary' :
|
.map(s => [s.latitude!, s.longitude!] as [number, number]);
|
||||||
rawPhase === 'all-through' ? 'All-through' :
|
if (points.length) map.fitBounds(L.latLngBounds(points), { padding: [50, 50] });
|
||||||
rawPhase.includes('primary') ? 'Primary' :
|
|
||||||
isSecondary ? 'Secondary' : 'Primary';
|
|
||||||
|
|
||||||
// Distance string
|
|
||||||
const distanceStr =
|
|
||||||
school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : '';
|
|
||||||
|
|
||||||
// Headline metric
|
|
||||||
let metricHtml = '';
|
|
||||||
if (isSecondary) {
|
|
||||||
const score = school.attainment_8_score!;
|
|
||||||
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
|
||||||
let deltaLine = '';
|
|
||||||
if (laAvg != null) {
|
|
||||||
const diff = Math.round((score - laAvg) * 10) / 10;
|
|
||||||
const sign = diff >= 0 ? '+' : '';
|
|
||||||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
|
||||||
// vs primary RWM % where ±2 pts is the threshold
|
|
||||||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
|
||||||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
|
||||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
|
||||||
}
|
|
||||||
metricHtml = `<div style="margin-bottom:4px">
|
|
||||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
|
||||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
|
||||||
${deltaLine}
|
|
||||||
</div>`;
|
|
||||||
} else if (school.rwm_expected_pct != null) {
|
|
||||||
const rwm = school.rwm_expected_pct;
|
|
||||||
let deltaLine = '';
|
|
||||||
if (nationalAvgRwm != null) {
|
|
||||||
const diff = Math.round(rwm - nationalAvgRwm);
|
|
||||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
|
||||||
const text =
|
|
||||||
diff >= 2 ? `+${diff} pts vs national` :
|
|
||||||
diff <= -2 ? `${diff} pts vs national` :
|
|
||||||
'≈ national avg';
|
|
||||||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
|
||||||
}
|
|
||||||
metricHtml = `<div style="margin-bottom:4px">
|
|
||||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
|
||||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
|
||||||
${deltaLine}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = schoolUrl(school.urn, school.school_name);
|
|
||||||
|
|
||||||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
|
||||||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
|
||||||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
|
||||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
|
||||||
</div>
|
|
||||||
${metricHtml}
|
|
||||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:var(--text-inverse);border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
marker.bindPopup(popupContent);
|
|
||||||
|
|
||||||
if (onMarkerClick) {
|
|
||||||
marker.on('click', () => onMarkerClick(school));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update map view
|
|
||||||
if (schools.length > 1) {
|
|
||||||
const bounds = L.latLngBounds(
|
|
||||||
schools
|
|
||||||
.filter(s => s.latitude && s.longitude)
|
|
||||||
.map(s => [s.latitude!, s.longitude!] as [number, number])
|
|
||||||
);
|
|
||||||
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
|
|
||||||
} else {
|
} else {
|
||||||
mapRef.current.setView(center, zoom);
|
map.setView(center, zoom);
|
||||||
|
}
|
||||||
|
}, [schools, center, zoom, referencePoint, radiusMiles]);
|
||||||
|
|
||||||
|
// Selection: restyle the pin, and on wide screens open its card.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const previous = selectedRef.current;
|
||||||
|
selectedRef.current = selectedUrn ?? null;
|
||||||
|
|
||||||
|
if (previous != null && previous !== selectedUrn) {
|
||||||
|
const old = markersRef.current.get(previous);
|
||||||
|
old?.setIcon(pinIcon(false));
|
||||||
|
old?.setZIndexOffset(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
const marker = selectedUrn != null ? markersRef.current.get(selectedUrn) : undefined;
|
||||||
return () => {
|
const school = selectedUrn != null ? schools.find(s => s.urn === selectedUrn) : undefined;
|
||||||
// Don't destroy map on every update, just clean markers
|
if (!marker || !school) {
|
||||||
};
|
popupRef.current = null;
|
||||||
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
map.closePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
marker.setIcon(pinIcon(true));
|
||||||
|
marker.setZIndexOffset(1000);
|
||||||
|
|
||||||
|
if (!window.matchMedia(CARD_ON_MAP_QUERY).matches) {
|
||||||
|
if (!map.getBounds().contains(marker.getLatLng())) map.panTo(marker.getLatLng());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = L.popup({
|
||||||
|
className: 'sc-popup',
|
||||||
|
maxWidth: 300,
|
||||||
|
minWidth: 260,
|
||||||
|
offset: [0, -12],
|
||||||
|
autoPanPadding: [24, 24],
|
||||||
|
}).setLatLng(marker.getLatLng()).setContent(cardHtmlFor(school));
|
||||||
|
popup.on('remove', () => {
|
||||||
|
// Closed from the map, not replaced by another selection.
|
||||||
|
if (popupRef.current === popup) {
|
||||||
|
popupRef.current = null;
|
||||||
|
latest.current.onDeselect?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
popupRef.current = popup;
|
||||||
|
popup.openOn(map);
|
||||||
|
// One listener on the card's shell: it outlives the content swaps below.
|
||||||
|
popup.getElement()?.addEventListener('click', (e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('[data-compare]')) latest.current.onAddToCompare?.(school);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedUrn, schools]);
|
||||||
|
|
||||||
|
// The open card follows the compare basket and the averages as they arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
const popup = popupRef.current;
|
||||||
|
if (!popup || selectedUrn == null) return;
|
||||||
|
const school = schools.find(s => s.urn === selectedUrn);
|
||||||
|
if (school) popup.setContent(cardHtmlFor(school));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [compareUrns, nationalAvgRwm, laAverages]);
|
||||||
|
|
||||||
// Cleanup map on unmount
|
// Cleanup map on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -74,3 +74,173 @@
|
|||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Results map skin ──────────────────────────────────────────────────────
|
||||||
|
Leaflet renders these outside React, so they are global class names, scoped
|
||||||
|
under .mapWrapper to keep this module pure. Pins are brand teal, the chosen
|
||||||
|
one coral; the search location is ink, ringed. */
|
||||||
|
|
||||||
|
/* Muted tiles so the pins, not the streets, carry the colour. */
|
||||||
|
.mapWrapper :global(.leaflet-tile-pane) {
|
||||||
|
filter: saturate(0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The fullscreen button owns the top-right corner; zoom stacks under it. */
|
||||||
|
.mapWrapper :global(.leaflet-top.leaflet-right) {
|
||||||
|
top: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-pin) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
border: 2.5px solid var(--bg-card);
|
||||||
|
box-shadow: 0 2px 5px rgba(var(--shadow-rgb), 0.35);
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-pin:hover) {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-pin--selected) {
|
||||||
|
background: var(--action);
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-pin:focus-visible) {
|
||||||
|
outline: 3px solid var(--text-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-home) {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
box-shadow: 0 0 0 6px rgba(var(--shadow-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-radius) {
|
||||||
|
stroke: var(--brand);
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-dasharray: 6 6;
|
||||||
|
fill: var(--brand);
|
||||||
|
fill-opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-radius-label) {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
transform-origin: center;
|
||||||
|
translate: -50% -50%;
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The selected school's card, drawn as a card rather than a speech bubble. */
|
||||||
|
.mapWrapper :global(.sc-popup .leaflet-popup-content-wrapper) {
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 28px rgba(var(--shadow-rgb), 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-popup .leaflet-popup-content) {
|
||||||
|
margin: 0.875rem 1rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-head) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-right: 1rem; /* clear of the popup's × */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-name) {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-name:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-dist) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--brand-on);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-tags) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-ofsted),
|
||||||
|
.mapWrapper :global(.sc-tag) {
|
||||||
|
padding: 0.125rem 0.4375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-tag) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-ofsted1),
|
||||||
|
.mapWrapper :global(.sc-ofsted2) { background: var(--status-above-bg); color: var(--status-above); }
|
||||||
|
.mapWrapper :global(.sc-ofsted3) { background: var(--status-below-bg); color: var(--status-below); }
|
||||||
|
.mapWrapper :global(.sc-ofsted4) { background: var(--status-below); color: var(--text-inverse); }
|
||||||
|
.mapWrapper :global(.sc-ofstedRc) { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||||
|
.mapWrapper :global(.sc-ofstedInspected) { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||||
|
.mapWrapper :global(.sc-ofstedPending) { background: var(--border); color: var(--text-muted); }
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-metric) {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-metric strong) {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-up) { color: var(--status-above); font-weight: 600; }
|
||||||
|
.mapWrapper :global(.sc-down) { color: var(--status-below); font-weight: 600; }
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-actions) {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.sc-card-actions > *) {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -26,12 +26,17 @@ interface SchoolMapProps {
|
|||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
referencePoint?: [number, number];
|
referencePoint?: [number, number];
|
||||||
|
radiusMiles?: number;
|
||||||
|
selectedUrn?: number | null;
|
||||||
onMarkerClick?: (school: School) => void;
|
onMarkerClick?: (school: School) => void;
|
||||||
|
onDeselect?: () => void;
|
||||||
|
onAddToCompare?: (school: School) => void;
|
||||||
|
compareUrns?: number[];
|
||||||
nationalAvgRwm?: number | null;
|
nationalAvgRwm?: number | null;
|
||||||
laAverages?: Record<string, number | null>;
|
laAverages?: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages, ...selection }: SchoolMapProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
||||||
@@ -123,6 +128,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
|||||||
onMarkerClick={onMarkerClick}
|
onMarkerClick={onMarkerClick}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
laAverages={laAverages}
|
laAverages={laAverages}
|
||||||
|
{...selection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
|
||||||
import styles from './SchoolRow.module.css';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
interface SchoolRowProps {
|
interface SchoolRowProps {
|
||||||
@@ -38,18 +38,10 @@ export function SchoolRow({
|
|||||||
school.religious_denomination &&
|
school.religious_denomination &&
|
||||||
school.religious_denomination !== 'Does not apply';
|
school.religious_denomination !== 'Does not apply';
|
||||||
|
|
||||||
// A placeholder all-zero row (every subject 0 — a special/suppressed
|
|
||||||
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
|
|
||||||
// so its figure is hidden. A genuine 0% combined (some pupils met individual
|
|
||||||
// subjects but not all three) is NOT all-zero and stays shown.
|
|
||||||
const rwmPlaceholder =
|
|
||||||
school.rwm_expected_pct === 0 &&
|
|
||||||
(school.reading_expected_pct ?? 0) === 0 &&
|
|
||||||
(school.writing_expected_pct ?? 0) === 0 &&
|
|
||||||
(school.maths_expected_pct ?? 0) === 0;
|
|
||||||
// The school's OWN figure and its year-over-year trend are same-school
|
// The school's OWN figure and its year-over-year trend are same-school
|
||||||
// measures — shown whenever there's a real value, special schools included.
|
// measures — shown whenever there's a real value (not the all-zero
|
||||||
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
// placeholder, see listRwmValue), special schools included.
|
||||||
|
const showRwmValue = listRwmValue(school) != null;
|
||||||
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||||
const rwmDelta =
|
const rwmDelta =
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Whether a User-Agent string belongs to a phone or tablet.
|
||||||
|
*
|
||||||
|
* Used for one thing: choosing the results view before the first paint, when
|
||||||
|
* the viewport is not yet known. A wrong answer is corrected client-side (see
|
||||||
|
* HomeView's MAP_DEFAULT_MIN_WIDTH), so this errs towards "handheld" and is
|
||||||
|
* kept deliberately small rather than a full UA parser.
|
||||||
|
*
|
||||||
|
* iPadOS reports a desktop Mac UA by default and so reads as desktop here,
|
||||||
|
* which is the right call: a landscape iPad has room for the map layout.
|
||||||
|
*/
|
||||||
|
const HANDHELD = /Mobi|Android|iPhone|iPad|iPod|Tablet|Silk|Kindle|PlayBook|BlackBerry|Opera Mini|IEMobile/i;
|
||||||
|
|
||||||
|
export function isHandheldUserAgent(ua: string | null | undefined): boolean {
|
||||||
|
return !!ua && HANDHELD.test(ua);
|
||||||
|
}
|
||||||
@@ -898,3 +898,27 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
|
|||||||
const t = (school.school_type ?? '').toLowerCase();
|
const t = (school.school_type ?? '').toLowerCase();
|
||||||
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The school's combined Reading, Writing & Maths figure, or null when there is
|
||||||
|
* no real one to show.
|
||||||
|
*
|
||||||
|
* A placeholder all-zero row (every subject 0, the special/suppressed
|
||||||
|
* signature that SchoolDetailView calls ks2Placeholder) is not a score. A
|
||||||
|
* genuine 0% combined, where some pupils met individual subjects but not all
|
||||||
|
* three, is not all-zero and stays shown.
|
||||||
|
*/
|
||||||
|
export function listRwmValue(school: {
|
||||||
|
rwm_expected_pct?: number | null;
|
||||||
|
reading_expected_pct?: number | null;
|
||||||
|
writing_expected_pct?: number | null;
|
||||||
|
maths_expected_pct?: number | null;
|
||||||
|
}): number | null {
|
||||||
|
if (school.rwm_expected_pct == null) return null;
|
||||||
|
const placeholder =
|
||||||
|
school.rwm_expected_pct === 0 &&
|
||||||
|
(school.reading_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.writing_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.maths_expected_pct ?? 0) === 0;
|
||||||
|
return placeholder ? null : school.rwm_expected_pct;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user