"use client"; import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers"; import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { useMapInstance } from "./map/useMapInstance"; import { setupMapLayers } from "./map/useMapLayers"; import { useMapInteraction } from "./map/useMapInteraction"; import { useMapSync } from "./map/useMapSync"; import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay"; export type MapHoverPayload = { featureId: string | number; feature: Feature | null; point: { x: number; y: number }; lngLat: { lng: number; lat: number }; }; export type MapHandle = { getViewState: () => { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number; projection: string; } | null; getMap: () => import("maplibre-gl").Map | null; }; type MapProps = { mode: EditorMode; // FeatureCollection that should actually be rendered/interacted with on the map. // Callers should apply timeline/replay filters before passing it here. renderDraft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; selectedFeatureIds: (string | number)[]; onSelectFeatureIds: (ids: (string | number)[]) => void; onSetMode?: (mode: EditorMode, featureId?: string | number) => void; // Label lookup context only. It may include non-rendered geometries for entity label resolution. labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number | (string | number)[]) => void; onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; applyGeometryBindingFilter?: boolean; height?: CSSProperties["height"]; fitToDraftBounds?: boolean; fitBoundsKey?: string | number | null; onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined; highlightFeatures?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null; focusRequestKey?: string | number | null; focusPadding?: number | import("maplibre-gl").PaddingOptions; imageOverlay?: MapImageOverlay | null; onImageOverlayChange?: (overlay: MapImageOverlay) => void; onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; }; const Map = forwardRef(function Map({ mode, onSetMode, renderDraft, backgroundVisibility, geometryVisibility, selectedFeatureIds, onSelectFeatureIds, labelContextDraft, labelTimelineYear, onCreateFeature, onDeleteFeature, onHideFeature, onUpdateFeature, allowGeometryEditing = true, applyGeometryBindingFilter = true, height = "100vh", fitToDraftBounds = false, fitBoundsKey = null, onHoverFeatureChange, highlightFeatures = null, focusFeatureCollection = null, focusRequestKey = null, focusPadding, imageOverlay = null, onImageOverlayChange, onBindGeometries, }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); // Ref giữ render draft mới nhất để map engines đọc không bị stale closure. const renderDraftRef = useRef(renderDraft); // Ref callback select feature mới nhất cho event click trên map. const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); // Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select. const onSetModeRef = useRef(onSetMode); // Ref callback hover mới nhất cho tooltip/panel ngoài map. const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); // Ref callback create mới nhất khi drawing engine tạo feature. const onCreateRef = useRef(onCreateFeature); // Ref callback delete mới nhất khi editing engine xóa feature. const onDeleteRef = useRef(onDeleteFeature); // Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map. const onHideRef = useRef(onHideFeature); // Ref callback update mới nhất khi editing engine đổi geometry. const onUpdateRef = useRef(onUpdateFeature); // Ref giữ overlay mới nhất cho right-drag controls. const imageOverlayRef = useRef(imageOverlay); // Ref callback update overlay mới nhất để interaction không stale. const onImageOverlayChangeRef = useRef(onImageOverlayChange); // Ref callback bind geometry mới nhất để interaction không stale. const onBindGeometriesRef = useRef(onBindGeometries); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { mapRef, containerRef, fatalInitError, zoomLevel, zoomBounds, isGlobeProjection, setIsGlobeProjection, isMapLoaded, geolocationCenteredRef, handleZoomByStep, handleZoomSliderChange, beginZoomSliderDrag, endZoomSliderDrag, getViewState, } = useMapInstance(); // Public API cho parent đọc map instance/view state mà không expose implementation nội bộ. useImperativeHandle(ref, () => ({ getViewState, getMap: () => mapRef.current, }), [getViewState, mapRef]); // Hook gắn/dọn các interaction vẽ, chọn, sửa geometry. const { editingEngineRef, setupMapInteractions, cleanupMapInteractions, } = useMapInteraction({ mapRef, mode, modeRef, renderDraftRef, allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, onSetModeRef, onCreateRef, onDeleteRef, onHideRef, onUpdateRef, onHoverFeatureChangeRef, onBindGeometriesRef, }); // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. const { applyRenderDraftToMap, applyHighlightToMap, applyImageOverlayToMap, tryCenterToUserLocation, } = useMapSync({ mapRef, renderDraft, labelContextDraft, labelTimelineYear, backgroundVisibility, geometryVisibility, selectedFeatureIds, applyGeometryBindingFilter, fitToDraftBounds, fitBoundsKey, highlightFeatures, focusFeatureCollection, focusRequestKey, focusPadding, imageOverlay, allowGeometryEditing, editingEngineRef, geolocationCenteredRef, }); useEffect(() => { const map = mapRef.current; if (!map || !isMapLoaded) return; setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); applyImageOverlayToMap(); setupMapInteractions(map); applyRenderDraftToMap(renderDraftRef.current); tryCenterToUserLocation(); return () => { cleanupMapInteractions(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMapLoaded]); useEffect(() => { const map = mapRef.current; if (map && isMapLoaded) { // Trigger resize after a short delay to allow layout to settle setTimeout(() => map.resize(), 100); } }, [mode, isMapLoaded, mapRef]); const hasImageOverlay = Boolean(imageOverlay); useEffect(() => { const map = mapRef.current; if (!map || !isMapLoaded || !hasImageOverlay) return; return bindImageOverlayInteractions( map, () => imageOverlayRef.current, (nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay) ); }, [hasImageOverlay, isMapLoaded, mapRef]); return (
{fatalInitError ? (
Map khong khoi tao duoc
{fatalInitError}
) : null}
{ event.stopPropagation(); try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // Browser may reject capture for non-primary pointers; drag lock still works. } beginZoomSliderDrag(); }} onPointerUp={(event) => { event.stopPropagation(); try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // Ignore if capture was already released. } endZoomSliderDrag(); }} onPointerCancel={endZoomSliderDrag} onBlur={endZoomSliderDrag} onChange={(event) => handleZoomSliderChange(Number(event.target.value))} style={{ flex: 1, accentColor: "#38bdf8", cursor: "pointer", }} aria-label="Map zoom" />
{zoomLevel.toFixed(1)}x
); }); export default Map; const zoomButtonStyle: React.CSSProperties = { width: "28px", height: "28px", borderRadius: "999px", border: "1px solid #334155", background: "#1e293b", color: "#f8fafc", fontSize: "18px", lineHeight: "1", cursor: "pointer", display: "grid", placeItems: "center", };