294 lines
8.1 KiB
TypeScript
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
|
|
});
|
|
}
|