"use client"; import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { DRAWING_INTERACTIVE_LAYER_IDS, DRAWING_SOURCE_IDS, createCircleFeature, createLineFeature, createPointFeature, createPolygonFeature, emptyDrawingFeatureCollection, ensureDrawingLayers, setSelectedDrawingFeatureId, setDrawingSourceData, type DrawingFeatureCollection } from "../map/drawing-layers"; import { createDrawingId, createDrawingLabel, createFeatureFromDraft, formatDrawingTime, getDraftPointCount, getDrawingKindLabel, getVisibleFeatureCollection, type DrawingDraftState, type WorkbenchDrawMode } from "../map/drawing-model"; import { getDistanceMeters, isSameCoordinate } from "../map/geo"; export type { WorkbenchDrawMode }; export type WorkbenchDrawingAnnotation = { id: string; label: string; description: string; kind: WorkbenchDrawMode; hidden: boolean; selected: boolean; onToggleVisibility: () => void; onSelect: () => void; }; type UseWorkbenchDrawingOptions = { mapRef: RefObject; mapReady: boolean; activeMode: WorkbenchDrawMode | null; onModeChange: (mode: WorkbenchDrawMode | null) => void; }; export function useWorkbenchDrawing({ mapRef, mapReady, activeMode, onModeChange }: UseWorkbenchDrawingOptions) { const [featureCollection, setFeatureCollection] = useState(emptyDrawingFeatureCollection); const [hiddenFeatureIds, setHiddenFeatureIds] = useState>(() => new Set()); const [selectedFeatureId, setSelectedFeatureId] = useState(null); const [, setHistoryVersion] = useState(0); const [draftPointCount, setDraftPointCount] = useState(0); const featureCollectionRef = useRef(featureCollection); const hiddenFeatureIdsRef = useRef(hiddenFeatureIds); const undoStackRef = useRef([]); const redoStackRef = useRef([]); const draftRef = useRef(null); const featureCounterRef = useRef(0); useEffect(() => { featureCollectionRef.current = featureCollection; }, [featureCollection]); useEffect(() => { hiddenFeatureIdsRef.current = hiddenFeatureIds; }, [hiddenFeatureIds]); useEffect(() => { const map = mapRef.current; if (!mapReady || !map) { return; } ensureDrawingLayers(map); setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollectionRef.current, hiddenFeatureIdsRef.current)); setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection); }, [mapReady, mapRef]); const clearDraft = useCallback(() => { draftRef.current = null; const map = mapRef.current; if (map) { setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection); } }, [mapRef]); const updateFeatures = useCallback( (nextCollection: DrawingFeatureCollection) => { featureCollectionRef.current = nextCollection; setFeatureCollection(nextCollection); const map = mapRef.current; if (map) { setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(nextCollection, hiddenFeatureIdsRef.current)); } }, [mapRef] ); const commitFeatures = useCallback( (nextCollection: DrawingFeatureCollection) => { undoStackRef.current.push(featureCollectionRef.current); redoStackRef.current = []; updateFeatures(nextCollection); setHistoryVersion((current) => current + 1); }, [updateFeatures] ); useEffect(() => { const map = mapRef.current; if (!mapReady || !map) { return; } setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollection, hiddenFeatureIds)); }, [featureCollection, hiddenFeatureIds, mapReady, mapRef]); useEffect(() => { const map = mapRef.current; if (!mapReady || !map) { return; } setSelectedDrawingFeatureId(map, selectedFeatureId); }, [mapReady, mapRef, selectedFeatureId]); useEffect(() => { const map = mapRef.current; if (!mapReady || !map || activeMode) { return; } const activeMap = map; function handleDrawingClick(event: MapMouseEvent) { const feature = activeMap.queryRenderedFeatures(event.point, { layers: DRAWING_INTERACTIVE_LAYER_IDS })[0]; const id = feature?.properties?.id; if (typeof id === "string" && id) { setSelectedFeatureId((current) => (current === id ? null : id)); } } function handleMouseEnter() { activeMap.getCanvas().style.cursor = "pointer"; } function handleMouseLeave() { activeMap.getCanvas().style.cursor = ""; } DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => { if (activeMap.getLayer(layerId)) { activeMap.on("click", layerId, handleDrawingClick); activeMap.on("mouseenter", layerId, handleMouseEnter); activeMap.on("mouseleave", layerId, handleMouseLeave); } }); return () => { DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => { if (activeMap.getLayer(layerId)) { activeMap.off("click", layerId, handleDrawingClick); activeMap.off("mouseenter", layerId, handleMouseEnter); activeMap.off("mouseleave", layerId, handleMouseLeave); } }); }; }, [activeMode, mapReady, mapRef]); const updateDraft = useCallback( (draft: DrawingDraftState | null) => { draftRef.current = draft; const map = mapRef.current; if (!map) { return; } if (!draft || getDraftPointCount(draft) === 0) { setDraftPointCount(0); setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection); return; } setDraftPointCount(getDraftPointCount(draft)); const draftFeatures: DrawingFeatureCollection["features"] = draft.mode === "circle" ? [createPointFeature("draft-circle-center", draft.center, "圆心")] : draft.coordinates.map((coordinates, index) => createPointFeature(`draft-vertex-${index}`, coordinates, `顶点 ${index + 1}`) ); if (draft.mode !== "circle" && draft.coordinates.length >= 2) { draftFeatures.push(createLineFeature("draft-line", draft.coordinates, "绘制草稿")); } if (draft.mode === "polygon" && draft.coordinates.length >= 3) { draftFeatures.push(createPolygonFeature("draft-polygon", draft.coordinates, "范围草稿")); } if (draft.mode === "circle" && draft.edge) { draftFeatures.push(createPointFeature("draft-circle-edge", draft.edge, "半径")); draftFeatures.push(createLineFeature("draft-circle-radius", [draft.center, draft.edge], "圆形半径")); draftFeatures.push(createCircleFeature("draft-circle", draft.center, getDistanceMeters(draft.center, draft.edge), "圆形草稿")); } setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, { type: "FeatureCollection", features: draftFeatures }); }, [mapRef] ); const finishDraft = useCallback(() => { const draft = draftRef.current; if (!draft) { return; } const minimumPoints = draft.mode === "line" ? 2 : draft.mode === "polygon" ? 3 : 2; const draftPointCount = getDraftPointCount(draft); if (draftPointCount < minimumPoints) { return; } const id = createDrawingId(draft.mode, featureCounterRef.current + 1); featureCounterRef.current += 1; const label = createDrawingLabel(draft.mode, featureCounterRef.current); const feature = createFeatureFromDraft(id, draft, label); if (!feature) { return; } commitFeatures({ type: "FeatureCollection", features: [...featureCollectionRef.current.features, feature] }); updateDraft(null); onModeChange(null); }, [commitFeatures, onModeChange, updateDraft]); useEffect(() => { const map = mapRef.current; if (!mapReady || !map || !activeMode) { clearDraft(); return; } const drawingMode = activeMode; const canvas = map.getCanvas(); const previousCursor = canvas.style.cursor; canvas.style.cursor = "crosshair"; if (drawingMode !== "point") { map.doubleClickZoom.disable(); } function handleClick(event: MapMouseEvent) { event.preventDefault(); const coordinates: [number, number] = [event.lngLat.lng, event.lngLat.lat]; if (drawingMode === "point") { const nextNumber = featureCounterRef.current + 1; const id = createDrawingId(drawingMode, nextNumber); featureCounterRef.current = nextNumber; const feature = createPointFeature(id, coordinates, createDrawingLabel("point", nextNumber)); commitFeatures({ type: "FeatureCollection", features: [...featureCollectionRef.current.features, feature] }); return; } if (drawingMode === "circle") { const currentDraft = draftRef.current?.mode === "circle" ? draftRef.current : null; if (!currentDraft) { updateDraft({ mode: "circle", center: coordinates }); return; } updateDraft({ mode: "circle", center: currentDraft.center, edge: coordinates }); finishDraft(); return; } const draftMode: DrawingDraftState["mode"] = drawingMode === "polygon" ? "polygon" : "line"; const currentDraft = draftRef.current?.mode === draftMode ? draftRef.current : { mode: draftMode, coordinates: [] }; if (isSameCoordinate(currentDraft.coordinates[currentDraft.coordinates.length - 1], coordinates)) { return; } updateDraft({ mode: draftMode, coordinates: [...currentDraft.coordinates, coordinates] }); } function handleDoubleClick(event: MapMouseEvent) { event.preventDefault(); finishDraft(); } function handleMouseMove(event: MapMouseEvent) { if (drawingMode !== "circle") { return; } const currentDraft = draftRef.current; if (currentDraft?.mode !== "circle") { return; } updateDraft({ mode: "circle", center: currentDraft.center, edge: [event.lngLat.lng, event.lngLat.lat] }); } map.on("click", handleClick); map.on("dblclick", handleDoubleClick); map.on("mousemove", handleMouseMove); return () => { map.off("click", handleClick); map.off("dblclick", handleDoubleClick); map.off("mousemove", handleMouseMove); canvas.style.cursor = previousCursor; if (drawingMode !== "point") { map.doubleClickZoom.enable(); } }; }, [activeMode, clearDraft, commitFeatures, finishDraft, mapReady, mapRef, updateDraft]); const undo = useCallback(() => { const draft = draftRef.current; if (draft && getDraftPointCount(draft) > 0) { if (draft.mode === "circle") { updateDraft(draft.edge ? { mode: "circle", center: draft.center } : null); } else { updateDraft({ mode: draft.mode, coordinates: draft.coordinates.slice(0, -1) }); } return; } const previousCollection = undoStackRef.current.pop(); if (!previousCollection) { return; } redoStackRef.current.push(featureCollectionRef.current); updateFeatures(previousCollection); setHistoryVersion((current) => current + 1); }, [updateDraft, updateFeatures]); const redo = useCallback(() => { const nextCollection = redoStackRef.current.pop(); if (!nextCollection) { return; } undoStackRef.current.push(featureCollectionRef.current); updateFeatures(nextCollection); setHistoryVersion((current) => current + 1); }, [updateFeatures]); const deleteSelected = useCallback(() => { if (!selectedFeatureId) { return; } const nextFeatures = featureCollectionRef.current.features.filter((feature) => feature.properties.id !== selectedFeatureId); if (nextFeatures.length === featureCollectionRef.current.features.length) { return; } setSelectedFeatureId(null); setHiddenFeatureIds((current) => { const next = new Set(current); next.delete(selectedFeatureId); return next; }); commitFeatures({ type: "FeatureCollection", features: nextFeatures }); }, [commitFeatures, selectedFeatureId]); const clear = useCallback(() => { clearDraft(); setHiddenFeatureIds(new Set()); setSelectedFeatureId(null); commitFeatures(emptyDrawingFeatureCollection); onModeChange(null); }, [clearDraft, commitFeatures, onModeChange]); const toggleVisibility = useCallback((featureId: string) => { setHiddenFeatureIds((current) => { const next = new Set(current); if (next.has(featureId)) { next.delete(featureId); } else { next.add(featureId); } return next; }); }, []); const exportGeoJSON = useCallback(() => { const data = JSON.stringify(featureCollectionRef.current, null, 2); const blob = new Blob([data], { type: "application/geo+json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "workbench-annotations.geojson"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); }, []); const annotations = useMemo( () => featureCollection.features.map((feature) => ({ id: feature.properties.id, label: feature.properties.label, description: `${getDrawingKindLabel(feature.properties.kind)} · ${formatDrawingTime(feature.properties.createdAt)}`, kind: feature.properties.kind, hidden: hiddenFeatureIds.has(feature.properties.id), selected: selectedFeatureId === feature.properties.id, onToggleVisibility: () => toggleVisibility(feature.properties.id), onSelect: () => setSelectedFeatureId(feature.properties.id) })), [featureCollection, hiddenFeatureIds, selectedFeatureId, toggleVisibility] ); return { annotations, canUndo: Boolean(draftPointCount || undoStackRef.current.length), canRedo: Boolean(redoStackRef.current.length), canDeleteSelected: Boolean(selectedFeatureId), hasFeatures: featureCollection.features.length > 0, undo, redo, deleteSelected, clear, exportGeoJSON }; }