Files
History-user/src/uhm/components/map/useMapSync.ts
T

268 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import {
applyBackgroundLayerVisibility,
buildPolygonLabelFeatureCollection,
buildPathArrowFeatureCollection,
decorateLineFeaturesWithLabels,
decoratePointFeaturesWithLabels,
filterDraftByBinding,
filterDraftByGeometryVisibility,
fitMapToFeatureCollection,
setSelectedFeatureState,
splitDraftFeatures,
} from "./mapUtils";
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
draft: FeatureCollection;
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
respectBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
highlightFeatures?: FeatureCollection | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
imageOverlay?: MapImageOverlay | null;
allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>;
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
};
export function useMapSync({
mapRef,
draft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
respectBindingFilter,
fitToDraftBounds,
fitBoundsKey,
highlightFeatures,
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
}: UseMapSyncProps) {
const draftRef = useRef<FeatureCollection>(draft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const respectBindingFilterRef = useRef(respectBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const fitBoundsAppliedRef = useRef(false);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource || !polygonLabelSource) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (map.getSource(sourceId)) {
map.removeFeatureState({ source: sourceId });
}
}
const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
: fc;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const labelContext = labelContextDraftRef.current || fc;
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(visibleDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
countriesSource.setData(labeledGeometries);
placesSource.setData(labeledPoints);
polygonLabelSource.setData(polygonLabels);
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
const currentSelectedIds = selectedFeatureIdsRef.current;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
requestAnimationFrame(() => {
if (mapRef.current !== map) return;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
}
}, [mapRef]);
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
if (!source) return;
source.setData(fc);
moveHighlightLayersToTop(map);
}, [mapRef]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
if (!map) return;
geolocationCenteredRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (mapRef.current !== map) return;
const { longitude, latitude } = pos.coords;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
},
() => { },
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
);
}, [mapRef, geolocationCenteredRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
}, [highlightFeatures, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlay);
}, [imageOverlay, mapRef]);
useEffect(() => {
applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
draft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
respectBindingFilter,
geometryVisibility,
highlightFeatures,
applyDraftToMap,
editingEngineRef,
]);
useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current;
const target = focusFeatureCollection;
if (!target || !target.features.length) return;
if (!map) return;
let cancelled = false;
let rafId: number | null = null;
const focus = () => {
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
fitMapToFeatureCollection(map, target, focusPadding, {
duration: 550,
maxZoom: 10,
pointZoom: 9,
});
};
if (map.isStyleLoaded()) {
rafId = requestAnimationFrame(focus);
} else {
map.once("idle", focus);
}
return () => {
cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
return {
applyDraftToMap,
applyHighlightToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlayRef.current);
},
};
}
function moveHighlightLayersToTop(map: maplibregl.Map) {
const layerIds = ["entity-focus-fill", "entity-focus-line", "entity-focus-points"];
for (const layerId of layerIds) {
if (map.getLayer(layerId)) {
map.moveLayer(layerId);
}
}
}