Move into folders
This commit is contained in:
parent
ee73ab77fd
commit
5cbb180c57
24 changed files with 181 additions and 185 deletions
626
frontend/src/components/map/Map.tsx
Normal file
626
frontend/src/components/map/Map.tsx
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
HexagonData,
|
||||
PostcodeFeature,
|
||||
PostcodeProperties,
|
||||
ViewState,
|
||||
ViewChangeParams,
|
||||
POI,
|
||||
FeatureMeta,
|
||||
} from '../../types';
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
countToColor,
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
getMapStyle,
|
||||
} from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
const match = id.match(/^([nwr])(\d+)$/);
|
||||
if (!match) return null;
|
||||
const typeMap: Record<string, string> = { n: 'node', w: 'way', r: 'relation' };
|
||||
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
|
||||
}
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
colorRange: [number, number] | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
filters?: FeatureFilters;
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}
|
||||
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
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 MapboxOverlay({ interleaved: true }));
|
||||
const prevLayersRef = useRef(layers);
|
||||
const prevTooltipRef = useRef(getTooltip);
|
||||
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
|
||||
prevLayersRef.current = layers;
|
||||
prevTooltipRef.current = getTooltip;
|
||||
overlay.setProps({ layers, getTooltip });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
pois,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
filterRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
features,
|
||||
selectedHexagonId,
|
||||
hoveredHexagonId,
|
||||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
initialViewState,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
filters = {},
|
||||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
// Send exact viewport bounds - server will filter to only return
|
||||
// hexagons/postcodes that intersect this precise AABB
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
zoom: viewState.zoom,
|
||||
latitude: viewState.latitude,
|
||||
longitude: viewState.longitude,
|
||||
});
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setViewState(evt.viewState);
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Hexagons render below roads/buildings/labels so map features show on top
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
id: string;
|
||||
} | null>(null);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
if (data.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of data) {
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [data]);
|
||||
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
);
|
||||
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
viewFeatureRef.current = viewFeature;
|
||||
const colorRangeRef = useRef(colorRange);
|
||||
colorRangeRef.current = colorRange;
|
||||
const filterRangeRef = useRef(filterRange);
|
||||
filterRangeRef.current = filterRange;
|
||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||
const countRangeRef = useRef(countRange);
|
||||
countRangeRef.current = countRange;
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
hoveredHexagonIdRef.current = hoveredHexagonId;
|
||||
|
||||
const onHexagonClickRef = useRef(onHexagonClick);
|
||||
onHexagonClickRef.current = onHexagonClick;
|
||||
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonClickRef.current(info.object.h3);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||
onHexagonHoverRef.current = onHexagonHover;
|
||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
||||
} else {
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// Compute count range for postcodes (similar to hexagons)
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
const c = d.properties.count;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
||||
// Track selected/hovered postcode for styling
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||
hoveredPostcodeRef.current = hoveredPostcode;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
if (pc) {
|
||||
setSelectedPostcode((prev) => (prev === pc ? null : pc));
|
||||
onHexagonClickRef.current(pc, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
if (pc && info.x !== undefined && info.y !== undefined) {
|
||||
setHoveredPostcode(pc);
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(pc, info.x, info.y);
|
||||
} else {
|
||||
setHoveredPostcode(null);
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
|
||||
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.h3 === hoveredHexagonIdRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [0, 0, 0, 0] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current) return 3;
|
||||
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
||||
return 0;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [colorTrigger],
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 1,
|
||||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
||||
const postcodeLayer = useMemo(
|
||||
() =>
|
||||
new GeoJsonLayer<PostcodeProperties>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData as PostcodeFeature[],
|
||||
getFillColor: (f) => {
|
||||
const d = f.properties;
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineColor: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
if (pc === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [100, 100, 100, 150] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
if (pc === selectedPostcodeRef.current) return 3;
|
||||
if (pc === hoveredPostcodeRef.current) return 2;
|
||||
return 1;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [postcodeColorTrigger],
|
||||
getLineColor: [postcodeColorTrigger],
|
||||
getLineWidth: [postcodeColorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
||||
const postcodeLabelsLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<PostcodeFeature>({
|
||||
id: 'postcode-labels',
|
||||
data: postcodeData,
|
||||
getPosition: (f) => f.properties.centroid,
|
||||
getText: (f) => f.properties.postcode,
|
||||
getSize: 12,
|
||||
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 14,
|
||||
billboard: false,
|
||||
pickable: false,
|
||||
}),
|
||||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: pois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 24,
|
||||
sizeMinPixels: 20,
|
||||
sizeMaxPixels: 40,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
}),
|
||||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
// Check if the searched postcode has data (passes current filters)
|
||||
const searchedPostcodeHasData = useMemo(() => {
|
||||
if (!searchedPostcode) return false;
|
||||
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
|
||||
}, [searchedPostcode, postcodeData]);
|
||||
|
||||
// Highlight layer for searched postcode
|
||||
const searchedPostcodeHighlightLayer = useMemo(() => {
|
||||
if (!searchedPostcode) return null;
|
||||
const hasData = searchedPostcodeHasData;
|
||||
const feature = {
|
||||
type: 'Feature' as const,
|
||||
geometry: searchedPostcode.geometry,
|
||||
properties: {},
|
||||
};
|
||||
return new GeoJsonLayer({
|
||||
id: 'searched-postcode-highlight',
|
||||
data: [feature],
|
||||
getFillColor: hasData
|
||||
? [29, 228, 195, 40] // teal tint when has data
|
||||
: [255, 180, 0, 30], // orange tint when filtered out
|
||||
getLineColor: hasData
|
||||
? [29, 228, 195, 255] // solid teal when has data
|
||||
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
|
||||
getLineWidth: hasData ? 4 : 3,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [searchedPostcode, searchedPostcodeHasData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
const baseLayers = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
<MapGL
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={handleMapLoad as never}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={true}
|
||||
touchPitch={false}
|
||||
keyboard={true}
|
||||
pitchWithRotate={false}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={MAP_BOUNDS}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
{screenshotMode ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
||||
<h1
|
||||
className="text-5xl font-bold text-white drop-shadow-lg"
|
||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
Your perfect postcodes
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
Previewing “{viewFeature}”
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelPin}
|
||||
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={colorFeatureMeta.name}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
/>
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={[0, 0]}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
/>
|
||||
)}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 40,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<strong>{popupInfo.name}</strong>
|
||||
<div className="text-warm-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
|
||||
{osmIdToUrl(popupInfo.id) && (
|
||||
<a
|
||||
href={osmIdToUrl(popupInfo.id)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 text-xs"
|
||||
>
|
||||
View on OSM
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue