fix(sensor-placement): stabilize engineering drawing export

Render the OpenLayers basemap at the final A3 map-box resolution with pixelRatio 1, carry provider attribution into the drawing, and keep the linework/basemap export path covered by regression tests.
This commit is contained in:
2026-07-30 16:21:30 +08:00
parent d643f09fcf
commit 6114e76735
5 changed files with 115 additions and 16 deletions
@@ -33,6 +33,7 @@ import {
lineStringFromFlatCoordinates, lineStringFromFlatCoordinates,
} from "@components/olmap/core/tileFeatureIndex"; } from "@components/olmap/core/tileFeatureIndex";
import { import {
A3_LANDSCAPE_HEIGHT,
A3_LANDSCAPE_WIDTH, A3_LANDSCAPE_WIDTH,
canvasToPngBlob, canvasToPngBlob,
getPaddedDrawingExtent, getPaddedDrawingExtent,
@@ -55,7 +56,10 @@ interface SchemeDrawingDialogProps {
} }
const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000; const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000;
const DRAWING_MAP_SIZE: [number, number] = [1600, 981]; const DRAWING_MAP_SIZE: [number, number] = [
A3_LANDSCAPE_WIDTH - 300,
A3_LANDSCAPE_HEIGHT - 650,
];
type DrawingMode = "linework" | "basemap"; type DrawingMode = "linework" | "basemap";
@@ -106,17 +110,26 @@ export const fitMapToFullNetwork = (
interface DrawingMapSession { interface DrawingMapSession {
map: OlMap; map: OlMap;
extent: [number, number, number, number]; extent: [number, number, number, number];
attribution: string | null;
dispose: () => void; dispose: () => void;
} }
const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer<TileSource>[] => { const cloneVisibleBaseLayers = (
mainMap: OlMap,
): { layers: TileLayer<TileSource>[]; attribution: string | null } => {
const cloned: TileLayer<TileSource>[] = []; const cloned: TileLayer<TileSource>[] = [];
const attributions = new Set<string>();
const collect = (layer: unknown, parentVisible = true) => { const collect = (layer: unknown, parentVisible = true) => {
const candidate = layer as { const candidate = layer as {
getVisible?: () => boolean; getVisible?: () => boolean;
get?: (key: string) => unknown;
getLayers?: () => { getArray: () => unknown[] }; getLayers?: () => { getArray: () => unknown[] };
}; };
if (!parentVisible || candidate.getVisible?.() === false) return; if (!parentVisible || candidate.getVisible?.() === false) return;
const attribution = candidate.get?.("exportAttribution");
if (typeof attribution === "string" && attribution) {
attributions.add(attribution);
}
if (layer instanceof LayerGroup) { if (layer instanceof LayerGroup) {
candidate.getLayers?.().getArray().forEach((child) => collect(child)); candidate.getLayers?.().getArray().forEach((child) => collect(child));
return; return;
@@ -133,7 +146,10 @@ const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer<TileSource>[] => {
); );
}; };
mainMap.getLayers().getArray().forEach((layer) => collect(layer)); mainMap.getLayers().getArray().forEach((layer) => collect(layer));
return cloned; return {
layers: cloned,
attribution: attributions.size ? [...attributions].join(" · ") : null,
};
}; };
const createDrawingMapSession = ( const createDrawingMapSession = (
@@ -180,12 +196,14 @@ const createDrawingMapSession = (
}); });
pipeLayer.set("value", "pipes"); pipeLayer.set("value", "pipes");
pipeLayer.setExtent(networkExtent); pipeLayer.setExtent(networkExtent);
const layers = const basemap =
mode === "basemap" mode === "basemap"
? [...cloneVisibleBaseLayers(mainMap), pipeLayer] ? cloneVisibleBaseLayers(mainMap)
: [pipeLayer]; : { layers: [], attribution: null };
const layers = [...basemap.layers, pipeLayer];
drawingMap = new OlMap({ drawingMap = new OlMap({
target, target,
pixelRatio: 1,
view: new View({ view: new View({
projection: mainMap.getView().getProjection(), projection: mainMap.getView().getProjection(),
}), }),
@@ -200,6 +218,7 @@ const createDrawingMapSession = (
return { return {
map: drawingMap, map: drawingMap,
extent: networkExtent, extent: networkExtent,
attribution: basemap.attribution,
dispose: () => { dispose: () => {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
@@ -378,6 +397,7 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
); );
const [drawingMode, setDrawingMode] = useState<DrawingMode>("linework"); const [drawingMode, setDrawingMode] = useState<DrawingMode>("linework");
const [mapCanvas, setMapCanvas] = useState<HTMLCanvasElement | null>(null); const [mapCanvas, setMapCanvas] = useState<HTMLCanvasElement | null>(null);
const [mapAttribution, setMapAttribution] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -393,6 +413,7 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
setError(null); setError(null);
setNetworkData(null); setNetworkData(null);
setMapCanvas(null); setMapCanvas(null);
setMapAttribution(null);
if (!map) { if (!map) {
setError("主地图尚未初始化,请稍后重试"); setError("主地图尚未初始化,请稍后重试");
setLoading(false); setLoading(false);
@@ -424,6 +445,9 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
if (!cancelled) { if (!cancelled) {
setNetworkData(data); setNetworkData(data);
setMapCanvas(captured); setMapCanvas(captured);
setMapAttribution(
drawingMode === "basemap" ? drawingSession.attribution : null,
);
} }
}) })
.catch((reason) => { .catch((reason) => {
@@ -455,6 +479,7 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
network, network,
networkData, networkData,
mapCanvas, mapCanvas,
mapAttribution,
dirty, dirty,
width: 1600, width: 1600,
}) })
@@ -478,7 +503,16 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
cancelled = true; cancelled = true;
if (currentUrl) URL.revokeObjectURL(currentUrl); if (currentUrl) URL.revokeObjectURL(currentUrl);
}; };
}, [dirty, mapCanvas, network, networkData, open, rows, scheme]); }, [
dirty,
mapAttribution,
mapCanvas,
network,
networkData,
open,
rows,
scheme,
]);
const createFullResolutionBlob = async () => { const createFullResolutionBlob = async () => {
if (!networkData) throw new Error("管网数据尚未加载"); if (!networkData) throw new Error("管网数据尚未加载");
@@ -488,6 +522,7 @@ const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
network, network,
networkData, networkData,
mapCanvas, mapCanvas,
mapAttribution,
dirty, dirty,
width: A3_LANDSCAPE_WIDTH, width: A3_LANDSCAPE_WIDTH,
}); });
@@ -320,6 +320,7 @@ describe("engineering drawing", () => {
extent: [0, 0, 1000, 1000], extent: [0, 0, 1000, 1000],
}, },
mapCanvas, mapCanvas,
mapAttribution: "© Mapbox © OpenStreetMap",
dirty: false, dirty: false,
width: 1600, width: 1600,
}); });
@@ -331,5 +332,10 @@ describe("engineering drawing", () => {
expect.any(Number), expect.any(Number),
expect.any(Number), expect.any(Number),
); );
expect(context.fillText).toHaveBeenCalledWith(
"底图来源:© Mapbox © OpenStreetMap",
expect.any(Number),
expect.any(Number),
);
}); });
}); });
@@ -13,6 +13,7 @@ interface DrawingOptions {
network: string; network: string;
networkData: NetworkDrawingData; networkData: NetworkDrawingData;
mapCanvas?: HTMLCanvasElement | null; mapCanvas?: HTMLCanvasElement | null;
mapAttribution?: string | null;
dirty: boolean; dirty: boolean;
width?: number; width?: number;
} }
@@ -123,6 +124,7 @@ export const renderEngineeringDrawing = async ({
network, network,
networkData, networkData,
mapCanvas, mapCanvas,
mapAttribution,
dirty, dirty,
width = A3_LANDSCAPE_WIDTH, width = A3_LANDSCAPE_WIDTH,
}: DrawingOptions): Promise<HTMLCanvasElement> => { }: DrawingOptions): Promise<HTMLCanvasElement> => {
@@ -261,6 +263,20 @@ export const renderEngineeringDrawing = async ({
context.strokeStyle = "#111827"; context.strokeStyle = "#111827";
context.lineWidth = px(4); context.lineWidth = px(4);
context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
if (mapCanvas && mapAttribution) {
context.fillStyle = "#334155";
drawText(
context,
`底图来源:${mapAttribution}`,
mapBox.x + mapBox.width - px(16),
mapBox.y + mapBox.height - px(18),
px(18),
500,
"right",
"rgba(255,255,255,0.96)",
px(6),
);
}
for (let index = 0; index <= 4; index += 1) { for (let index = 0; index <= 4; index += 1) {
const ratio = index / 4; const ratio = index / 4;
@@ -16,24 +16,38 @@ jest.mock("ol/layer/Tile.js", () => ({
__esModule: true, __esModule: true,
default: class MockTileLayer { default: class MockTileLayer {
private readonly source: unknown; private readonly source: unknown;
private readonly properties = new Map<string, unknown>();
constructor(options: any) { constructor(options: any) {
this.source = options.source; this.source = options.source;
} }
getSource() { getSource() {
return this.source; return this.source;
} }
set(key: string, value: unknown) {
this.properties.set(key, value);
}
get(key: string) {
return this.properties.get(key);
}
}, },
})); }));
jest.mock("ol/layer/Group", () => ({ jest.mock("ol/layer/Group", () => ({
__esModule: true, __esModule: true,
default: class MockGroup { default: class MockGroup {
private readonly layers: unknown[]; private readonly layers: unknown[];
private readonly properties = new Map<string, unknown>();
constructor(options: any) { constructor(options: any) {
this.layers = options.layers; this.layers = options.layers;
} }
getLayers() { getLayers() {
return { getArray: () => this.layers }; return { getArray: () => this.layers };
} }
set(key: string, value: unknown) {
this.properties.set(key, value);
}
get(key: string) {
return this.properties.get(key);
}
}, },
})); }));
@@ -70,6 +84,7 @@ describe("base layer resources", () => {
expect(getLeafSources(entry.layer)).toEqual( expect(getLeafSources(entry.layer)).toEqual(
getLeafSources(compare[index].layer), getLeafSources(compare[index].layer),
); );
expect(entry.layer.get("exportAttribution")).toBe(entry.attribution);
}); });
}); });
}); });
@@ -18,16 +18,42 @@ import { markMapResourcePersistent } from "../mapLifecycle";
const INITIAL_LAYER = "mapbox-light"; const INITIAL_LAYER = "mapbox-light";
const BASE_LAYER_METADATA = [ const BASE_LAYER_METADATA = [
{ id: "mapbox-light", name: "默认地图", img: mapboxLight.src }, {
{ id: "mapbox-satellite", name: "卫星地图", img: mapboxSatellite.src }, id: "mapbox-light",
name: "默认地图",
img: mapboxLight.src,
attribution: "© Mapbox © OpenStreetMap",
},
{
id: "mapbox-satellite",
name: "卫星地图",
img: mapboxSatellite.src,
attribution: "© Mapbox",
},
{ {
id: "mapbox-satellite-streets", id: "mapbox-satellite-streets",
name: "卫星街道地图", name: "卫星街道地图",
img: mapboxSatelliteStreet.src, img: mapboxSatelliteStreet.src,
attribution: "© Mapbox © OpenStreetMap",
},
{
id: "mapbox-streets",
name: "街道地图",
img: mapboxStreets.src,
attribution: "© Mapbox © OpenStreetMap",
},
{
id: "tianditu-vector",
name: "天地图矢量",
img: mapboxOutdoors.src,
attribution: "© 天地图",
},
{
id: "tianditu-image",
name: "天地图影像",
img: mapboxSatellite.src,
attribution: "© 天地图",
}, },
{ id: "mapbox-streets", name: "街道地图", img: mapboxStreets.src },
{ id: "tianditu-vector", name: "天地图矢量", img: mapboxOutdoors.src },
{ id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src },
] as const; ] as const;
const createTileSource = (url: string, attributions: string) => const createTileSource = (url: string, attributions: string) =>
@@ -123,10 +149,11 @@ export const createBaseLayerEntries = (sources: BaseLayerSources) => {
], ],
}), }),
}, },
].map((entry) => ({ ].map((entry) => {
...entry, const layer = markMapResourcePersistent(entry.layer);
layer: markMapResourcePersistent(entry.layer), layer.set("exportAttribution", entry.attribution);
})); return { ...entry, layer };
});
}; };
const BaseLayers: React.FC = () => { const BaseLayers: React.FC = () => {