feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
type ScaleLineState = {
label: string;
width: number;
zoom: string;
};
type MapScaleLineProps = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
maxWidth?: number;
coordinatePrecision?: number;
className?: string;
};
type CoordinateState = {
lng: number;
lat: number;
};
const DEFAULT_SCALE: ScaleLineState = {
label: "500 m",
width: 80,
zoom: "Z --"
};
const MIN_SCALE_WIDTH = 34;
const COORDINATE_READOUT_WIDTH_CLASS_NAME = "w-[160px]";
export function MapScaleLine({
mapRef,
mapReady,
maxWidth = 96,
coordinatePrecision = 5,
className = ""
}: MapScaleLineProps) {
const [scale, setScale] = useState<ScaleLineState>(DEFAULT_SCALE);
const [coordinate, setCoordinate] = useState<CoordinateState | null>(null);
const coordinateReadout = coordinate
? createCoordinateReadout(coordinate, coordinatePrecision)
: null;
const coordinateLabel = coordinateReadout
? `${coordinateReadout.mercatorText} · ${coordinateReadout.lngLatText}`
: "移动鼠标读取坐标";
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const updateScale = () => {
setScale(createScaleLineState(map, maxWidth));
};
updateScale();
map.on("move", updateScale);
map.on("zoom", updateScale);
map.on("resize", updateScale);
return () => {
map.off("move", updateScale);
map.off("zoom", updateScale);
map.off("resize", updateScale);
};
}, [mapReady, mapRef, maxWidth]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const handleMove = (event: { lngLat: { lng: number; lat: number } }) => {
setCoordinate({
lng: event.lngLat.lng,
lat: event.lngLat.lat
});
};
const handleLeave = () => {
setCoordinate(null);
};
map.on("mousemove", handleMove);
map.on("mouseout", handleLeave);
return () => {
map.off("mousemove", handleMove);
map.off("mouseout", handleLeave);
};
}, [mapReady, mapRef]);
return (
<div
aria-label={`比例尺 ${scale.label}${coordinateLabel}`}
className={cn(
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
MAP_CONTROL_SURFACE_CLASS_NAME,
className
)}
>
<div className="flex shrink-0 items-center gap-2">
<span className="min-w-11 whitespace-nowrap text-right tabular-nums">{scale.label}</span>
<div
className="relative h-2 shrink-0 border-x-2 border-b-2 border-slate-800 transition-[width,border-color] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${scale.width}px` }}
aria-hidden="true"
/>
</div>
<ScaleLineDivider />
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
<span className="text-slate-600">EPSG:3857</span>
{coordinateReadout ? (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
{coordinateReadout.mercatorText}
</span>
) : (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
)}
<span className="text-slate-500">Mapbox/OSM</span>
</div>
</div>
);
}
function ScaleLineDivider() {
return (
<div
className="h-3.5 w-px shrink-0 bg-slate-300/70"
aria-hidden="true"
/>
);
}
function createScaleLineState(map: MapLibreMap, maxWidth: number): ScaleLineState {
const canvas = map.getCanvas();
const canvasWidth = canvas.clientWidth || canvas.width;
const center = map.project(map.getCenter());
const halfWidth = Math.min(maxWidth / 2, Math.max(1, canvasWidth / 2));
const y = Math.round(center.y);
const start = map.unproject([Math.max(0, center.x - halfWidth), y]);
const end = map.unproject([Math.min(canvasWidth, center.x + halfWidth), y]);
const measuredWidth = Math.max(1, Math.min(canvasWidth, center.x + halfWidth) - Math.max(0, center.x - halfWidth));
const metersPerMaxWidth = distanceInMeters(start.lng, start.lat, end.lng, end.lat);
if (!Number.isFinite(metersPerMaxWidth) || metersPerMaxWidth <= 0) {
return {
...DEFAULT_SCALE,
zoom: `Z ${map.getZoom().toFixed(1)}`
};
}
const niceMeters = getNiceDistance(metersPerMaxWidth);
const widthPx = Math.min(
measuredWidth,
Math.max(Math.min(MIN_SCALE_WIDTH, measuredWidth), Math.round((niceMeters / metersPerMaxWidth) * measuredWidth))
);
return {
label: formatDistance(niceMeters),
width: widthPx,
zoom: `Z ${map.getZoom().toFixed(1)}`
};
}
function getNiceDistance(maxMeters: number) {
const target = Math.max(1, maxMeters);
const magnitude = 10 ** Math.floor(Math.log10(target));
const normalized = target / magnitude;
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
return multiplier * magnitude;
}
function formatDistance(meters: number) {
if (meters >= 1000) {
return `${Number((meters / 1000).toFixed(meters >= 10000 ? 0 : 1))} km`;
}
return `${Math.round(meters)} m`;
}
function createCoordinateReadout(coordinate: CoordinateState, precision: number) {
return {
lngLatText: `Lng ${coordinate.lng.toFixed(precision)} Lat ${coordinate.lat.toFixed(precision)}`,
mercatorText: formatWebMercator(coordinate.lng, coordinate.lat)
};
}
function formatWebMercator(lng: number, lat: number) {
const earthRadius = 6378137;
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
const x = earthRadius * toRadians(lng);
const y = earthRadius * Math.log(Math.tan(Math.PI / 4 + toRadians(clampedLat) / 2));
return `X ${Math.round(x).toLocaleString("en-US")} Y ${Math.round(y).toLocaleString("en-US")}`;
}
function distanceInMeters(startLng: number, startLat: number, endLng: number, endLat: number) {
const earthRadius = 6371008.8;
const startPhi = toRadians(startLat);
const endPhi = toRadians(endLat);
const deltaPhi = toRadians(endLat - startLat);
const deltaLambda = toRadians(endLng - startLng);
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(startPhi) * Math.cos(endPhi) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRadians(degrees: number) {
return (degrees * Math.PI) / 180;
}