Restore full acrylic materials to stacked task cards and floating controls. Extend the map behind the navigation header while preserving document and control insets. Align timeline and scale readouts, refresh dense-marker hover content, and highlight model and approval menu options. Keep narrow timeline headers clear of their legend and actions. Update coupled geometry checks and retain city zoom-12 and menu regression coverage. Validation: scoped git diff --check and read-only UI geometry inspection. Test suites were not rerun at the user's request.
221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
import type { Map as MapLibreMap } from "maplibre-gl";
|
||
import { type RefObject, useEffect, useState } from "react";
|
||
import { cn } from "@/shared/ui/cn";
|
||
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 overflow-hidden whitespace-nowrap text-slate-700">
|
||
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
||
{coordinateReadout ? (
|
||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
||
{coordinateReadout.mercatorText}
|
||
</span>
|
||
) : (
|
||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums")}>{coordinateLabel}</span>
|
||
)}
|
||
<span className="shrink-0 text-slate-600">EPSG:3857</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;
|
||
}
|