feat(map): fetch all schools for map view, add reference pin, cap radius at 5mi
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Remove 10-mile radius option; cap backend radius max at 5 miles - Raise backend page_size max to 500 so map can fetch all schools in one call - HomeView: when map view is active, fetch all schools within radius (page_size=500) instead of showing only the paginated first page; falls back to initial SSR schools while loading - SchoolMap/LeafletMapInner: accept referencePoint prop and render a distinctive coral circle pin at the search postcode location Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -293,9 +293,9 @@ async def get_schools(
|
|||||||
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
||||||
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
|
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
|
||||||
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
||||||
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
|
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
|
||||||
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
||||||
page_size: int = Query(25, ge=1, le=100, description="Results per page"),
|
page_size: int = Query(25, ge=1, le=500, description="Results per page"),
|
||||||
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
|
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
|
||||||
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
|
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
|
||||||
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
|
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
<option value="1">1 mile</option>
|
<option value="1">1 mile</option>
|
||||||
<option value="3">3 miles</option>
|
<option value="3">3 miles</option>
|
||||||
<option value="5">5 miles</option>
|
<option value="5">5 miles</option>
|
||||||
<option value="10">10 miles</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
|
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
|
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
|
||||||
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||||
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
@@ -52,6 +54,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
setAllSchools(initialSchools.schools);
|
setAllSchools(initialSchools.schools);
|
||||||
setCurrentPage(initialSchools.page);
|
setCurrentPage(initialSchools.page);
|
||||||
setHasMore(initialSchools.total_pages > 1);
|
setHasMore(initialSchools.total_pages > 1);
|
||||||
|
setMapSchools([]);
|
||||||
}
|
}
|
||||||
}, [searchParams, initialSchools]);
|
}, [searchParams, initialSchools]);
|
||||||
|
|
||||||
@@ -60,6 +63,20 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
setSelectedMapSchool(null);
|
setSelectedMapSchool(null);
|
||||||
}, [resultsView, searchParams]);
|
}, [resultsView, searchParams]);
|
||||||
|
|
||||||
|
// Fetch all schools within radius when map view is active
|
||||||
|
useEffect(() => {
|
||||||
|
if (resultsView !== 'map' || !isLocationSearch) return;
|
||||||
|
setIsLoadingMap(true);
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
searchParams.forEach((value, key) => { params[key] = value; });
|
||||||
|
params.page = 1;
|
||||||
|
params.page_size = 500;
|
||||||
|
fetchSchools(params, { cache: 'no-store' })
|
||||||
|
.then(r => setMapSchools(r.schools))
|
||||||
|
.catch(() => setMapSchools(initialSchools.schools))
|
||||||
|
.finally(() => setIsLoadingMap(false));
|
||||||
|
}, [resultsView, searchParams]);
|
||||||
|
|
||||||
// Fetch LA averages when secondary schools are visible
|
// Fetch LA averages when secondary schools are visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSecondaryView) return;
|
if (!isSecondaryView) return;
|
||||||
@@ -214,13 +231,14 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<div className={styles.mapViewContainer}>
|
<div className={styles.mapViewContainer}>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
<SchoolMap
|
<SchoolMap
|
||||||
schools={initialSchools.schools}
|
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||||||
center={initialSchools.location_info?.coordinates}
|
center={initialSchools.location_info?.coordinates}
|
||||||
|
referencePoint={initialSchools.location_info?.coordinates}
|
||||||
onMarkerClick={setSelectedMapSchool}
|
onMarkerClick={setSelectedMapSchool}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactList}>
|
<div className={styles.compactList}>
|
||||||
{initialSchools.schools.map((school) => (
|
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||||||
<div
|
<div
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ interface LeafletMapInnerProps {
|
|||||||
schools: School[];
|
schools: School[];
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
referencePoint?: [number, number];
|
||||||
onMarkerClick?: (school: School) => void;
|
onMarkerClick?: (school: School) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }: LeafletMapInnerProps) {
|
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick }: LeafletMapInnerProps) {
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refMarkerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current) return;
|
if (!mapContainerRef.current) return;
|
||||||
@@ -43,13 +45,36 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
|||||||
}).addTo(mapRef.current);
|
}).addTo(mapRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing markers
|
// Clear existing school markers (not the reference pin)
|
||||||
mapRef.current.eachLayer((layer) => {
|
mapRef.current.eachLayer((layer) => {
|
||||||
if (layer instanceof L.Marker) {
|
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||||||
mapRef.current!.removeLayer(layer);
|
mapRef.current!.removeLayer(layer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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: #e07256;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10],
|
||||||
|
className: '',
|
||||||
|
});
|
||||||
|
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
||||||
|
.addTo(mapRef.current)
|
||||||
|
.bindPopup('<strong>Search location</strong>');
|
||||||
|
}
|
||||||
|
|
||||||
// Add markers for schools
|
// Add markers for schools
|
||||||
schools.forEach((school) => {
|
schools.forEach((school) => {
|
||||||
if (school.latitude && school.longitude && mapRef.current) {
|
if (school.latitude && school.longitude && mapRef.current) {
|
||||||
@@ -89,7 +114,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
|||||||
return () => {
|
return () => {
|
||||||
// Don't destroy map on every update, just clean markers
|
// Don't destroy map on every update, just clean markers
|
||||||
};
|
};
|
||||||
}, [schools, center, zoom, onMarkerClick]);
|
}, [schools, center, zoom, referencePoint, onMarkerClick]);
|
||||||
|
|
||||||
// Cleanup map on unmount
|
// Cleanup map on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ interface SchoolMapProps {
|
|||||||
schools: School[];
|
schools: School[];
|
||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
referencePoint?: [number, number];
|
||||||
onMarkerClick?: (school: School) => void;
|
onMarkerClick?: (school: School) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, onMarkerClick }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) {
|
||||||
// Calculate center if not provided
|
// Calculate center if not provided
|
||||||
const mapCenter: [number, number] = center || (() => {
|
const mapCenter: [number, number] = center || (() => {
|
||||||
if (schools.length === 0) return [51.5074, -0.1278]; // Default to London
|
if (schools.length === 0) return [51.5074, -0.1278]; // Default to London
|
||||||
@@ -50,6 +51,7 @@ export function SchoolMap({ schools, center, zoom = 13, onMarkerClick }: SchoolM
|
|||||||
schools={schools}
|
schools={schools}
|
||||||
center={mapCenter}
|
center={mapCenter}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
referencePoint={referencePoint}
|
||||||
onMarkerClick={onMarkerClick}
|
onMarkerClick={onMarkerClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user