215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import * as maplibregl from "maplibre-gl";
|
||
import type { StyleSpecification } from "maplibre-gl";
|
||
import "maplibre-gl/dist/maplibre-gl.css";
|
||
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
||
|
||
import type { MapSpec } from "./visualization-spec";
|
||
import { VisualizationCard } from "./visualization-card";
|
||
|
||
function createStyle(): StyleSpecification {
|
||
return {
|
||
version: 8,
|
||
sources: {
|
||
"google-raster": {
|
||
type: "raster",
|
||
tiles: [getMapTileUrl()],
|
||
tileSize: 256,
|
||
attribution: getMapTileAttribution(),
|
||
},
|
||
},
|
||
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
||
};
|
||
}
|
||
|
||
function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
||
return [
|
||
...spec.markers.map((marker) => marker.coordinate),
|
||
...spec.lines.flatMap((line) => line.coordinates),
|
||
...spec.polygons.flatMap((polygon) => polygon.coordinates),
|
||
];
|
||
}
|
||
|
||
function defaultCenter(spec: MapSpec): [number, number] {
|
||
if (spec.center) return spec.center;
|
||
const coordinates = allCoordinates(spec);
|
||
if (coordinates.length === 0) return [104.1954, 35.8617];
|
||
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
||
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
||
}
|
||
|
||
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const [tileFailed, setTileFailed] = useState(false);
|
||
const [reloadToken, setReloadToken] = useState(0);
|
||
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
setTileFailed(false);
|
||
|
||
const map = new maplibregl.Map({
|
||
container,
|
||
style: createStyle(),
|
||
center: defaultCenter(spec),
|
||
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
||
minZoom: 1,
|
||
maxZoom: 20,
|
||
attributionControl: { compact: true },
|
||
});
|
||
mapRef.current = map;
|
||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||
|
||
const markers: maplibregl.Marker[] = [];
|
||
for (const marker of spec.markers) {
|
||
const element = document.createElement("button");
|
||
element.type = "button";
|
||
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
||
element.title = marker.name;
|
||
element.setAttribute("aria-label", marker.name);
|
||
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
||
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
||
);
|
||
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
||
}
|
||
|
||
let baseTileLoaded = false;
|
||
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
||
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
|
||
baseTileLoaded = true;
|
||
setTileFailed(false);
|
||
};
|
||
const onError = (event: maplibregl.MapEventType["error"]) => {
|
||
const message = event.error?.message ?? "";
|
||
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
||
};
|
||
map.on("sourcedata", onSourceData);
|
||
map.on("error", onError);
|
||
|
||
map.on("load", () => {
|
||
if (spec.lines.length > 0) {
|
||
map.addSource("visual-lines", {
|
||
type: "geojson",
|
||
data: {
|
||
type: "FeatureCollection",
|
||
features: spec.lines.map((line) => ({
|
||
type: "Feature",
|
||
properties: { name: line.name ?? "" },
|
||
geometry: { type: "LineString", coordinates: line.coordinates },
|
||
})),
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: "visual-lines",
|
||
type: "line",
|
||
source: "visual-lines",
|
||
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
|
||
});
|
||
}
|
||
|
||
if (spec.polygons.length > 0) {
|
||
map.addSource("visual-polygons", {
|
||
type: "geojson",
|
||
data: {
|
||
type: "FeatureCollection",
|
||
features: spec.polygons.map((polygon) => ({
|
||
type: "Feature",
|
||
properties: { name: polygon.name ?? "" },
|
||
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
||
})),
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: "visual-polygons-fill",
|
||
type: "fill",
|
||
source: "visual-polygons",
|
||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
||
});
|
||
map.addLayer({
|
||
id: "visual-polygons-border",
|
||
type: "line",
|
||
source: "visual-polygons",
|
||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||
});
|
||
}
|
||
|
||
if (!spec.center && coordinates.length > 1) {
|
||
const bounds = coordinates.reduce(
|
||
(current, coordinate) => current.extend(coordinate),
|
||
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
||
);
|
||
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
map.off("sourcedata", onSourceData);
|
||
map.off("error", onError);
|
||
markers.forEach((marker) => marker.remove());
|
||
mapRef.current = null;
|
||
map.remove();
|
||
};
|
||
}, [coordinates, reloadToken, spec]);
|
||
|
||
return (
|
||
<VisualizationCard>
|
||
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
|
||
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||
<span className="truncate text-sm font-medium">{spec.title}</span>
|
||
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
|
||
</div>
|
||
<div className="relative">
|
||
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
|
||
{tileFailed ? (
|
||
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
|
||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
|
||
<Button
|
||
aria-label="重新加载地图"
|
||
className="shrink-0 text-amber-950 hover:bg-amber-100"
|
||
onClick={() => {
|
||
setTileFailed(false);
|
||
setReloadToken((value) => value + 1);
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<RefreshCw aria-hidden="true" />
|
||
重试
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{spec.markers.length > 0 ? (
|
||
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
||
<ul className="mt-2 space-y-1">
|
||
{spec.markers.map((marker, index) => (
|
||
<li key={`${marker.name}-${index}`}>
|
||
<span className="font-medium text-foreground">{marker.name}</span>
|
||
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
||
{marker.description ? <span>:{marker.description}</span> : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
) : null}
|
||
</VisualizationCard>
|
||
);
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value.replace(/[&<>'"]/g, (character) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
"'": "'",
|
||
'"': """,
|
||
})[character] ?? character);
|
||
}
|