This commit is contained in:
Andras Schmelczer 2026-05-28 21:48:35 +01:00
parent 39ef5c6646
commit c995f12f8b
78 changed files with 4830 additions and 1619 deletions

View file

@ -22,9 +22,10 @@ import type {
import {
zoomToResolution,
getBoundsFromViewState,
getVisibleBoundsFromViewState,
getBoundsWithBottomScreenInset,
getMapStyle,
getMapDataBeforeId,
getPoiIconUrl,
getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
@ -45,6 +46,7 @@ import { useDeckLayers } from '../../hooks/useDeckLayers';
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { ts } from '../../i18n/server';
import type { OverlayId } from '../../lib/overlays';
import type { BasemapId } from '../../lib/basemaps';
interface MapProps {
data: HexagonData[];
@ -52,6 +54,7 @@ interface MapProps {
usePostcodeView: boolean;
pois: POI[];
activeOverlays?: Set<OverlayId>;
basemap?: BasemapId;
actualListings?: ActualListing[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
@ -105,6 +108,130 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
return parts.length > 0 ? parts.join(' · ') : null;
}
function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) {
return (
<a
href={listing.listing_url}
target="_blank"
rel="noopener noreferrer"
className="block px-3 py-2"
>
{listing.asking_price != null && (
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
{formatListingPrice(listing.asking_price)}
{listing.price_qualifier ? (
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
{listing.price_qualifier}
</span>
) : null}
</div>
)}
{formatListingHeadline(listing, t) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listing, t)}
</div>
)}
{listing.address && (
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
{listing.address}
</div>
)}
{listing.postcode && (
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
{listing.postcode}
</div>
)}
{listing.floor_area_sqm != null && (
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
{Math.round(listing.floor_area_sqm)} sqm
{listing.asking_price_per_sqm != null
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
: ''}
</div>
)}
{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">
{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>
);
}
function ListingClusterPopupContent({
count,
listings,
t,
}: {
count: number;
listings: ActualListing[];
t: TFunction;
}) {
const visibleCount = listings.length;
return (
<div>
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
<div className="text-base font-bold text-red-600 dark:text-red-400">
{count.toLocaleString()} listings
</div>
<div className="text-[11px] text-warm-500 dark:text-warm-400">
{visibleCount > 0
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
: 'Grouped near this map position'}
</div>
</div>
{visibleCount > 0 && (
<div className="max-h-80 overflow-y-auto py-1">
{listings.map((listing, idx) => {
const headline = formatListingHeadline(listing, t);
return (
<a
key={`${listing.listing_url}-${idx}`}
href={listing.listing_url}
target="_blank"
rel="noopener noreferrer"
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{listing.asking_price != null
? formatListingPrice(listing.asking_price)
: 'Listing'}
</div>
{headline && (
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
{headline}
</div>
)}
{listing.address && (
<div className="mt-0.5 line-clamp-1 text-[11px] text-warm-500 dark:text-warm-400">
{listing.address}
</div>
)}
</div>
{listing.postcode && (
<div className="shrink-0 text-[11px] font-medium text-warm-400 dark:text-warm-500">
{listing.postcode}
</div>
)}
</div>
</a>
);
})}
</div>
)}
</div>
);
}
interface PoiPopupCardData {
name: string;
category: string;
@ -581,6 +708,7 @@ export default memo(function Map({
usePostcodeView,
pois,
activeOverlays = EMPTY_OVERLAYS,
basemap = 'standard',
actualListings = EMPTY_ACTUAL_LISTINGS,
onViewChange,
viewFeature,
@ -665,9 +793,13 @@ export default memo(function Map({
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 bounds = getVisibleBoundsFromViewState(
renderedViewState,
dimensions.width,
dimensions.height,
bottomScreenInset
);
const visibleBounds = bounds;
const resolution = zoomToResolution(renderedViewState.zoom);
const renderedVisibleCenter =
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
@ -676,6 +808,7 @@ export default memo(function Map({
onViewChange({
resolution,
bounds,
visibleBounds,
zoom: renderedViewState.zoom,
latitude: renderedViewState.latitude,
longitude: renderedViewState.longitude,
@ -739,7 +872,8 @@ export default memo(function Map({
if (flyToRef) flyToRef.current = handleFlyTo;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const mapStyle = useMemo(() => getMapStyle(theme, basemap), [theme, basemap]);
const mapDataBeforeId = useMemo(() => getMapDataBeforeId(basemap), [basemap]);
const maxBounds = useMemo(
() => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
[bottomScreenInset]
@ -794,6 +928,7 @@ export default memo(function Map({
currentLocation,
bounds: viewportBounds,
travelTimeEntries,
mapDataBeforeId,
});
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
@ -849,6 +984,14 @@ export default memo(function Map({
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL>
{basemap === 'satellite' && (
<div
className="pointer-events-auto absolute left-2 z-10 max-w-[calc(100%_-_1rem)] rounded bg-white/85 px-1.5 py-0.5 text-[10px] leading-tight text-warm-600 shadow-sm dark:bg-warm-900/85 dark:text-warm-300"
style={{ bottom: bottomScreenInset > 0 ? bottomScreenInset + 8 : 34 }}
>
Sentinel-2 cloudless by EOX, contains modified Copernicus Sentinel data 2024
</div>
)}
{screenshotMode ? (
ogMode ? (
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
@ -1019,7 +1162,9 @@ export default memo(function Map({
)}
{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]"
className={`pointer-events-auto absolute rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white ${
listingPopup.mode === 'cluster' ? 'w-80 max-w-[calc(100vw-2rem)]' : 'max-w-[280px]'
}`}
style={{
left: listingPopup.x,
top: listingPopup.y - 12,
@ -1029,63 +1174,21 @@ export default memo(function Map({
onMouseLeave={clearListingPopup}
>
<button
type="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>
{listingPopup.mode === 'single' ? (
<ListingPopupSingleContent listing={listingPopup.listing} t={t} />
) : (
<ListingClusterPopupContent
count={listingPopup.count}
listings={listingPopup.listings}
t={t}
/>
)}
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (