More FE changes

This commit is contained in:
Andras Schmelczer 2026-05-09 09:43:41 +01:00
parent f114ada255
commit a48eb945e0
48 changed files with 4127 additions and 1751 deletions

View file

@ -1,6 +1,8 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-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 {
@ -18,17 +20,12 @@ import type {
import {
zoomToResolution,
getBoundsFromViewState,
getBoundsWithBottomScreenInset,
getMapStyle,
getPoiIconUrl,
getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
MAP_BOUNDS,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
} from '../../lib/consts';
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';
@ -57,7 +54,7 @@ interface MapProps {
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
initialViewState: ViewState;
flyToRef?: React.MutableRefObject<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>;
@ -75,6 +72,7 @@ interface MapProps {
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
bottomScreenInset?: number;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
@ -84,6 +82,10 @@ interface Dimensions {
height: number;
}
type MapContainerStyle = CSSProperties & {
'--map-mobile-bottom-inset'?: string;
};
function resolveInset(
pixelValue: number | undefined,
ratioValue: number | undefined,
@ -185,6 +187,27 @@ class SafeMapboxOverlay extends MapboxOverlay {
}
}
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 DeckOverlay({
layers,
getTooltip,
@ -240,18 +263,18 @@ export default memo(function Map({
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 || INITIAL_VIEW_STATE
);
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 ? initialViewState : internalViewState;
const viewState = screenshotMode ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
@ -282,17 +305,33 @@ export default memo(function Map({
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
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);
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
onViewChange({
resolution,
bounds,
zoom: renderedViewState.zoom,
latitude: renderedViewState.latitude,
longitude: renderedViewState.longitude,
});
};
frame = window.requestAnimationFrame(emit);
return () => window.cancelAnimationFrame(frame);
}, [viewState, dimensions, bottomScreenInset, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setInternalViewState((prev) => {
@ -342,6 +381,14 @@ export default memo(function Map({
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 {
layers,
@ -374,8 +421,14 @@ export default memo(function Map({
});
return (
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
<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}
@ -389,7 +442,7 @@ export default memo(function Map({
keyboard={true}
pitchWithRotate={false}
minZoom={MAP_MIN_ZOOM}
maxBounds={MAP_BOUNDS}
maxBounds={maxBounds}
>
<DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
@ -486,6 +539,7 @@ export default memo(function Map({
}
featureName={colorFeatureMeta.name}
theme={theme}
suffix={colorFeatureMeta.suffix}
raw={colorFeatureMeta.raw}
/>
) : null
@ -553,7 +607,7 @@ export default memo(function Map({
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`,
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}}
/>
{popupInfo.category}