From 03d976c567a9bf7b65686e795d245b40c151d55b Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Tue, 14 Nov 2023 16:14:27 +0100 Subject: [PATCH] Performance: fix PDF export for maps with many items --- src/Logic/Actors/GeoLocationHandler.ts | 2 +- src/UI/DownloadFlow/DownloadPanel.svelte | 2 +- src/UI/DownloadFlow/DownloadPdf.svelte | 2 - src/UI/Map/MapLibreAdaptor.ts | 137 +++++++++++++++-------- src/Utils/pngMapCreator.ts | 42 ++++++- src/Utils/svgToPdf.ts | 3 + 6 files changed, 131 insertions(+), 57 deletions(-) diff --git a/src/Logic/Actors/GeoLocationHandler.ts b/src/Logic/Actors/GeoLocationHandler.ts index 137345b4bf..9333a84134 100644 --- a/src/Logic/Actors/GeoLocationHandler.ts +++ b/src/Logic/Actors/GeoLocationHandler.ts @@ -173,7 +173,7 @@ export default class GeoLocationHandler { properties[k] = location[k] } } - console.log(location) + console.debug("Current location object:", location) properties["_all"] = JSON.stringify(location) const feature = { diff --git a/src/UI/DownloadFlow/DownloadPanel.svelte b/src/UI/DownloadFlow/DownloadPanel.svelte index 9fab44c75b..6cbac91dd5 100644 --- a/src/UI/DownloadFlow/DownloadPanel.svelte +++ b/src/UI/DownloadFlow/DownloadPanel.svelte @@ -92,7 +92,7 @@ mimetype="image/png" mainText={t.downloadAsPng} helperText={t.downloadAsPngHelper} - construct={() => state.mapProperties.exportAsPng(1)} + construct={() => state.mapProperties.exportAsPng()} />
diff --git a/src/UI/DownloadFlow/DownloadPdf.svelte b/src/UI/DownloadFlow/DownloadPdf.svelte index 379950b8b0..77e043c0f4 100644 --- a/src/UI/DownloadFlow/DownloadPdf.svelte +++ b/src/UI/DownloadFlow/DownloadPdf.svelte @@ -15,7 +15,6 @@ export let templateName: string export let state: ThemeViewState const template: PdfTemplateInfo = SvgToPdf.templates[templateName] - console.log("template", template) let mainText: Translation = typeof template.description === "string" ? new Translation(template.description) @@ -47,7 +46,6 @@ }) const unsub = creator.status.addCallbackAndRunD((s) => { - console.log("SVG creator status:", s) status?.setData(s) }) await creator.ExportPdf(Locale.language.data) diff --git a/src/UI/Map/MapLibreAdaptor.ts b/src/UI/Map/MapLibreAdaptor.ts index 9913942ec5..90f8ad7b3d 100644 --- a/src/UI/Map/MapLibreAdaptor.ts +++ b/src/UI/Map/MapLibreAdaptor.ts @@ -9,6 +9,7 @@ import SvelteUIElement from "../Base/SvelteUIElement" import MaplibreMap from "./MaplibreMap.svelte" import { RasterLayerProperties } from "../../Models/RasterLayerProperties" import * as htmltoimage from "html-to-image" +import { draw } from "svelte/transition" /** * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties` @@ -180,20 +181,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { } } - public static setDpi( - drawOn: HTMLCanvasElement, - ctx: CanvasRenderingContext2D, - dpiFactor: number - ) { - drawOn.style.width = drawOn.style.width || drawOn.width + "px" - drawOn.style.height = drawOn.style.height || drawOn.height + "px" - - // Resize canvas and scale future draws. - drawOn.width = Math.ceil(drawOn.width * dpiFactor) - drawOn.height = Math.ceil(drawOn.height * dpiFactor) - ctx.scale(dpiFactor, dpiFactor) - } - /** * Prepares an ELI-URL to be compatible with mapbox */ @@ -224,27 +211,55 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { return url } - public async exportAsPng(markerScale: number = 1): Promise { + private static async toBlob(canvas: HTMLCanvasElement): Promise { + return await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob))) + } + + private static async createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.decode = () => resolve(img) as any + img.onload = () => resolve(img) + img.onerror = reject + img.crossOrigin = "anonymous" + img.decoding = "async" + img.src = url + }) + } + + public async exportAsPng( + rescaleIcons: number = 1, + progress: UIEventSource<{ current: number; total: number }> = undefined + ): Promise { const map = this._maplibreMap.data if (!map) { return undefined } - const drawOn = document.createElement("canvas") - drawOn.width = map.getCanvas().width - drawOn.height = map.getCanvas().height - + const drawOn = document.createElement("canvas", {}) const ctx = drawOn.getContext("2d") - // Set up CSS size. - MapLibreAdaptor.setDpi(drawOn, ctx, markerScale / map.getPixelRatio()) + // The width/height has been set in 'mm' on the parent element and converted to pixels by the browser + const w = map.getContainer().getBoundingClientRect().width + const h = map.getContainer().getBoundingClientRect().height + + let dpi = map.getPixelRatio() + console.log("Sizes:", { + dpi, + w, + h, + origSizeW: drawOn.style.width, + origSizeH: drawOn.style.height, + }) + // The 'css'-size stays constant... + drawOn.style.width = w + "px" + drawOn.style.height = h + "px" + + // ...but the number of pixels is increased + drawOn.width = Math.ceil(w * dpi) + drawOn.height = Math.ceil(h * dpi) await this.exportBackgroundOnCanvas(ctx) - - // MapLibreAdaptor.setDpi(drawOn, ctx, 1) - await this.drawMarkers(markerScale, ctx) - ctx.scale(markerScale, markerScale) - this._maplibreMap.data?.resize() - - return await new Promise((resolve) => drawOn.toBlob((blob) => resolve(blob))) + await this.drawMarkers(ctx, rescaleIcons, progress) + return await MapLibreAdaptor.toBlob(drawOn) } /** @@ -273,16 +288,26 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { map.resize() } - private async drawMarkers(dpiFactor: number, drawOn: CanvasRenderingContext2D): Promise { + /** + * Draws the markers of the current map on the specified canvas. + * The DPIfactor is used to calculate the correct position, whereas 'rescaleIcons' can be used to make the icons smaller + * @param drawOn + * @param rescaleIcons + * @private + */ + private async drawMarkers( + drawOn: CanvasRenderingContext2D, + rescaleIcons: number = 1, + progress: UIEventSource<{ current: number; total: number }> + ): Promise { const map = this._maplibreMap.data if (!map) { + console.error("There is no map to export from") return undefined } - map.getCanvas().style.display = "none" - const width = map.getCanvas().width - const height = map.getCanvas().height const container = map.getContainer() + function isDisplayed(el: Element) { const r1 = el.getBoundingClientRect() const r2 = container.getBoundingClientRect() @@ -293,32 +318,50 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { r2.bottom < r1.top ) } + const markers = Array.from(container.getElementsByClassName("marker")) for (let i = 0; i < markers.length; i++) { - const marker = markers[i] + const marker = markers[i] if (!isDisplayed(marker)) { continue } - const markerRect = marker.getBoundingClientRect() - const w = markerRect.width - const h = markerRect.height - console.log("Drawing marker", i, "/", markers.length, marker) - const markerImg = await htmltoimage.toCanvas(marker, { - pixelRatio: dpiFactor, - canvasWidth: width * dpiFactor, - canvasHeight: height * dpiFactor, - width: width, - height: height, - }) + const pixelRatio = map.getPixelRatio() + let x = marker.getBoundingClientRect().x + let y = marker.getBoundingClientRect().y + const style = marker.style.transform + marker.style.transform = "" + const offset = style.match(/translate\(([-0-9]+)%, ?([-0-9]+)%\)/) + + console.log("MARKER", marker) + const w = marker.style.width + // Force a wider view for icon badges + marker.style.width = marker.getBoundingClientRect().width * 4 + "px" + const svgSource = await htmltoimage.toSvg(marker) + const img = await MapLibreAdaptor.createImage(svgSource) + marker.style.width = w + if (offset && rescaleIcons !== 1) { + const [_, relXStr, relYStr] = offset + const relX = Number(relXStr) + const relY = Number(relYStr) + console.log("Moving icon with", relX, relY, img.width, img.height, x, y) + // x += img.width * (relX / 100) + y += img.height * (relY / 100) + } + + x *= pixelRatio + y *= pixelRatio + + if (progress) { + progress.setData({ current: i, total: markers.length }) + } try { - drawOn.drawImage(markerImg, markerRect.x, markerRect.y) + drawOn.drawImage(img, x, y, img.width * rescaleIcons, img.height * rescaleIcons) } catch (e) { console.log("Could not draw image because of", e) } + marker.style.transform = style } - - map.getCanvas().style.display = "unset" } private updateStores(isSetup: boolean = false): void { diff --git a/src/Utils/pngMapCreator.ts b/src/Utils/pngMapCreator.ts index 670aad31e4..9df52fc663 100644 --- a/src/Utils/pngMapCreator.ts +++ b/src/Utils/pngMapCreator.ts @@ -6,7 +6,13 @@ import { MapLibreAdaptor } from "../UI/Map/MapLibreAdaptor" import { AvailableRasterLayers } from "../Models/RasterLayers" export interface PngMapCreatorOptions { + /** + * In mm + */ readonly width: number + /** + * In mm + */ readonly height: number } @@ -27,8 +33,23 @@ export class PngMapCreator { public async CreatePng(freeComponentId: string, status?: UIEventSource): Promise { const div = document.createElement("div") div.id = "mapdiv-" + PngMapCreator.id + + /** + * We want a certain amount of pixels per mm² for a high print quality + * For this, we create a bigger map on the screen with a canvas, which has a pixelratio given + * + * We know that the default DPI of a canvas is 92, but to print something, we need a bit more + * So, instead, we give it PIXELRATIO more mm and let it render. + * We then draw this onto the PDF as if it were smaller, so it'll have plenty of quality there. + * + * However, we also need to compensate for this in the zoom level + * + */ + + const pixelRatio = 2 // dots per mm div.style.width = this._options.width + "mm" div.style.height = this._options.height + "mm" + PngMapCreator.id++ try { const layout = this._state.layout @@ -43,18 +64,18 @@ export class PngMapCreator { const l = settings.location.data document.getElementById(freeComponentId).appendChild(div) - const pixelRatio = 4 + const newZoom = settings.zoom.data + Math.log2(pixelRatio) - 1 const mapElem = new MlMap({ container: div.id, style: AvailableRasterLayers.maptilerDefaultLayer.properties.url, center: [l.lon, l.lat], - zoom: settings.zoom.data, + zoom: newZoom, pixelRatio, }) const map = new UIEventSource(mapElem) const mla = new MapLibreAdaptor(map) - mla.zoom.setData(settings.zoom.data) + mla.zoom.setData(newZoom) mla.location.setData(settings.location.data) mla.rasterLayer.setData(settings.rasterLayer.data) mla.allowZooming.setData(false) @@ -70,9 +91,12 @@ export class PngMapCreator { console.log("Waiting for the style to be loaded...") await Utils.waitFor(250) } + // Some extra buffer... - setState("One second pause to make sure all images are loaded...") - await Utils.waitFor(1000) + for (let i = 0; i < 5; i++) { + setState(5 - i + " seconds pause to make sure all images are loaded...") + await Utils.waitFor(1000) + } setState( "Exporting png (" + this._options.width + @@ -82,7 +106,13 @@ export class PngMapCreator { pixelRatio + ")" ) - return await mla.exportAsPng(pixelRatio) + const progress = new UIEventSource<{ current: number; total: number }>(undefined) + progress.addCallbackD(({ current, total }) => { + setState(`Rendering marker ${current}/${total}`) + }) + const png = await mla.exportAsPng(pixelRatio, progress) + setState("Offering as download...") + return png } finally { div.parentElement.removeChild(div) } diff --git a/src/Utils/svgToPdf.ts b/src/Utils/svgToPdf.ts index b7fb6c961f..de26d92fbe 100644 --- a/src/Utils/svgToPdf.ts +++ b/src/Utils/svgToPdf.ts @@ -974,6 +974,9 @@ class SvgToPdfPage { } //*/ svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png)) + svgImage.style.width = width + "mm" + svgImage.style.height = height + "mm" + console.log("Adding map element to PDF", svgImage) smallestRect.parentElement.insertBefore(svgImage, smallestRect) await this.prepareElement(svgImage, [], false) const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style"))