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