1263 lines
43 KiB
TypeScript
1263 lines
43 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 { Layer, Map as MapGL, Source, 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,
|
|
SchoolMetadata,
|
|
} from '../../types';
|
|
|
|
import {
|
|
zoomToResolution,
|
|
getVisibleBoundsFromViewState,
|
|
getBoundsWithBottomScreenInset,
|
|
getMapStyle,
|
|
getMapDataBeforeId,
|
|
getPoiIconUrl,
|
|
getMapCenterForTargetScreenPoint,
|
|
} from '../../lib/map-utils';
|
|
import {
|
|
MAP_MIN_ZOOM,
|
|
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';
|
|
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';
|
|
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
|
|
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
|
import type { BasemapId } from '../../lib/basemaps';
|
|
|
|
interface MapProps {
|
|
data: HexagonData[];
|
|
postcodeData: PostcodeFeature[];
|
|
usePostcodeView: boolean;
|
|
pois: POI[];
|
|
activeOverlays?: Set<OverlayId>;
|
|
activeCrimeTypes?: Set<string>;
|
|
basemap?: BasemapId;
|
|
colorOpacity?: number;
|
|
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[] = [];
|
|
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
|
const ALL_CRIME_TYPES = new Set<string>(CRIME_TYPE_VALUES);
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
icon_category?: string;
|
|
group: string;
|
|
emoji: string;
|
|
school?: SchoolMetadata;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
|
* "http://", some without, and some as bare hostnames. */
|
|
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
|
|
return null;
|
|
}
|
|
|
|
function renderSchoolMetadata(school: SchoolMetadata) {
|
|
// First line collects the headline classification (phase, type, religious
|
|
// character) so the popup is scannable even when most fields are absent.
|
|
const headline: string[] = [];
|
|
if (school.phase) headline.push(school.phase);
|
|
if (school.type) headline.push(school.type);
|
|
|
|
const pupilsLine =
|
|
school.pupils !== undefined && school.capacity !== undefined
|
|
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
|
: school.pupils !== undefined
|
|
? `${school.pupils.toLocaleString()} pupils`
|
|
: school.capacity !== undefined
|
|
? `Capacity ${school.capacity.toLocaleString()}`
|
|
: null;
|
|
|
|
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
|
|
|
return (
|
|
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
|
{headline.length > 0 && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
|
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
|
</>
|
|
)}
|
|
{school.age_range && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
|
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
|
</>
|
|
)}
|
|
{school.gender && school.gender !== 'Mixed' && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
|
<dd className="dark:text-warm-200">{school.gender}</dd>
|
|
</>
|
|
)}
|
|
{pupilsLine && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
|
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
|
</>
|
|
)}
|
|
{school.fsm_percent !== undefined && (
|
|
<>
|
|
<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>
|
|
<dd className="dark:text-warm-200">Yes</dd>
|
|
</>
|
|
)}
|
|
{school.religious_character &&
|
|
school.religious_character !== 'Does not apply' &&
|
|
school.religious_character !== 'None' && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
|
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
|
</>
|
|
)}
|
|
{school.admissions_policy && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
|
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
|
</>
|
|
)}
|
|
{school.trust && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
|
<dd className="dark:text-warm-200">{school.trust}</dd>
|
|
</>
|
|
)}
|
|
{(school.address || school.postcode) && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
|
<dd className="dark:text-warm-200">
|
|
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
|
</dd>
|
|
</>
|
|
)}
|
|
{school.local_authority && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
|
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
|
</>
|
|
)}
|
|
{school.head_name && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
|
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
|
</>
|
|
)}
|
|
{websiteUrl && (
|
|
<>
|
|
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
|
<dd className="truncate">
|
|
<a
|
|
href={websiteUrl}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
|
|
>
|
|
{websiteUrl.replace(/^https?:\/\//, '')}
|
|
</a>
|
|
</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
|
|
function overlayTileUrl(path: string): string {
|
|
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
|
}
|
|
|
|
function OverlayTileLayers({
|
|
activeOverlays,
|
|
activeCrimeTypes,
|
|
zoom,
|
|
}: {
|
|
activeOverlays: Set<OverlayId>;
|
|
activeCrimeTypes: Set<string>;
|
|
zoom: number;
|
|
}) {
|
|
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
|
|
|
const showNoise = activeOverlays.has('noise');
|
|
const showCrime = activeOverlays.has('crime-hotspots');
|
|
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
|
const showPropertyBorders = activeOverlays.has('property-borders');
|
|
|
|
// Restrict the heatmap to the selected crime types. When every type is
|
|
// selected we omit the filter entirely so all features contribute.
|
|
const crimeFilter =
|
|
activeCrimeTypes.size >= CRIME_TYPE_VALUES.length
|
|
? undefined
|
|
: ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];
|
|
|
|
return (
|
|
<>
|
|
{showNoise && (
|
|
<Source
|
|
id="overlay-noise-source"
|
|
type="raster"
|
|
tiles={[overlayTileUrl('noise')]}
|
|
tileSize={256}
|
|
minzoom={OVERLAY_MIN_ZOOM.noise}
|
|
maxzoom={14}
|
|
>
|
|
<Layer
|
|
id="overlay-noise"
|
|
type="raster"
|
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
|
paint={{
|
|
'raster-opacity': 0.68,
|
|
'raster-fade-duration': 120,
|
|
}}
|
|
/>
|
|
</Source>
|
|
)}
|
|
|
|
{showCrime && (
|
|
<Source
|
|
id="overlay-crime-source"
|
|
type="vector"
|
|
tiles={[overlayTileUrl('crime-hotspots')]}
|
|
minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
|
|
maxzoom={15}
|
|
>
|
|
<Layer
|
|
id="overlay-crime-heatmap"
|
|
type="heatmap"
|
|
source-layer="crime_hotspots"
|
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
|
filter={crimeFilter as never}
|
|
paint={
|
|
{
|
|
'heatmap-weight': [
|
|
'interpolate',
|
|
['linear'],
|
|
['coalesce', ['get', 'count'], ['get', 'weight'], 1],
|
|
0,
|
|
0,
|
|
10,
|
|
1,
|
|
],
|
|
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
|
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
|
'heatmap-opacity': 0.72,
|
|
'heatmap-color': [
|
|
'interpolate',
|
|
['linear'],
|
|
['heatmap-density'],
|
|
0,
|
|
'rgba(0, 0, 0, 0)',
|
|
0.2,
|
|
'rgb(253, 224, 71)',
|
|
0.45,
|
|
'rgb(249, 115, 22)',
|
|
0.75,
|
|
'rgb(220, 38, 38)',
|
|
1,
|
|
'rgb(127, 29, 29)',
|
|
],
|
|
} as never
|
|
}
|
|
/>
|
|
</Source>
|
|
)}
|
|
|
|
{showTrees && (
|
|
<Source
|
|
id="overlay-trees-source"
|
|
type="vector"
|
|
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
|
minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
|
|
maxzoom={16}
|
|
>
|
|
<Layer
|
|
id="overlay-tree-polygons"
|
|
type="fill"
|
|
source-layer="trees_outside_woodlands"
|
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
|
paint={
|
|
{
|
|
'fill-color': '#1f9d55',
|
|
'fill-opacity': [
|
|
'interpolate',
|
|
['linear'],
|
|
['coalesce', ['get', 'area_sqm'], 0],
|
|
0,
|
|
0.28,
|
|
250,
|
|
0.62,
|
|
],
|
|
'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
|
|
} as never
|
|
}
|
|
/>
|
|
</Source>
|
|
)}
|
|
|
|
{showPropertyBorders && (
|
|
<Source
|
|
id="overlay-property-borders-source"
|
|
type="vector"
|
|
tiles={[overlayTileUrl('property-borders')]}
|
|
minzoom={OVERLAY_MIN_ZOOM['property-borders']}
|
|
maxzoom={16}
|
|
>
|
|
<Layer
|
|
id="overlay-property-borders"
|
|
type="line"
|
|
source-layer="property_borders"
|
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
|
paint={
|
|
{
|
|
'line-color': '#b45309',
|
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
|
|
} as never
|
|
}
|
|
/>
|
|
</Source>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default memo(function Map({
|
|
data,
|
|
postcodeData,
|
|
usePostcodeView,
|
|
pois,
|
|
activeOverlays = EMPTY_OVERLAYS,
|
|
activeCrimeTypes = ALL_CRIME_TYPES,
|
|
basemap = 'standard',
|
|
colorOpacity = 1,
|
|
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 });
|
|
const [mapReady, setMapReady] = useState(false);
|
|
|
|
// 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;
|
|
}
|
|
const bounds = getVisibleBoundsFromViewState(
|
|
renderedViewState,
|
|
dimensions.width,
|
|
dimensions.height,
|
|
bottomScreenInset
|
|
);
|
|
const visibleBounds = bounds;
|
|
const resolution = zoomToResolution(renderedViewState.zoom);
|
|
const renderedVisibleCenter =
|
|
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
|
|
renderedViewState;
|
|
|
|
onViewChange({
|
|
resolution,
|
|
bounds,
|
|
visibleBounds,
|
|
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 handleLoad = useCallback(() => {
|
|
setMapReady(true);
|
|
}, []);
|
|
|
|
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, basemap), [theme, basemap]);
|
|
const mapDataBeforeId = useMemo(() => getMapDataBeforeId(basemap), [basemap]);
|
|
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,
|
|
visiblePois,
|
|
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,
|
|
mapDataBeforeId,
|
|
colorOpacity,
|
|
});
|
|
|
|
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' : ''}`}
|
|
ref={containerRef}
|
|
style={mapContainerStyle}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<MapGL
|
|
ref={mapRef}
|
|
{...viewState}
|
|
onMove={handleMove}
|
|
onLoad={handleLoad}
|
|
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}
|
|
>
|
|
<OverlayTileLayers
|
|
activeOverlays={activeOverlays}
|
|
activeCrimeTypes={activeCrimeTypes}
|
|
zoom={viewState.zoom}
|
|
/>
|
|
<DeckOverlay layers={layers} getTooltip={null} />
|
|
{!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">
|
|
{/* 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>
|
|
)}
|
|
{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: 30,
|
|
}}
|
|
>
|
|
<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>
|
|
) : (
|
|
<PoiPopupCardContent poi={popupInfo} />
|
|
)}
|
|
</div>
|
|
)}
|
|
{listingPopup && (
|
|
<div
|
|
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,
|
|
transform: 'translate(-50%, -100%)',
|
|
zIndex: 30,
|
|
}}
|
|
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>
|
|
{listingPopup.mode === 'single' ? (
|
|
<ListingPopupSingleContent listing={listingPopup.listing} t={t} />
|
|
) : (
|
|
<ListingClusterPopupContent
|
|
count={listingPopup.count}
|
|
listings={listingPopup.listings}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
});
|