124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
|
import type { MapFeatureInteractionState } from "../hooks/use-map-interactions";
|
|
|
|
const MAX_EXPORT_DIMENSION = 4096;
|
|
const EXPORT_IDLE_TIMEOUT_MS = 6000;
|
|
|
|
export type ExportMapViewOptions = {
|
|
scale?: number;
|
|
targetLongEdge?: number;
|
|
filename?: string;
|
|
selectedFeature?: Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id"> | null;
|
|
};
|
|
|
|
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename, selectedFeature }: ExportMapViewOptions = {}) {
|
|
const canvas = map.getCanvas();
|
|
const sourceWidth = canvas.clientWidth || canvas.width;
|
|
const sourceHeight = canvas.clientHeight || canvas.height;
|
|
|
|
if (!sourceWidth || !sourceHeight) {
|
|
throw new Error("地图画布尺寸不可用。");
|
|
}
|
|
|
|
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
|
|
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
|
|
const exportWidth = Math.round(sourceWidth * exportScale);
|
|
const exportHeight = Math.round(sourceHeight * exportScale);
|
|
const container = createExportContainer(exportWidth, exportHeight);
|
|
const style = cloneMapStyle(map.getStyle());
|
|
const bounds = map.getBounds();
|
|
|
|
document.body.appendChild(container);
|
|
|
|
const exportMap = new maplibregl.Map({
|
|
container,
|
|
style,
|
|
center: map.getCenter(),
|
|
zoom: map.getZoom(),
|
|
pitch: map.getPitch(),
|
|
bearing: map.getBearing(),
|
|
preserveDrawingBuffer: true,
|
|
interactive: false,
|
|
attributionControl: false,
|
|
fadeDuration: 0
|
|
});
|
|
|
|
try {
|
|
await onceMapEvent(exportMap, "load");
|
|
exportMap.fitBounds(bounds, {
|
|
padding: 0,
|
|
duration: 0
|
|
});
|
|
if (selectedFeature) {
|
|
exportMap.setFeatureState(
|
|
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
|
|
{ selected: true, hovered: false }
|
|
);
|
|
}
|
|
await waitForMapIdle(exportMap);
|
|
|
|
const link = document.createElement("a");
|
|
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
|
|
link.href = exportMap.getCanvas().toDataURL("image/png");
|
|
link.click();
|
|
|
|
return {
|
|
width: exportWidth,
|
|
height: exportHeight,
|
|
scale: exportScale
|
|
};
|
|
} finally {
|
|
exportMap.remove();
|
|
container.remove();
|
|
}
|
|
}
|
|
|
|
function createExportContainer(width: number, height: number) {
|
|
const container = document.createElement("div");
|
|
container.style.position = "fixed";
|
|
container.style.left = "-10000px";
|
|
container.style.top = "0";
|
|
container.style.width = `${width}px`;
|
|
container.style.height = `${height}px`;
|
|
container.style.pointerEvents = "none";
|
|
container.style.opacity = "0";
|
|
return container;
|
|
}
|
|
|
|
function cloneMapStyle(style: StyleSpecification) {
|
|
if (typeof structuredClone === "function") {
|
|
return structuredClone(style);
|
|
}
|
|
|
|
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
|
|
}
|
|
|
|
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
|
|
return new Promise<void>((resolve, reject) => {
|
|
map.once(eventName, () => resolve());
|
|
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
|
|
});
|
|
}
|
|
|
|
function waitForMapIdle(map: MapLibreMap) {
|
|
return Promise.race([
|
|
onceMapEvent(map, "idle"),
|
|
new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
|
|
})
|
|
]);
|
|
}
|
|
|
|
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
|
|
if (targetLongEdge === 3840) {
|
|
return "4k";
|
|
}
|
|
|
|
return `${scale.toFixed(1)}x`;
|
|
}
|
|
|
|
function getDefaultExportFilename(label: string) {
|
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
return `drainage-network-view-${label}-${timestamp}.png`;
|
|
}
|