feat: initialize drainage network frontend
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
createMeasurementFeatureCollection,
|
||||
emptyMeasurementFeatureCollection,
|
||||
ensureMeasurementLayers,
|
||||
setSelectedMeasurementPipeIds,
|
||||
setMeasurementSourceData
|
||||
} from "../map/measurement-layers";
|
||||
import { getFeatureId } from "../map/feature-adapter";
|
||||
import {
|
||||
getLastSegmentDistanceMeters,
|
||||
getLineDistanceMeters,
|
||||
getPolygonAreaSquareMeters,
|
||||
isSameCoordinate,
|
||||
type LngLatTuple
|
||||
} from "../map/geo";
|
||||
|
||||
export type WorkbenchMeasureMode = "distance" | "area" | "segment";
|
||||
export type WorkbenchMeasureUnit = "m" | "km";
|
||||
export type WorkbenchMeasurementResult = {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
metrics: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
type UseWorkbenchMeasurementOptions = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
mode: WorkbenchMeasureMode;
|
||||
unit: WorkbenchMeasureUnit;
|
||||
};
|
||||
|
||||
const SNAP_RADIUS_PIXELS = 14;
|
||||
const SNAP_LAYER_IDS = ["pipes-flow"];
|
||||
|
||||
type SelectedPipe = {
|
||||
id: string;
|
||||
lengthMeters?: number;
|
||||
};
|
||||
|
||||
export function useWorkbenchMeasurement({
|
||||
mapRef,
|
||||
mapReady,
|
||||
mode,
|
||||
unit
|
||||
}: UseWorkbenchMeasurementOptions) {
|
||||
const [coordinates, setCoordinates] = useState<LngLatTuple[]>([]);
|
||||
const [selectedPipes, setSelectedPipes] = useState<SelectedPipe[]>([]);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
const coordinatesRef = useRef(coordinates);
|
||||
const selectedPipesRef = useRef(selectedPipes);
|
||||
|
||||
useEffect(() => {
|
||||
coordinatesRef.current = coordinates;
|
||||
}, [coordinates]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedPipesRef.current = selectedPipes;
|
||||
}, [selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMeasurementLayers(map);
|
||||
setMeasurementSourceData(map, emptyMeasurementFeatureCollection);
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
const syncMeasurementSource = useCallback(
|
||||
(nextCoordinates: LngLatTuple[], nextMode = mode) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeasurementSourceData(
|
||||
map,
|
||||
nextMode !== "segment" && nextCoordinates.length > 0
|
||||
? createMeasurementFeatureCollection(nextCoordinates, nextMode)
|
||||
: emptyMeasurementFeatureCollection
|
||||
);
|
||||
},
|
||||
[mapRef, mode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncMeasurementSource(coordinates, mode);
|
||||
}, [coordinates, mode, syncMeasurementSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMeasurementPipeIds(
|
||||
map,
|
||||
mode === "segment" ? selectedPipes.map((pipe) => pipe.id) : []
|
||||
);
|
||||
}, [mapReady, mapRef, mode, selectedPipes]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !measuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeMap = map;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
|
||||
function handleClick(event: MapMouseEvent) {
|
||||
event.preventDefault();
|
||||
if (mode === "segment") {
|
||||
const selectedPipe = getSelectedPipe(activeMap, event);
|
||||
if (selectedPipe) {
|
||||
setSelectedPipes((current) => addUniquePipe(current, selectedPipe));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinate: LngLatTuple = [event.lngLat.lng, event.lngLat.lat];
|
||||
setCoordinates((current) => {
|
||||
if (isSameCoordinate(current[current.length - 1], coordinate)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, coordinate];
|
||||
});
|
||||
}
|
||||
|
||||
map.on("click", handleClick);
|
||||
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
canvas.style.cursor = previousCursor;
|
||||
};
|
||||
}, [mapReady, mapRef, measuring, mode]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setCoordinates([]);
|
||||
setSelectedPipes([]);
|
||||
setMeasuring(false);
|
||||
syncMeasurementSource([]);
|
||||
}, [syncMeasurementSource]);
|
||||
|
||||
const copyResult = useCallback(() => {
|
||||
if (!navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(getResultText(mode, unit, coordinatesRef.current, selectedPipesRef.current));
|
||||
}, [mode, unit]);
|
||||
|
||||
const result = useMemo<WorkbenchMeasurementResult>(() => {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
const areaSquareMeters = mode === "area" ? getPolygonAreaSquareMeters(coordinates) : 0;
|
||||
const segmentCount = Math.max(coordinates.length - 1, 0);
|
||||
const lastSegmentMeters = getLastSegmentDistanceMeters(coordinates);
|
||||
const selectedPipeLengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
|
||||
if (mode === "area") {
|
||||
return {
|
||||
label: "当前面积",
|
||||
value: formatArea(areaSquareMeters, unit),
|
||||
unit: unit === "km" ? "km²" : "m²",
|
||||
metrics: [
|
||||
{ label: "顶点", value: String(coordinates.length) },
|
||||
{ label: "周长", value: `${formatDistance(distanceMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
return {
|
||||
label: "资产长度",
|
||||
value: formatDistance(selectedPipeLengthMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "管段", value: `${selectedPipes.length} 段` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "当前长度",
|
||||
value: formatDistance(distanceMeters, unit),
|
||||
unit,
|
||||
metrics: [
|
||||
{ label: "段数", value: String(segmentCount) },
|
||||
{ label: "末段", value: `${formatDistance(lastSegmentMeters, unit)} ${unit}` }
|
||||
]
|
||||
};
|
||||
}, [coordinates, mode, selectedPipes, unit]);
|
||||
|
||||
return {
|
||||
measuring,
|
||||
result,
|
||||
hasPoints: mode === "segment" ? selectedPipes.length > 0 : coordinates.length > 0,
|
||||
start: () => setMeasuring(true),
|
||||
stop: () => setMeasuring(false),
|
||||
clear,
|
||||
copyResult
|
||||
};
|
||||
}
|
||||
|
||||
function getResultText(
|
||||
mode: WorkbenchMeasureMode,
|
||||
unit: WorkbenchMeasureUnit,
|
||||
coordinates: LngLatTuple[],
|
||||
selectedPipes: SelectedPipe[]
|
||||
) {
|
||||
const distanceMeters = getLineDistanceMeters(coordinates);
|
||||
|
||||
if (mode === "area") {
|
||||
return `面积:${formatArea(getPolygonAreaSquareMeters(coordinates), unit)} ${unit === "km" ? "km²" : "m²"}`;
|
||||
}
|
||||
|
||||
if (mode === "segment") {
|
||||
const lengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
|
||||
return `资产长度:${formatDistance(lengthMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
return `长度:${formatDistance(distanceMeters, unit)} ${unit}`;
|
||||
}
|
||||
|
||||
function getSelectedPipe(map: MapLibreMap, event: MapMouseEvent): SelectedPipe | null {
|
||||
const point = event.point;
|
||||
const features = map.queryRenderedFeatures(
|
||||
[
|
||||
[point.x - SNAP_RADIUS_PIXELS, point.y - SNAP_RADIUS_PIXELS],
|
||||
[point.x + SNAP_RADIUS_PIXELS, point.y + SNAP_RADIUS_PIXELS]
|
||||
],
|
||||
{ layers: SNAP_LAYER_IDS }
|
||||
);
|
||||
|
||||
for (const feature of features) {
|
||||
const selectedPipe = toSelectedPipe(feature);
|
||||
if (selectedPipe) {
|
||||
return selectedPipe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSelectedPipe(feature: MapGeoJSONFeature): SelectedPipe | null {
|
||||
const id = getFeatureId(feature);
|
||||
if (!id || feature.layer.id !== "pipes-flow") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
lengthMeters: getNumericProperty(feature.properties?.length)
|
||||
};
|
||||
}
|
||||
|
||||
function getNumericProperty(value: unknown) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function addUniquePipe(current: SelectedPipe[], next: SelectedPipe) {
|
||||
if (current.some((pipe) => pipe.id === next.id)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, next];
|
||||
}
|
||||
|
||||
function formatDistance(valueMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueMeters / 1000 : valueMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 2 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function formatArea(valueSquareMeters: number, unit: WorkbenchMeasureUnit) {
|
||||
const value = unit === "km" ? valueSquareMeters / 1_000_000 : valueSquareMeters;
|
||||
return value.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: unit === "km" ? 3 : 1,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user