feat: initialize drainage network frontend
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
"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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user