Files
TudorandClaude Opus 4.8 d4d9ae5252
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
feat(school-detail): map-blended hero, remove at-a-glance stats
Replace the header's at-a-glance stats row with a location map that sits
atop the hero and blends into the school title. The map is a static,
non-interactive preview (never traps page scroll) with a coral pin; the
whole band — or the inline "View on map ↗" link by the address — opens a
fullscreen, interactive map. Compare floats glassy over the band.

The separate "Location" section (and its nav item) is removed; the map now
lives only in the hero. Schools without lat/long render the header with no
map band, as before.

New: SchoolHeroMap (fullscreen wrapper, forwardRef open handle) +
LeafletHeroMapInner (minimal single-school map with interaction toggle).
Applied to both primary and secondary detail views; dead heroStats/tone/
mapContainer CSS removed (shared .heroStat* card classes kept).

Also drops the orphaned "Latest data" note and does not reintroduce an
Ofsted strip in the hero (it would duplicate the Ofsted section directly
below). Mockup kept at mockups/header-map-hero.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:12:12 +01:00

96 lines
3.2 KiB
TypeScript

/**
* LeafletHeroMapInner
* Minimal single-school map for the detail hero. Renders as a static preview
* (all interaction disabled so it never traps page scroll); when `interactive`
* flips true — i.e. the hero map has gone fullscreen — pan/zoom are enabled and
* a zoom control appears.
*/
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
interface LeafletHeroMapInnerProps {
lat: number;
lng: number;
interactive: boolean;
}
// Coral teardrop pin with a soft halo — inline styles only, so it renders
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
const PIN_HTML = `
<div style="position:relative;width:26px;height:26px">
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
<circle cx="13" cy="13" r="5" fill="#fff"/>
</svg>
</div>`;
export default function LeafletHeroMapInner({ lat, lng, interactive }: LeafletHeroMapInnerProps) {
const elRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
// Create the map once.
useEffect(() => {
if (!elRef.current || mapRef.current) return;
const map = L.map(elRef.current, {
zoomControl: false,
attributionControl: true,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
touchZoom: false,
}).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
L.marker([lat, lng], {
icon: L.divIcon({ className: '', iconSize: [26, 34], iconAnchor: [13, 30], html: PIN_HTML }),
keyboard: false,
}).addTo(map);
mapRef.current = map;
setTimeout(() => map.invalidateSize(), 60);
return () => {
map.remove();
mapRef.current = null;
zoomCtrlRef.current = null;
};
}, [lat, lng]);
// Toggle interaction (and the zoom control) when entering/leaving fullscreen,
// and re-measure since the container size changes.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handlers = [
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
];
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
if (interactive && !zoomCtrlRef.current) {
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
zoomCtrlRef.current.addTo(map);
} else if (!interactive && zoomCtrlRef.current) {
zoomCtrlRef.current.remove();
zoomCtrlRef.current = null;
}
if (!interactive) map.setView([lat, lng], 15);
setTimeout(() => map.invalidateSize(), 80);
}, [interactive, lat, lng]);
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
}