Files
next-tjwater-frontend/src/features/map/core/components/scale-line.tsx
T
jiang 85a9a3ab45 fix(ui): refine acrylic controls and timeline interactions
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.
2026-10-09 18:11:33 +08:00

221 lines
6.8 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.
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;
}