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; draft: FeatureCollection; labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; 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; }; 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(draft); const labelContextDraftRef = useRef(labelContextDraft); const labelTimelineYearRef = useRef(labelTimelineYear); const backgroundVisibilityRef = useRef(backgroundVisibility); const geometryVisibilityRef = useRef | undefined>(geometryVisibility); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); const respectBindingFilterRef = useRef(respectBindingFilter); const fitToDraftBoundsRef = useRef(fitToDraftBounds); const highlightFeaturesRef = useRef(highlightFeatures || null); const imageOverlayRef = useRef(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); } } }