diff --git a/mockups/header-map-hero.html b/mockups/header-map-hero.html new file mode 100644 index 0000000..50418f1 --- /dev/null +++ b/mockups/header-map-hero.html @@ -0,0 +1,179 @@ + + + + + +School header — “Emerge” map hero (desktop + mobile) + + + + + + + + +
+
+

“Emerge” map hero — refined

+

Transition made more diffuse: the map dissolves gradually and reaches solid white before the school name, so the title sits cleanly on white with only a soft memory of the map above it. Redundant floating pill removed — the only map CTA is the inline “View on map ↗”. Desktop and mobile shown side by side.

+
+ +
Option A · “Emerge” (refined)
+

Same school throughout (Our Lady Queen of Heaven, SW19 6AD). Live, non-interactive preview maps; the CTA opens the full map.

+ +
+ +
+
Desktop
+ ← Back +
+ +
+
+
+

Our Lady Queen of Heaven RC School

+
WandsworthVoluntary aided school
+
+ + Victoria Drive, Southfields, London, SW19 6AD  ·  View on map ↗ +
+
+ Headteacher: Mr Jeremy Tuke + School website ↗ + Pupils: 221 (capacity: 232) +
+
Ofsted OutstandingInspected November 2023
+
+
+
+ + +
+
Mobile
+
+
+ +
+ ← Back +
+ +
+
+
+

Our Lady Queen of Heaven RC School

+
WandsworthVoluntary aided
+
+ + Victoria Drive, Southfields, SW19 6AD +
+ +
+ Headteacher: Mr Jeremy Tuke + School website ↗ + Pupils: 221 (cap. 232) +
+
Ofsted OutstandingInspected November 2023
+
+
+
+
+
+
+ +

No-location fallback — schools without lat/long simply render the hero with no map band (title at the top as today), so the component degrades cleanly.

+
+ + + + diff --git a/nextjs-app/components/LeafletHeroMapInner.tsx b/nextjs-app/components/LeafletHeroMapInner.tsx new file mode 100644 index 0000000..0f8d7c4 --- /dev/null +++ b/nextjs-app/components/LeafletHeroMapInner.tsx @@ -0,0 +1,95 @@ +/** + * 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 = ` +
+ + + + + +
`; + +export default function LeafletHeroMapInner({ lat, lng, interactive }: LeafletHeroMapInnerProps) { + const elRef = useRef(null); + const mapRef = useRef(null); + const zoomCtrlRef = useRef(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: '© OpenStreetMap 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
; +} diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index b4f25bd..956fa63 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -29,12 +29,15 @@ /* Header Section */ .header { + position: relative; background: var(--bg-card, white); border: 1px solid var(--border-color, #e5dfd5); border-radius: 10px; - padding: 1.25rem 1.5rem; + /* Padding lives on .headerContent so the map band can bleed to the edges. */ + padding: 0; margin-bottom: 0; box-shadow: var(--shadow-soft); + overflow: hidden; } .headerContent { @@ -42,6 +45,55 @@ justify-content: space-between; align-items: flex-start; gap: 1.5rem; + padding: 1.25rem 1.5rem; +} + +/* With a map band above, slide the title up under the fade so map and title + read as one object; the Compare button floats glassy over the map. */ +.headerHasMap .headerContent { + padding-top: 0; + margin-top: -0.5rem; + position: relative; + z-index: 3; +} + +.headerHasMap .actions { + position: absolute; + top: 14px; + right: 14px; + z-index: 6; + margin: 0; +} + +.headerHasMap .actions .btnAdd { + background: rgba(255, 255, 255, 0.9); + color: var(--accent-coral, #e07256); + border-color: transparent; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16); +} + +.headerHasMap .actions .btnAdd:hover { + background: #fff; +} + +/* Inline "View on map ↗" trigger next to the address. */ +.mapLink { + border: none; + background: none; + padding: 0; + font: inherit; + font-weight: 600; + color: var(--accent-coral, #e07256); + cursor: pointer; + white-space: nowrap; +} + +.mapLink:hover { + color: var(--accent-coral-dark, #c45a3f); + text-decoration: underline; + text-underline-offset: 2px; } .titleSection { @@ -841,18 +893,6 @@ color: var(--accent-teal, #2d7d7d); } -/* Map */ -.mapContainer { - width: 100%; - height: 250px; - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--border-color, #e5dfd5); - isolation: isolate; - z-index: 0; - position: relative; -} - /* History Table */ .tableWrapper { overflow-x: auto; @@ -1179,10 +1219,6 @@ height: auto; } - .mapContainer { - height: 200px; - } - .dataTable { font-size: 0.75rem; } @@ -1218,75 +1254,7 @@ } } -/* Hero tone scheme — colour tokens applied to the scorecard's serif Ofsted - number (colour only) without bleeding into the .ofstedGrade{N} badges. */ -.tone-teal { - --hero-tone: var(--accent-teal, #2d7d7d); -} -.tone-green { - --hero-tone: #3c8c3c; -} -.tone-gold { - --hero-tone: var(--accent-gold, #c9a227); -} -.tone-coral { - --hero-tone: var(--accent-coral, #e07256); -} -.tone-neutral { - --hero-tone: var(--text-muted, #8a847a); -} - -/* ── Hero at-a-glance stats (A3) ─────────────────────────────────────── */ -.heroStats { - display: flex; - flex-wrap: wrap; - gap: 1.25rem 3rem; - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color, #e5dfd5); -} - -.heroStat { - display: flex; - flex-direction: column; - gap: 0.35rem; - min-width: 0; - flex: 0 0 auto; -} - -.heroStatNumber, -.heroStatNumberSerif { - font-family: var(--font-playfair), "Playfair Display", serif; - font-weight: 700; - line-height: 1; - color: var(--text-primary, #1a1612); - /* Fixed height so every stat's label row sits at the same Y, regardless - of whether the content is a short numeral or a longer word. Each - stat's content is bottom-aligned within this box. */ - min-height: clamp(2rem, 4vw, 2.75rem); - display: flex; - align-items: flex-end; -} - -.heroStatNumber { - font-size: clamp(2rem, 4vw, 2.75rem); - font-variant-numeric: tabular-nums; -} - -.heroStatNumberSerif { - /* Slightly smaller so long words like "Requires Improvement" still fit, - but aligned on the same bottom baseline as the numeric stats. */ - font-size: clamp(1.75rem, 3.5vw, 2.25rem); -} - -.heroStatNumberSerif.tone-teal, -.heroStatNumberSerif.tone-green, -.heroStatNumberSerif.tone-gold, -.heroStatNumberSerif.tone-coral, -.heroStatNumberSerif.tone-neutral { - color: var(--hero-tone); -} - +/* .heroStatLabel is shared by the SATs / Pupils stat cards below. */ .heroStatLabel { font-size: 0.6875rem; font-weight: 600; @@ -1295,22 +1263,7 @@ color: var(--text-secondary, #5c564d); } -.heroStatFoot { - font-size: 0.75rem; - color: var(--text-muted, #8a847a); -} - -.heroDataNote { - margin: 0.5rem 0 0; - font-size: 0.75rem; - color: var(--text-muted, #8a847a); -} - @media (max-width: 640px) { - .heroStats { - gap: 1rem 1.5rem; - } - .heroSummary { font-size: 1rem; margin-top: 1rem; diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index c982c69..f6a2454 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useComparison } from '@/hooks/useComparison'; -import { SchoolMap } from './SchoolMap'; +import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap'; import { MetricTooltip } from './MetricTooltip'; import type { School, SchoolResult, AbsenceData, @@ -19,7 +19,6 @@ import type { } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear, - buildOfstedHeroChip, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; @@ -93,6 +92,8 @@ export function SchoolDetailView({ // Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves. const heroActionsRef = useRef(null); const [heroCtaVisible, setHeroCtaVisible] = useState(true); + // Hero map — the "View on map" link opens its fullscreen view. + const heroMapRef = useRef(null); // "All ▾" jump menu listing every section. const [sectionsOpen, setSectionsOpen] = useState(false); @@ -236,7 +237,6 @@ export function SchoolDetailView({ if (parentView && parentView.total_responses != null && parentView.total_responses > 0) navItems.push({ id: 'parents', label: 'Parents' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); - if (hasLocation) navItems.push({ id: 'location', label: 'Location' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); @@ -285,26 +285,6 @@ export function SchoolDetailView({ return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness); })(); - // ── Hero: framework-aware signal chip + narrative summary ───────────── - const ofstedHeroChip = buildOfstedHeroChip(ofsted); - - // KS2 headline numbers for the at-a-glance row - const heroRwm = isPrimary ? latestResults?.rwm_expected_pct ?? null : null; - const heroRwmNat = primaryAvg.rwm_expected_pct ?? null; - - // KS4 headline number for secondary/all-through schools - const heroAtt8 = isSecondary ? latestResults?.attainment_8_score ?? null : null; - const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null; - - const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : ''; - - // Scorecard renders if any tile has content, so a results-less school still - // shows its Ofsted signal (previously carried by the now-removed chip strip). - const hasHeroStats = heroRwm != null - || heroAtt8 != null - || ofsted != null - || admissions?.first_preference_offer_pct != null; - // Label shown in the mobile "section" menu button — the section in view. const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? ''; @@ -317,8 +297,11 @@ export function SchoolDetailView({ Back - {/* Header */} -
+ {/* Header — the location map band blends down into the school title. */} +
+ {hasLocation && ( + + )}

{schoolInfo.school_name}

@@ -336,6 +319,18 @@ export function SchoolDetailView({ {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} + {hasLocation && ( + <> + {' · '} + + + )}

)}
@@ -383,69 +378,6 @@ export function SchoolDetailView({
- - {/* At-a-glance stats row — the single home for the headline numbers. - Shows whenever any tile has content (results, Ofsted, or admissions), - so schools without KS2/KS4 results still carry their Ofsted signal. */} - {hasHeroStats && ( -
- {isPrimary && heroRwm != null && ( -
-
{Math.round(heroRwm)}%
-
Reading, Writing & Maths
- {heroRwmNat != null && ( - - )} -
- )} - - {isSecondary && heroAtt8 != null && ( -
-
{heroAtt8.toFixed(1)}
-
Attainment 8 score
- {heroAtt8Nat != null && ( - - )} -
- )} - - {ofsted && ( -
-
- {ofstedHeroChip.state === 'oeif' - ? ofstedHeroChip.title.replace(/^Ofsted\s+/, '') - : ofstedHeroChip.state === 'reportCard' - ? 'Report Card' - : '—'} -
-
- {ofstedHeroChip.subtitle} -
- {ofstedHeroChip.detail && ( -
{ofstedHeroChip.detail}
- )} -
- )} - - {admissions?.first_preference_offer_pct != null && ( -
-
- {Math.round(admissions.first_preference_offer_pct)}% -
-
First-choice offer rate
- {admissions.oversubscribed && ( -
Oversubscribed
- )} -
- )} -
- )} - - {heroAcademicYear && ( -

- Latest data: {heroAcademicYear} -

- )}
{/* Sticky Section Navigation — docks under the global header */} @@ -1266,20 +1198,6 @@ export function SchoolDetailView({ )} - {/* Location */} - {hasLocation && ( -
-

Location

-
- -
-
- )} - {/* Local Area Context */} {hasDeprivation && deprivation && (
diff --git a/nextjs-app/components/SchoolHeroMap.module.css b/nextjs-app/components/SchoolHeroMap.module.css new file mode 100644 index 0000000..99d118b --- /dev/null +++ b/nextjs-app/components/SchoolHeroMap.module.css @@ -0,0 +1,115 @@ +/* Preview band that blends into the hero title; expands to fullscreen. */ +.wrapper { + position: relative; + width: 100%; + height: 210px; + background: #dfe6e2; +} + +@media (max-width: 640px) { + .wrapper { + height: 150px; + } +} + +/* Fullscreen: the Fullscreen API promotes this element to fill the viewport. */ +.wrapper[data-fullscreen] { + height: 100vh; + height: 100dvh; + background: #fff; +} + +.skeleton { + width: 100%; + height: 100%; + background: + linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da; + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} + +@keyframes shimmer { + to { background-position: -200% 0; } +} + +/* Full-band click target. Transparent so the map shows through; reveals a + hint pill on hover/focus. Sits above the map but below the header's fade + and Compare button (which carry higher stacking in the header). */ +.openBtn { + position: absolute; + inset: 0; + z-index: 3; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.openHint { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 16px; + padding: 8px 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #1a1612); + background: rgba(255, 255, 255, .85); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + box-shadow: 0 2px 10px rgba(0, 0, 0, .16); + opacity: 0; + transform: translateY(4px); + transition: opacity .16s ease, transform .16s ease; +} + +.openBtn:hover .openHint, +.openBtn:focus-visible .openHint { + opacity: 1; + transform: none; +} + +/* Diffuse fade: map dissolves to the card background before the title. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 33%; + z-index: 2; + pointer-events: none; + background: linear-gradient(to bottom, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, .35) 35%, + rgba(255, 255, 255, .75) 62%, + rgba(255, 255, 255, .95) 82%, + var(--bg-card, #fff) 100%); +} + +.closeBtn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, .92); + color: var(--text-primary, #1a1612); + cursor: pointer; + box-shadow: 0 2px 10px rgba(0, 0, 0, .2); +} + +.closeBtn:hover { + background: #fff; +} diff --git a/nextjs-app/components/SchoolHeroMap.tsx b/nextjs-app/components/SchoolHeroMap.tsx new file mode 100644 index 0000000..18e5cc9 --- /dev/null +++ b/nextjs-app/components/SchoolHeroMap.tsx @@ -0,0 +1,77 @@ +/** + * SchoolHeroMap + * The location map that sits atop the school-detail hero. Shows a static + * preview (pin + tiles) that blends down into the title; the whole band — or + * the parent's "View on map" link, via the imperative `open()` handle — expands + * it to a fullscreen, interactive map. + */ + +'use client'; + +import dynamic from 'next/dynamic'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import styles from './SchoolHeroMap.module.css'; + +const LeafletHeroMap = dynamic(() => import('./LeafletHeroMapInner'), { + ssr: false, + loading: () =>
)} - {/* ── Location ───────────────────────────────────── */} - {hasLocation && ( -
-

Location

-
- -
-
- )} - {/* ── Finances ───────────────────────────────────── */} {hasFinance && finance && (