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:
@@ -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 = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user