(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.
@@ -216,6 +220,7 @@ export default function LeafletMapInner({
} else {
map.setView(center, zoom);
}
+ setPinsVersion(v => v + 1);
}, [schools, center, zoom, referencePoint, radiusMiles]);
// Selection: restyle the pin, and on wide screens open its card.
@@ -267,7 +272,7 @@ export default function LeafletMapInner({
if ((e.target as HTMLElement).closest('[data-compare]')) latest.current.onAddToCompare?.(school);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedUrn, schools]);
+ }, [selectedUrn, pinsVersion]);
// The open card follows the compare basket and the averages as they arrive.
useEffect(() => {
diff --git a/nextjs-app/components/SchoolMap.tsx b/nextjs-app/components/SchoolMap.tsx
index 7f4a94a..a839daa 100644
--- a/nextjs-app/components/SchoolMap.tsx
+++ b/nextjs-app/components/SchoolMap.tsx
@@ -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';
@@ -85,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];
@@ -95,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 (
diff --git a/nextjs-app/lib/device.ts b/nextjs-app/lib/device.ts
deleted file mode 100644
index 509f1b7..0000000
--- a/nextjs-app/lib/device.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * 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);
-}