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

294 lines
8.1 KiB
TypeScript

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