perfect-postcode/frontend/src/components/map/Map.tsx
2026-05-17 13:52:11 +01:00

792 lines
29 KiB
TypeScript

import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
HexagonData,
PostcodeFeature,
PostcodeGeometry,
ViewState,
ViewChangeParams,
POI,
FeatureMeta,
Bounds,
MapFlyToOptions,
ActualListing,
} from '../../types';
import {
zoomToResolution,
getBoundsFromViewState,
getBoundsWithBottomScreenInset,
getMapStyle,
getPoiIconUrl,
getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers';
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { ts } from '../../i18n/server';
interface MapProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
pois: POI[];
actualListings?: ActualListing[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
onResetPreviewScale?: () => void;
canResetPreviewScale?: boolean;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState: ViewState;
flyToRef?: React.MutableRefObject<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
hideLocationSearch?: boolean;
hideTopCardsWhenNarrow?: boolean;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
bottomScreenInset?: number;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
}
interface Dimensions {
width: number;
height: number;
}
const DESKTOP_TOP_CARD_WIDTH = 300;
const DESKTOP_TOP_CARD_GAP = 8;
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';
type MapContainerStyle = CSSProperties & {
'--map-mobile-bottom-inset'?: string;
};
function resolveInset(
pixelValue: number | undefined,
ratioValue: number | undefined,
size: number
) {
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
const area = options?.visibleArea;
const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);
const left = Math.min(dimensions.width, leftInset);
const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
const top = Math.min(dimensions.height, topInset);
const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
function getViewportRelativeVisibleAreaCenter(
dimensions: Dimensions,
container: HTMLDivElement | null,
options?: MapFlyToOptions
) {
const area = options?.visibleViewportArea;
if (!area || !container) return null;
const rect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
const viewportRight = viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
const viewportBottom =
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
const right = clamp(viewportRight - rect.left, left, dimensions.width);
const top = clamp(viewportTop - rect.top, 0, dimensions.height);
const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
) => unknown;
__propertyMapNullViewportPatch?: boolean;
}
function patchNullViewportDraw(overlay: MapboxOverlay) {
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
return;
}
const drawLayers = deck._drawLayers.bind(deck);
deck._drawLayers = (redrawReason, renderOptions) => {
const viewports = renderOptions?.viewports;
if (viewports) {
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
const nonNullViewports = viewports.filter(Boolean);
if (nonNullViewports.length === 0) return;
if (nonNullViewports.length !== viewports.length) {
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
}
}
return drawLayers(redrawReason, renderOptions);
};
deck.__propertyMapNullViewportPatch = true;
}
class SafeMapboxOverlay extends MapboxOverlay {
onAdd(map: unknown) {
const element = super.onAdd(map);
patchNullViewportDraw(this);
return element;
}
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
super.setProps(props);
patchNullViewportDraw(this);
}
}
function getPoiGroupColor(group: string): [number, number, number] {
const color = POI_GROUP_COLORS[group];
if (!color) {
throw new Error(`Missing POI group color for '${group}'`);
}
return color;
}
function getRenderedViewState(map: MapRef | null): ViewState | null {
if (!map) return null;
const center = map.getCenter();
return {
longitude: center.lng,
latitude: center.lat,
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
};
}
function getRenderedVisibleCenter(
map: MapRef | null,
dimensions: Dimensions,
bottomScreenInset: number
): Pick<ViewState, 'latitude' | 'longitude'> | null {
if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null;
const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height);
const visibleCenterY = (dimensions.height - visibleBottomInset) / 2;
const center = map.unproject([dimensions.width / 2, visibleCenterY]);
return {
longitude: center.lng,
latitude: center.lat,
};
}
function DeckOverlay({
layers,
getTooltip,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layers: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
useEffect(() => {
overlay.setProps({
layers: layers.filter(Boolean),
getTooltip,
});
}, [overlay, layers, getTooltip]);
return null;
}
export default memo(function Map({
data,
postcodeData,
usePostcodeView,
pois,
actualListings = EMPTY_ACTUAL_LISTINGS,
onViewChange,
viewFeature,
colorRange,
filterRange,
viewSource,
onCancelPin,
onResetPreviewScale,
canResetPreviewScale = false,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme = 'light',
screenshotMode = false,
ogMode = false,
filters = {},
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
bounds: viewportBounds,
hideLegend = false,
hideLocationSearch = false,
hideTopCardsWhenNarrow = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp,
totalCount: totalCountProp,
bottomScreenInset = 0,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapRef | null>(null);
const { t } = useTranslation();
const modes = useTranslatedModes();
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState = screenshotMode ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let initialized = false;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
if (!initialized) {
initialized = true;
setDimensions({ width, height });
} else {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => setDimensions({ width, height }), 150);
}
}
});
observer.observe(container);
return () => {
observer.disconnect();
if (resizeTimer) clearTimeout(resizeTimer);
};
}, []);
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
let frame = 0;
const emit = () => {
const renderedViewState = getRenderedViewState(mapRef.current);
// mapRef can be null on the very first effect run if MapLibre hasn't
// finished mounting; retry next frame so the initial bounds always reach
// the data hook.
if (!renderedViewState) {
frame = window.requestAnimationFrame(emit);
return;
}
// The bottom sheet can reveal covered map area without a pan/zoom event.
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
const resolution = zoomToResolution(renderedViewState.zoom);
const renderedVisibleCenter =
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
renderedViewState;
onViewChange({
resolution,
bounds,
zoom: renderedViewState.zoom,
latitude: renderedViewState.latitude,
longitude: renderedViewState.longitude,
visibleLatitude: renderedVisibleCenter.latitude,
visibleLongitude: renderedVisibleCenter.longitude,
});
};
frame = window.requestAnimationFrame(emit);
return () => window.cancelAnimationFrame(frame);
}, [viewState, dimensions, bottomScreenInset, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setInternalViewState((prev) => {
const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize
// fires move events with identical lat/lng/zoom). Returning the same reference
// tells React to bail out.
if (
prev.latitude === next.latitude &&
prev.longitude === next.longitude &&
prev.zoom === next.zoom &&
prev.pitch === next.pitch &&
prev.bearing === next.bearing
) {
return prev;
}
return next;
});
}, []);
const handleIdle = useCallback(() => {
if (screenshotMode) window.__map_idle = true;
}, [screenshotMode]);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
const targetPoint =
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
getMapRelativeVisibleAreaCenter(dimensions, options);
const center = getMapCenterForTargetScreenPoint(
lat,
lng,
zoom,
dimensions.width,
dimensions.height,
targetPoint.x,
targetPoint.y
);
return { ...prev, ...center, zoom };
});
},
[dimensions]
);
if (flyToRef) flyToRef.current = handleFlyTo;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const maxBounds = useMemo(
() => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
[bottomScreenInset]
);
const mapContainerStyle = useMemo<MapContainerStyle>(
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
[bottomScreenInset]
);
const hideDesktopTopCardsForWidth =
hideTopCardsWhenNarrow &&
dimensions.width > 0 &&
dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
const stackDesktopTopCards =
hideTopCardsWhenNarrow &&
dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth;
const showLegend = !hideLegend && !hideDesktopTopCardsForWidth;
const desktopTopCardsLayoutClass = stackDesktopTopCards
? 'flex-col items-start'
: 'items-start justify-between';
const {
layers,
popupInfo,
clearPopupInfo,
listingPopup,
clearListingPopup,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
} = useDeckLayers({
data,
postcodeData,
usePostcodeView,
zoom: viewState.zoom,
pois,
actualListings,
viewFeature,
colorRange,
filterRange,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries,
});
return (
<div
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
ref={containerRef}
style={mapContainerStyle}
onMouseLeave={handleMouseLeave}
>
<MapGL
ref={mapRef}
{...viewState}
onMove={handleMove}
onLoad={undefined}
onIdle={handleIdle}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
dragRotate={false}
touchZoomRotate={true}
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={MAP_MIN_ZOOM}
maxBounds={maxBounds}
>
<DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{screenshotMode ? (
ogMode ? (
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
{/* Center: Logo card with hero text */}
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10 max-w-[1040px]">
<LogoIcon className="w-24 h-24 shrink-0 text-teal-400" />
<span
className="font-bold text-white/50"
style={{ fontSize: '4rem', lineHeight: 1.05, maxWidth: '760px' }}
>
{t('map.ogTitle')}
</span>
</div>
</div>
{/* Bottom bar */}
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
<div className="flex items-center gap-6">
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
{t('map.ogPropertyPrices')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
{t('map.ogEnergyRatings')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
{t('map.ogSchools')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
{t('map.ogCrimeStats')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
{t('map.ogTransport')}
</span>
</div>
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
perfect-postcode.co.uk
</span>
</div>
</div>
) : null
) : (
<>
{(showLocationSearch || showLegend) && (
<div
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${desktopTopCardsLayoutClass}`}
>
{showLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
className={DESKTOP_TOP_CARD_CLASS}
inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
/>
)}
{showLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
suffix=" min"
className={DESKTOP_TOP_CARD_CLASS}
/>
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
: ts(colorFeatureMeta.name)
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
featureName={colorFeatureMeta.name}
theme={theme}
suffix={colorFeatureMeta.suffix}
raw={colorFeatureMeta.raw}
className={DESKTOP_TOP_CARD_CLASS}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
className={DESKTOP_TOP_CARD_CLASS}
/>
))}
</div>
)}
{popupInfo && (
<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,
}}
>
<button
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearPopupInfo}
>
<CloseIcon className="w-3 h-3" />
</button>
{popupInfo.isCluster ? (
<div className="px-3 py-2 text-center">
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
{popupInfo.clusterCount}
</div>
<div className="text-warm-500 dark:text-warm-400 text-xs">
{t('common.places')}
</div>
</div>
) : (
<div className="px-3 py-2">
<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>
</div>
)}
</div>
)}
{listingPopup && (
<div
className="pointer-events-auto absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white max-w-[280px]"
style={{
left: listingPopup.x,
top: listingPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
}}
onMouseLeave={clearListingPopup}
>
<button
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearListingPopup}
>
<CloseIcon className="w-3 h-3" />
</button>
<a
href={listingPopup.listing.listing_url}
target="_blank"
rel="noopener noreferrer"
className="block px-3 py-2"
>
{listingPopup.listing.asking_price != null && (
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
{formatListingPrice(listingPopup.listing.asking_price)}
{listingPopup.listing.price_qualifier ? (
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
{listingPopup.listing.price_qualifier}
</span>
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing, t) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing, t)}
</div>
)}
{listingPopup.listing.address && (
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
{listingPopup.listing.address}
</div>
)}
{listingPopup.listing.postcode && (
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
{listingPopup.listing.postcode}
</div>
)}
{listingPopup.listing.floor_area_sqm != null && (
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
{Math.round(listingPopup.listing.floor_area_sqm)} sqm
{listingPopup.listing.asking_price_per_sqm != null
? ` · £${Math.round(listingPopup.listing.asking_price_per_sqm).toLocaleString()}/sqm`
: ''}
</div>
)}
{listingPopup.listing.features.length > 0 && (
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
{listingPopup.listing.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="line-clamp-1">
{feature}
</li>
))}
</ul>
)}
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
Open listing
</div>
</a>
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCard
x={hoverPosition.x}
y={hoverPosition.y}
id={hoveredHexagonId}
isPostcode={usePostcodeView}
data={
usePostcodeView
? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
?.properties || null
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
features={features}
/>
)}
</>
)}
</div>
);
});