477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
"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<MapLibreMap | null>;
|
|
mapReady: boolean;
|
|
activeMode: WorkbenchDrawMode | null;
|
|
onModeChange: (mode: WorkbenchDrawMode | null) => void;
|
|
};
|
|
|
|
export function useWorkbenchDrawing({
|
|
mapRef,
|
|
mapReady,
|
|
activeMode,
|
|
onModeChange
|
|
}: UseWorkbenchDrawingOptions) {
|
|
const [featureCollection, setFeatureCollection] = useState<DrawingFeatureCollection>(emptyDrawingFeatureCollection);
|
|
const [hiddenFeatureIds, setHiddenFeatureIds] = useState<Set<string>>(() => new Set());
|
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
|
const [, setHistoryVersion] = useState(0);
|
|
const [draftPointCount, setDraftPointCount] = useState(0);
|
|
const featureCollectionRef = useRef(featureCollection);
|
|
const hiddenFeatureIdsRef = useRef(hiddenFeatureIds);
|
|
const undoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
|
const redoStackRef = useRef<DrawingFeatureCollection[]>([]);
|
|
const draftRef = useRef<DrawingDraftState | null>(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<WorkbenchDrawingAnnotation[]>(
|
|
() =>
|
|
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
|
|
};
|
|
}
|