224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
"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;
|
||
}
|