Files
next-tjwater-drainage-frontend/features/workbench/hooks/use-workbench-drawing.ts
T

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
};
}