Merge pull request 'feat(search): align the map view with the mockup and open postcode searches on it' (#158) from feat/results-map-view into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
commit
cff3854e63
13 files changed
+1145
-478
No files matched your search
+61
-21
@@ -500,12 +500,8 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
||||
delete Element.prototype.requestFullscreen;
|
||||
});
|
||||
|
||||
// A postcode search opens on the map, phones included; open it fullscreen.
|
||||
await searchByName(page, 'B1 1BB');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to the map view with the floating button (the toolbar's switch is
|
||||
// hidden at phone width), then open the map fullscreen.
|
||||
await page.getByRole('button', { name: 'Show map' }).click();
|
||||
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||
await openFs.click();
|
||||
@@ -532,7 +528,10 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
||||
|
||||
const view = page.getByRole('group', { name: 'Results view' });
|
||||
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 expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
|
||||
@@ -548,27 +547,59 @@ test('the results toolbar stays pinned with its List/Map switch', async ({ page
|
||||
.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]) {
|
||||
test(`the floating Map button is in reach at ${width}px`, async ({ page }) => {
|
||||
test(`a phone opens on the map, with the list a tap away, at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await searchByName(page, 'B1 1BB');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Visible without scrolling, and clear of the bottom tab bar.
|
||||
const fab = page.getByRole('button', { name: 'Show map' });
|
||||
await expect(fab).toBeInViewport();
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
// On the map, with the floating button offering the list, clear of the
|
||||
// bottom tab bar.
|
||||
const toList = page.getByRole('button', { name: 'Show list' });
|
||||
await expect(toList).toBeInViewport({ timeout: 15_000 });
|
||||
await expect(page.locator('.sc-pin').first()).toBeAttached({ timeout: 15_000 });
|
||||
const barTop = await page.locator('nav[class*="bottomBar"]')
|
||||
.evaluate((el) => el.getBoundingClientRect().top);
|
||||
const fabBox = (await toList.boundingBox())!;
|
||||
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(barTop);
|
||||
|
||||
// The search folds to a summary, and the pinned toolbar survives a scroll.
|
||||
const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
|
||||
await expect(summary).toBeVisible();
|
||||
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
|
||||
await expect(summary).toBeInViewport();
|
||||
await expect(fab).toBeInViewport();
|
||||
// A pin opens the bottom sheet, stacked under the button, above the bar.
|
||||
// dispatchEvent, not click: a pin may sit under the button or the toolbar,
|
||||
// and Leaflet listens on the pin itself.
|
||||
await page.locator('.sc-pin').first().dispatchEvent('click');
|
||||
const sheet = page.locator('[class*="bottomSheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
expect(sheetBox.y + sheetBox.height).toBeLessThanOrEqual(barTop);
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(fabBox.y + fabBox.height);
|
||||
|
||||
// MOBILE.md: no horizontal overflow, and 44px targets in the new chrome.
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth))
|
||||
@@ -584,8 +615,17 @@ for (const width of [360, 390, 430]) {
|
||||
});
|
||||
expect(small).toEqual([]);
|
||||
|
||||
await fab.click();
|
||||
await expect(page.getByRole('button', { name: 'Show list' })).toBeVisible();
|
||||
// The list: the search folds to a summary, and the pinned toolbar and
|
||||
// the button survive a scroll.
|
||||
await toList.click();
|
||||
const toMap = page.getByRole('button', { name: 'Show map' });
|
||||
await expect(toMap).toBeInViewport();
|
||||
await expect(schoolLinks(page).first()).toBeVisible();
|
||||
const summary = page.getByRole('button', { name: /^Edit search: B1 1BB/ });
|
||||
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(600);
|
||||
await expect(summary).toBeInViewport();
|
||||
await expect(toMap).toBeInViewport();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test('load-more results from an old search are discarded, even after returning to it', async () => {
|
||||
// Name searches: a postcode search opens on the map, which has no Load more.
|
||||
params = new URLSearchParams('search=abbey');
|
||||
const pending = deferred();
|
||||
jest.mocked(fetchSchools).mockReturnValueOnce(pending.promise);
|
||||
const view = render(<HomeView initialSchools={response('Initial A')} filters={filters} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load more schools' }));
|
||||
const signal = jest.mocked(fetchSchools).mock.calls[0][1]?.signal;
|
||||
params = new URLSearchParams('postcode=SW2+1AA');
|
||||
params = new URLSearchParams('search=brecknock');
|
||||
view.rerender(<HomeView initialSchools={response('Initial B')} filters={filters} />);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
params = new URLSearchParams('postcode=SW1A+1AA');
|
||||
params = new URLSearchParams('search=abbey');
|
||||
view.rerender(<HomeView initialSchools={response('Fresh A')} filters={filters} />);
|
||||
await act(async () => pending.resolve(response('Stale append')));
|
||||
expect(screen.queryByText('Stale append')).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('puts the card back when the pins are rebuilt for a reason other than the schools', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
act(() => rerender({ onDeselect, selectedUrn: 1, radiusMiles: 3, referencePoint: [51.43, -0.21] }));
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('.sc-radius-label')).toHaveTextContent('3 miles');
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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), where every postcode
|
||||
* search opens. 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);
|
||||
setWide(true);
|
||||
});
|
||||
|
||||
/** Desktop unless a test says otherwise: the list pane is shown from 769px. */
|
||||
function setWide(wide: boolean) {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
async function renderMap() {
|
||||
const view = render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
return view;
|
||||
}
|
||||
|
||||
it('opens a postcode search on the map', async () => {
|
||||
await renderMap();
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '1');
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('lists a name search, which has no map', async () => {
|
||||
params = new URLSearchParams('search=southmead');
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('opens the map after a hero search, and keeps the reader\'s choice after that', async () => {
|
||||
// Landing page, then a hero search: the same instance gets new props.
|
||||
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} />);
|
||||
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
|
||||
// Chosen: a later search keeps the list.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
params = new URLSearchParams('postcode=SW170AA&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds no list cards on a phone, where the pane is hidden', async () => {
|
||||
setWide(false);
|
||||
const { container } = await renderMap();
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('[data-urn]')).toHaveLength(0);
|
||||
// The count stays: it is the pane's heading, shown above the map.
|
||||
expect(screen.getByRole('heading', { name: /3 schools within/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -54,25 +54,28 @@ describe('the List/Map switch', () => {
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
const view = screen.getByRole('group', { name: 'Results view' });
|
||||
expect(view.closest('div[class*="resultsToolbar"]')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'false');
|
||||
// A postcode search opens on the map.
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('has a floating twin that flips between map and list', async () => {
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'map', via: 'floating' });
|
||||
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show list' }));
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show map' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(track).toHaveBeenCalledWith('results_view_changed', { view: 'list', via: 'floating' });
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Show map' })));
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not track a click on the view already showing', () => {
|
||||
it('does not track a click on the view already showing', async () => {
|
||||
render(<HomeView initialSchools={results()} filters={filters} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Map' }));
|
||||
expect(track).not.toHaveBeenCalledWith('results_view_changed', expect.anything());
|
||||
});
|
||||
|
||||
|
||||
@@ -119,10 +119,13 @@ describe('third-party surfaces under themed text', () => {
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// 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 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);
|
||||
|
||||
@@ -572,133 +572,174 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 1rem;
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 520px;
|
||||
max-height: 800px;
|
||||
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
|
||||
height: calc(100dvh - var(--map-top) - var(--map-bottom));
|
||||
min-height: 480px;
|
||||
background: var(--bg-card);
|
||||
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 {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compactList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.625rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding-right: 0.375rem;
|
||||
padding: 0.125rem 1rem 1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.compactList::-webkit-scrollbar {
|
||||
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 */
|
||||
/* Compact School Item: the list pane's card, and the phone's bottom sheet. */
|
||||
.compactItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
flex-direction: column;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.compactItem:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 2px 6px rgba(var(--shadow-rgb), 0.05);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.compactItemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.compactItemSelected,
|
||||
.compactItemSelected:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.28);
|
||||
}
|
||||
|
||||
.compactItemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.compactItemName {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.compactItemName:hover {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactItemMeta {
|
||||
.compactItemTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.compactBadge,
|
||||
.compactTag {
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactTag {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compactItemMeta span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin-left: 0.375rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ofsted1,
|
||||
.ofsted2 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.compactStat {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compactStat strong {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.deltaUp { color: var(--status-above); font-weight: 600; }
|
||||
.deltaDown { color: var(--status-below); font-weight: 600; }
|
||||
|
||||
.compactItemActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -795,49 +836,11 @@
|
||||
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 {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
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. */
|
||||
@@ -883,10 +886,7 @@
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bottomSheet .compactItem:hover {
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.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 {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 400px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
height: 100%;
|
||||
.mapListPane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mapListPane .sortSelect,
|
||||
.compactList {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mapListPane .resultsHeader {
|
||||
padding: 0.625rem 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the tab bar, and riding with it when iOS Chrome's URL bar hides
|
||||
@@ -964,6 +970,16 @@
|
||||
.hasViewFab {
|
||||
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 {
|
||||
|
||||
+222
-143
@@ -5,7 +5,7 @@
|
||||
|
||||
'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 { FilterBar } from './FilterBar';
|
||||
import { SchoolRow } from './SchoolRow';
|
||||
@@ -16,7 +16,7 @@ import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
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 styles from './HomeView.module.css';
|
||||
|
||||
@@ -33,6 +33,13 @@ interface HomeViewProps {
|
||||
autosuggest?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* The view a postcode search opens in, on every screen size. The map is on
|
||||
* trial as the default (2026-09-30); results_view_changed shows how often
|
||||
* readers switch away from it. Name searches have no map and always list.
|
||||
*/
|
||||
const DEFAULT_LOCATION_VIEW: 'list' | 'map' = 'map';
|
||||
|
||||
function daysUntil(month: number, day: number): number {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
@@ -222,7 +229,13 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||||
/*
|
||||
* The reader's choice, once they make one; until then the default for the
|
||||
* kind of search. Derived rather than seeded into state, because a hero
|
||||
* search reaches the results by client-side navigation: the same instance
|
||||
* gets new props, and a state seeded on the landing page would stay "list".
|
||||
*/
|
||||
const [chosenView, setChosenView] = useState<'list' | 'map' | null>(null);
|
||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||||
const sortOrder = searchParams.get('sort') || 'default';
|
||||
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||||
@@ -236,6 +249,8 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const resultsRef = useRef<HTMLElement>(null);
|
||||
const mapListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mapParamsRef = useRef<string>('');
|
||||
const loadMoreController = useRef<AbortController | null>(null);
|
||||
// Identity changes even for A → B → A, so an old A response stays stale.
|
||||
@@ -283,6 +298,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const isSecondaryView = currentPhase.toLowerCase().includes('secondary')
|
||||
|| (!currentPhase && secondaryCount > primaryCount);
|
||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||
const resultsView: 'list' | 'map' = chosenView ?? (isLocationSearch ? DEFAULT_LOCATION_VIEW : 'list');
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -419,7 +446,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
);
|
||||
}, [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_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);
|
||||
@@ -428,6 +455,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -473,6 +508,31 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
||||
const compareUrns = useMemo(() => selectedSchools.map(s => s.urn), [selectedSchools]);
|
||||
|
||||
/*
|
||||
* Below 769px the map view hides its list pane (HomeView.module.css), and
|
||||
* with the map now the default on phones, every postcode search there
|
||||
* would otherwise build up to 500 hidden cards. Decided after mount, so
|
||||
* the server's HTML and the first client render still agree.
|
||||
*/
|
||||
const [listPaneShown, setListPaneShown] = useState(true);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return;
|
||||
const query = window.matchMedia('(min-width: 769px)');
|
||||
const update = () => setListPaneShown(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
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
|
||||
@@ -482,7 +542,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
*/
|
||||
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
||||
if (view === resultsView) return;
|
||||
setResultsView(view);
|
||||
setChosenView(view);
|
||||
track('results_view_changed', { view, via });
|
||||
requestAnimationFrame(() => {
|
||||
const results = resultsRef.current;
|
||||
@@ -496,11 +556,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 (
|
||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||
CSS); the search arrangement is a filter bar directly above its results
|
||||
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
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
@@ -669,43 +770,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearch && (
|
||||
<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>
|
||||
)}
|
||||
{hasSearch && !isMapView && resultsHeader}
|
||||
|
||||
{isSearchActive && (
|
||||
{isSearchActive && !isMapView && (
|
||||
<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('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||||
@@ -724,35 +791,41 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
|
||||
/* Map View Layout */
|
||||
) : isMapView ? (
|
||||
/* Map View Layout: the list beside the map, as one card. */
|
||||
<div className={styles.mapViewContainer}>
|
||||
<div className={styles.mapListPane}>
|
||||
{resultsHeader}
|
||||
<div ref={mapListRef} className={styles.compactList}>
|
||||
{listPaneShown && 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}>
|
||||
<SchoolMap
|
||||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||||
schools={mapResultSchools}
|
||||
center={initialSchools.location_info?.coordinates}
|
||||
referencePoint={initialSchools.location_info?.coordinates}
|
||||
radiusMiles={radiusMiles}
|
||||
selectedUrn={selectedMapSchool?.urn ?? null}
|
||||
onMarkerClick={setSelectedMapSchool}
|
||||
onDeselect={clearMapSelection}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
compareUrns={compareUrns}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
/* List View Layout */
|
||||
@@ -829,8 +902,10 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
<CompactSchoolItem
|
||||
school={selectedMapSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||
isInCompare={compareUrns.includes(selectedMapSchool.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
showView
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -840,100 +915,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 {
|
||||
school: School;
|
||||
onAddToCompare: (school: School) => void;
|
||||
isInCompare: boolean;
|
||||
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 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 =
|
||||
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
|
||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||||
: null;
|
||||
/*
|
||||
* The headline figure, then its comparison. Same rules as the list rows:
|
||||
* no placeholder all-zero RWM, and no mainstream benchmark for special
|
||||
* schools, PRUs or AP.
|
||||
*/
|
||||
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 =
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: 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)' };
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
onSelect?.(school);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
<div className={styles.compactItemContent}>
|
||||
<div className={styles.compactItemHeader}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.distance !== undefined && school.distance !== null && (
|
||||
<span className={styles.distanceBadge}>
|
||||
{school.distance.toFixed(1)} mi
|
||||
</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
|
||||
data-urn={school.urn}
|
||||
className={`${styles.compactItem} ${isSelected ? styles.compactItemSelected : ''}`}
|
||||
onClick={onSelect ? handleClick : undefined}
|
||||
>
|
||||
<div className={styles.compactItemHeader}>
|
||||
<a href={href} className={styles.compactItemName}>{school.school_name}</a>
|
||||
{school.distance != null && (
|
||||
<span className={styles.distanceBadge}>{school.distance.toFixed(1)} mi</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.compactItemActions}>
|
||||
<button
|
||||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||||
onClick={() => onAddToCompare(school)}
|
||||
>
|
||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
<div className={styles.compactItemTags}>
|
||||
<span className={`${styles.compactBadge} ${styles[ofstedBadge.cssClass] ?? ''}`}>
|
||||
{ofstedBadge.label}
|
||||
</span>
|
||||
{school.school_type && <span className={styles.compactTag}>{school.school_type}</span>}
|
||||
</div>
|
||||
<div className={styles.compactItemFooter}>
|
||||
<span className={styles.compactStat}>
|
||||
{figure}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -5,213 +5,283 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { School } from '@/lib/types';
|
||||
import { schoolUrl } 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',
|
||||
});
|
||||
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
||||
|
||||
interface LeafletMapInnerProps {
|
||||
schools: School[];
|
||||
center: [number, number];
|
||||
zoom: 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;
|
||||
/** 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;
|
||||
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 {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
interface PopupBadge {
|
||||
label: string;
|
||||
style: string;
|
||||
function pinIcon(selected: boolean): L.DivIcon {
|
||||
const size = selected ? 32 : 22;
|
||||
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 {
|
||||
// A report card wins over any carried-forward legacy grade — its presence is
|
||||
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||
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)' };
|
||||
interface CardContext {
|
||||
inCompare: boolean;
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
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 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);
|
||||
// Bumped whenever the pins are rebuilt, for any reason, so the selection
|
||||
// effect puts the card back on the new pin rather than only when the
|
||||
// selection or the school list changes.
|
||||
const [pinsVersion, setPinsVersion] = useState(0);
|
||||
|
||||
// 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(() => {
|
||||
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
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
|
||||
// Search location, radius and school pins.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!map || !overlay) return;
|
||||
|
||||
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(mapRef.current);
|
||||
}
|
||||
// Forget the open card before closing it, so its close handler does not
|
||||
// read this rebuild as the reader deselecting; the selection effect below
|
||||
// reopens it on the new pin.
|
||||
popupRef.current = null;
|
||||
map.closePopup();
|
||||
overlay.clearLayers();
|
||||
markersRef.current.clear();
|
||||
|
||||
// Clear existing school markers (not the reference pin)
|
||||
mapRef.current.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||||
mapRef.current!.removeLayer(layer);
|
||||
let radiusBounds: L.LatLngBounds | null = null;
|
||||
if (referencePoint) {
|
||||
if (radiusMiles) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Add reference pin (search location)
|
||||
if (refMarkerRef.current) {
|
||||
refMarkerRef.current.remove();
|
||||
refMarkerRef.current = null;
|
||||
}
|
||||
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>');
|
||||
L.marker(referencePoint, {
|
||||
icon: L.divIcon({ className: 'sc-home', html: '', iconSize: [16, 16], iconAnchor: [8, 8] }),
|
||||
zIndexOffset: 500,
|
||||
title: 'Search location',
|
||||
alt: 'Search location',
|
||||
}).addTo(overlay);
|
||||
}
|
||||
|
||||
// Add markers for schools
|
||||
schools.forEach((school) => {
|
||||
if (school.latitude && school.longitude && mapRef.current) {
|
||||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
||||
for (const school of schools) {
|
||||
if (!school.latitude || !school.longitude) continue;
|
||||
const selected = school.urn === selectedRef.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
|
||||
const badge = buildPopupBadge(school);
|
||||
const isSecondary = school.attainment_8_score != null;
|
||||
|
||||
// Phase label
|
||||
const rawPhase = (school.phase ?? '').toLowerCase();
|
||||
const phaseLabel =
|
||||
rawPhase.includes('secondary') ? 'Secondary' :
|
||||
rawPhase === 'all-through' ? 'All-through' :
|
||||
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] });
|
||||
// Fit the search radius when there is one: every result lies inside it,
|
||||
// and it keeps the view the same shape whatever the pins happen to be.
|
||||
if (radiusBounds) {
|
||||
map.fitBounds(radiusBounds, { padding: [16, 16] });
|
||||
} else if (schools.length > 1) {
|
||||
const points = schools
|
||||
.filter(s => s.latitude && s.longitude)
|
||||
.map(s => [s.latitude!, s.longitude!] as [number, number]);
|
||||
if (points.length) map.fitBounds(L.latLngBounds(points), { padding: [50, 50] });
|
||||
} else {
|
||||
mapRef.current.setView(center, zoom);
|
||||
map.setView(center, zoom);
|
||||
}
|
||||
setPinsVersion(v => v + 1);
|
||||
}, [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
|
||||
return () => {
|
||||
// Don't destroy map on every update, just clean markers
|
||||
};
|
||||
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
||||
const marker = selectedUrn != null ? markersRef.current.get(selectedUrn) : undefined;
|
||||
const school = selectedUrn != null ? schools.find(s => s.urn === selectedUrn) : undefined;
|
||||
if (!marker || !school) {
|
||||
popupRef.current = null;
|
||||
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, pinsVersion]);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
|
||||
@@ -74,3 +74,173 @@
|
||||
font-size: 0.9375rem;
|
||||
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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { School } from '@/lib/types';
|
||||
import styles from './SchoolMap.module.css';
|
||||
|
||||
@@ -26,12 +26,17 @@ interface SchoolMapProps {
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
referencePoint?: [number, number];
|
||||
radiusMiles?: number;
|
||||
selectedUrn?: number | null;
|
||||
onMarkerClick?: (school: School) => void;
|
||||
onDeselect?: () => void;
|
||||
onAddToCompare?: (school: School) => void;
|
||||
compareUrns?: number[];
|
||||
nationalAvgRwm?: 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 [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
||||
@@ -80,8 +85,9 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
}
|
||||
}, [fallbackFullscreen]);
|
||||
|
||||
// Calculate center if not provided
|
||||
const mapCenter: [number, number] = center || (() => {
|
||||
// Calculate center if not provided. Memoised: a fresh array on every render
|
||||
// would make the map refit and rebuild every pin each time.
|
||||
const mapCenter = useMemo<[number, number]>(() => center || (() => {
|
||||
if (schools.length === 0) return [51.5074, -0.1278];
|
||||
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
|
||||
return [schools[0].latitude, schools[0].longitude];
|
||||
@@ -90,8 +96,8 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
if (validSchools.length === 0) return [51.5074, -0.1278];
|
||||
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
|
||||
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
|
||||
return [avgLat, avgLng];
|
||||
})();
|
||||
return [avgLat, avgLng] as [number, number];
|
||||
})(), [center, schools]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
||||
@@ -123,6 +129,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
onMarkerClick={onMarkerClick}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
{...selection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
interface SchoolRowProps {
|
||||
@@ -38,18 +38,10 @@ export function SchoolRow({
|
||||
school.religious_denomination &&
|
||||
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
|
||||
// measures — shown whenever there's a real value, special schools included.
|
||||
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
||||
// measures — shown whenever there's a real value (not the all-zero
|
||||
// placeholder, see listRwmValue), special schools included.
|
||||
const showRwmValue = listRwmValue(school) != null;
|
||||
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||
const rwmDelta =
|
||||
|
||||
@@ -898,3 +898,27 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
|
||||
const t = (school.school_type ?? '').toLowerCase();
|
||||
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