This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

View file

@ -33,6 +33,7 @@ import {
MAP_BOUNDS,
POI_GROUP_COLORS,
POSTCODE_ZOOM_THRESHOLD,
POI_AUTO_CARD_ZOOM_THRESHOLD,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
@ -104,6 +105,15 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
return parts.length > 0 ? parts.join(' · ') : null;
}
interface PoiPopupCardData {
name: string;
category: string;
icon_category?: string;
group: string;
emoji: string;
school?: SchoolMetadata;
}
interface Dimensions {
width: number;
height: number;
@ -289,10 +299,16 @@ function renderSchoolMetadata(school: SchoolMetadata) {
)}
{school.fsm_percent !== undefined && (
<>
<dt className="text-warm-500 dark:text-warm-400">FSM</dt>
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
</>
)}
{school.ofsted_rating && (
<>
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
</>
)}
{school.sixth_form === 'Has a sixth form' && (
<>
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
@ -358,6 +374,36 @@ function renderSchoolMetadata(school: SchoolMetadata) {
);
}
function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) {
return (
<div className="px-3 py-2 max-w-[280px]">
<div className="flex items-center gap-2">
<img
src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div className="min-w-0">
<div className="font-semibold dark:text-warm-100">{poi.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
}}
/>
{ts(poi.category)}
</div>
</div>
</div>
{poi.school && renderSchoolMetadata(poi.school)}
</div>
);
}
function getRenderedViewState(map: MapRef | null): ViewState | null {
if (!map) return null;
@ -575,6 +621,7 @@ export default memo(function Map({
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
const [mapReady, setMapReady] = useState(false);
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState = screenshotMode ? initialViewState : internalViewState;
@ -664,6 +711,10 @@ export default memo(function Map({
if (screenshotMode) window.__map_idle = true;
}, [screenshotMode]);
const handleLoad = useCallback(() => {
setMapReady(true);
}, []);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
@ -715,6 +766,7 @@ export default memo(function Map({
layers,
popupInfo,
clearPopupInfo,
visiblePois,
listingPopup,
clearListingPopup,
hoverPosition,
@ -744,6 +796,31 @@ export default memo(function Map({
travelTimeEntries,
});
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
const autoPoiCards = useMemo(() => {
const map = mapRef.current;
if (!showAutoPoiCards || !mapReady || !map || dimensions.width <= 0 || dimensions.height <= 0) {
return [];
}
return visiblePois.flatMap((poi) => {
const point = map.project([poi.lng, poi.lat]);
if (
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
point.x < 0 ||
point.x > dimensions.width ||
point.y < 0 ||
point.y > dimensions.height
) {
return [];
}
return [{ poi, x: point.x, y: point.y }];
});
// viewState isn't read directly but drives map.project — recompute when the camera moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
return (
<div
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
@ -755,7 +832,7 @@ export default memo(function Map({
ref={mapRef}
{...viewState}
onMove={handleMove}
onLoad={undefined}
onLoad={handleLoad}
onIdle={handleIdle}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
@ -896,14 +973,28 @@ export default memo(function Map({
))}
</div>
)}
{popupInfo && (
{autoPoiCards.map(({ poi, x, y }) => (
<div
key={poi.id}
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
style={{
left: x,
top: y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9,
}}
>
<PoiPopupCardContent poi={poi} />
</div>
))}
{popupInfo && (!showAutoPoiCards || popupInfo.isCluster) && (
<div
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 50,
transform: 'translateX(-50%)',
zIndex: 9999,
zIndex: 30,
}}
>
<button
@ -922,36 +1013,7 @@ export default memo(function Map({
</div>
</div>
) : (
<div className="px-3 py-2 max-w-[280px]">
<div className="flex items-center gap-2">
<img
src={getPoiIconUrl(
popupInfo.category,
popupInfo.emoji,
popupInfo.icon_category,
popupInfo.name
)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div>
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}}
/>
{ts(popupInfo.category)}
</div>
</div>
</div>
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
</div>
<PoiPopupCardContent poi={popupInfo} />
)}
</div>
)}
@ -962,7 +1024,7 @@ export default memo(function Map({
left: listingPopup.x,
top: listingPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
zIndex: 30,
}}
onMouseLeave={clearListingPopup}
>