Files
next-tjwater-drainage-frontend/features/workbench/map/export-view.ts
T

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`;
}