Files
next-tjwater-drainage-frontend/features/map/core/components/scale-line.tsx
T

224 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MAP_AIR_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_AIR_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;
}