forked from MapComplete/MapComplete
		
	Performance: fix PDF export for maps with many items
This commit is contained in:
		
							parent
							
								
									7ae1a6c00f
								
							
						
					
					
						commit
						03d976c567
					
				
					 6 changed files with 131 additions and 57 deletions
				
			
		|  | @ -173,7 +173,7 @@ export default class GeoLocationHandler { | ||||||
|                     properties[k] = location[k] |                     properties[k] = location[k] | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             console.log(location) |             console.debug("Current location object:", location) | ||||||
|             properties["_all"] = JSON.stringify(location) |             properties["_all"] = JSON.stringify(location) | ||||||
| 
 | 
 | ||||||
|             const feature = <Feature>{ |             const feature = <Feature>{ | ||||||
|  |  | ||||||
|  | @ -92,7 +92,7 @@ | ||||||
|     mimetype="image/png" |     mimetype="image/png" | ||||||
|     mainText={t.downloadAsPng} |     mainText={t.downloadAsPng} | ||||||
|     helperText={t.downloadAsPngHelper} |     helperText={t.downloadAsPngHelper} | ||||||
|     construct={() => state.mapProperties.exportAsPng(1)} |     construct={() => state.mapProperties.exportAsPng()} | ||||||
|   /> |   /> | ||||||
| 
 | 
 | ||||||
|   <div class="flex flex-col"> |   <div class="flex flex-col"> | ||||||
|  |  | ||||||
|  | @ -15,7 +15,6 @@ | ||||||
|   export let templateName: string |   export let templateName: string | ||||||
|   export let state: ThemeViewState |   export let state: ThemeViewState | ||||||
|   const template: PdfTemplateInfo = SvgToPdf.templates[templateName] |   const template: PdfTemplateInfo = SvgToPdf.templates[templateName] | ||||||
|   console.log("template", template) |  | ||||||
|   let mainText: Translation = |   let mainText: Translation = | ||||||
|     typeof template.description === "string" |     typeof template.description === "string" | ||||||
|       ? new Translation(template.description) |       ? new Translation(template.description) | ||||||
|  | @ -47,7 +46,6 @@ | ||||||
|     }) |     }) | ||||||
| 
 | 
 | ||||||
|     const unsub = creator.status.addCallbackAndRunD((s) => { |     const unsub = creator.status.addCallbackAndRunD((s) => { | ||||||
|       console.log("SVG creator status:", s) |  | ||||||
|       status?.setData(s) |       status?.setData(s) | ||||||
|     }) |     }) | ||||||
|     await creator.ExportPdf(Locale.language.data) |     await creator.ExportPdf(Locale.language.data) | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import SvelteUIElement from "../Base/SvelteUIElement" | ||||||
| import MaplibreMap from "./MaplibreMap.svelte" | import MaplibreMap from "./MaplibreMap.svelte" | ||||||
| import { RasterLayerProperties } from "../../Models/RasterLayerProperties" | import { RasterLayerProperties } from "../../Models/RasterLayerProperties" | ||||||
| import * as htmltoimage from "html-to-image" | import * as htmltoimage from "html-to-image" | ||||||
|  | import { draw } from "svelte/transition" | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties` |  * 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 |      * Prepares an ELI-URL to be compatible with mapbox | ||||||
|      */ |      */ | ||||||
|  | @ -224,27 +211,55 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { | ||||||
|         return url |         return url | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async exportAsPng(markerScale: number = 1): Promise<Blob> { |     private static async toBlob(canvas: HTMLCanvasElement): Promise<Blob> { | ||||||
|  |         return await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob))) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private static async createImage(url: string): Promise<HTMLImageElement> { | ||||||
|  |         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<Blob> { | ||||||
|         const map = this._maplibreMap.data |         const map = this._maplibreMap.data | ||||||
|         if (!map) { |         if (!map) { | ||||||
|             return undefined |             return undefined | ||||||
|         } |         } | ||||||
|         const drawOn = document.createElement("canvas") |         const drawOn = document.createElement("canvas", {}) | ||||||
|         drawOn.width = map.getCanvas().width |  | ||||||
|         drawOn.height = map.getCanvas().height |  | ||||||
| 
 |  | ||||||
|         const ctx = drawOn.getContext("2d") |         const ctx = drawOn.getContext("2d") | ||||||
|         // Set up CSS size.
 |         // The width/height has been set in 'mm' on the parent element and converted to pixels by the browser
 | ||||||
|         MapLibreAdaptor.setDpi(drawOn, ctx, markerScale / map.getPixelRatio()) |         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) |         await this.exportBackgroundOnCanvas(ctx) | ||||||
| 
 |         await this.drawMarkers(ctx, rescaleIcons, progress) | ||||||
|         // MapLibreAdaptor.setDpi(drawOn, ctx, 1)
 |         return await MapLibreAdaptor.toBlob(drawOn) | ||||||
|         await this.drawMarkers(markerScale, ctx) |  | ||||||
|         ctx.scale(markerScale, markerScale) |  | ||||||
|         this._maplibreMap.data?.resize() |  | ||||||
| 
 |  | ||||||
|         return await new Promise<Blob>((resolve) => drawOn.toBlob((blob) => resolve(blob))) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -273,16 +288,26 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { | ||||||
|         map.resize() |         map.resize() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private async drawMarkers(dpiFactor: number, drawOn: CanvasRenderingContext2D): Promise<void> { |     /** | ||||||
|  |      * 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<void> { | ||||||
|         const map = this._maplibreMap.data |         const map = this._maplibreMap.data | ||||||
|         if (!map) { |         if (!map) { | ||||||
|  |             console.error("There is no map to export from") | ||||||
|             return undefined |             return undefined | ||||||
|         } |         } | ||||||
|         map.getCanvas().style.display = "none" |  | ||||||
| 
 | 
 | ||||||
|         const width = map.getCanvas().width |  | ||||||
|         const height = map.getCanvas().height |  | ||||||
|         const container = map.getContainer() |         const container = map.getContainer() | ||||||
|  | 
 | ||||||
|         function isDisplayed(el: Element) { |         function isDisplayed(el: Element) { | ||||||
|             const r1 = el.getBoundingClientRect() |             const r1 = el.getBoundingClientRect() | ||||||
|             const r2 = container.getBoundingClientRect() |             const r2 = container.getBoundingClientRect() | ||||||
|  | @ -293,32 +318,50 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { | ||||||
|                 r2.bottom < r1.top |                 r2.bottom < r1.top | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|         const markers = Array.from(container.getElementsByClassName("marker")) |         const markers = Array.from(container.getElementsByClassName("marker")) | ||||||
|         for (let i = 0; i < markers.length; i++) { |         for (let i = 0; i < markers.length; i++) { | ||||||
|             const marker = markers[i] |             const marker = <HTMLElement>markers[i] | ||||||
|             if (!isDisplayed(marker)) { |             if (!isDisplayed(marker)) { | ||||||
|                 continue |                 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(<HTMLElement>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 { |             try { | ||||||
|                 drawOn.drawImage(markerImg, markerRect.x, markerRect.y) |                 drawOn.drawImage(img, x, y, img.width * rescaleIcons, img.height * rescaleIcons) | ||||||
|             } catch (e) { |             } catch (e) { | ||||||
|                 console.log("Could not draw image because of", 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 { |     private updateStores(isSetup: boolean = false): void { | ||||||
|  |  | ||||||
|  | @ -6,7 +6,13 @@ import { MapLibreAdaptor } from "../UI/Map/MapLibreAdaptor" | ||||||
| import { AvailableRasterLayers } from "../Models/RasterLayers" | import { AvailableRasterLayers } from "../Models/RasterLayers" | ||||||
| 
 | 
 | ||||||
| export interface PngMapCreatorOptions { | export interface PngMapCreatorOptions { | ||||||
|  |     /** | ||||||
|  |      * In mm | ||||||
|  |      */ | ||||||
|     readonly width: number |     readonly width: number | ||||||
|  |     /** | ||||||
|  |      * In mm | ||||||
|  |      */ | ||||||
|     readonly height: number |     readonly height: number | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -27,8 +33,23 @@ export class PngMapCreator { | ||||||
|     public async CreatePng(freeComponentId: string, status?: UIEventSource<string>): Promise<Blob> { |     public async CreatePng(freeComponentId: string, status?: UIEventSource<string>): Promise<Blob> { | ||||||
|         const div = document.createElement("div") |         const div = document.createElement("div") | ||||||
|         div.id = "mapdiv-" + PngMapCreator.id |         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.width = this._options.width + "mm" | ||||||
|         div.style.height = this._options.height + "mm" |         div.style.height = this._options.height + "mm" | ||||||
|  | 
 | ||||||
|         PngMapCreator.id++ |         PngMapCreator.id++ | ||||||
|         try { |         try { | ||||||
|             const layout = this._state.layout |             const layout = this._state.layout | ||||||
|  | @ -43,18 +64,18 @@ export class PngMapCreator { | ||||||
|             const l = settings.location.data |             const l = settings.location.data | ||||||
| 
 | 
 | ||||||
|             document.getElementById(freeComponentId).appendChild(div) |             document.getElementById(freeComponentId).appendChild(div) | ||||||
|             const pixelRatio = 4 |             const newZoom = settings.zoom.data + Math.log2(pixelRatio) - 1 | ||||||
|             const mapElem = new MlMap({ |             const mapElem = new MlMap({ | ||||||
|                 container: div.id, |                 container: div.id, | ||||||
|                 style: AvailableRasterLayers.maptilerDefaultLayer.properties.url, |                 style: AvailableRasterLayers.maptilerDefaultLayer.properties.url, | ||||||
|                 center: [l.lon, l.lat], |                 center: [l.lon, l.lat], | ||||||
|                 zoom: settings.zoom.data, |                 zoom: newZoom, | ||||||
|                 pixelRatio, |                 pixelRatio, | ||||||
|             }) |             }) | ||||||
| 
 | 
 | ||||||
|             const map = new UIEventSource<MlMap>(mapElem) |             const map = new UIEventSource<MlMap>(mapElem) | ||||||
|             const mla = new MapLibreAdaptor(map) |             const mla = new MapLibreAdaptor(map) | ||||||
|             mla.zoom.setData(settings.zoom.data) |             mla.zoom.setData(newZoom) | ||||||
|             mla.location.setData(settings.location.data) |             mla.location.setData(settings.location.data) | ||||||
|             mla.rasterLayer.setData(settings.rasterLayer.data) |             mla.rasterLayer.setData(settings.rasterLayer.data) | ||||||
|             mla.allowZooming.setData(false) |             mla.allowZooming.setData(false) | ||||||
|  | @ -70,9 +91,12 @@ export class PngMapCreator { | ||||||
|                 console.log("Waiting for the style to be loaded...") |                 console.log("Waiting for the style to be loaded...") | ||||||
|                 await Utils.waitFor(250) |                 await Utils.waitFor(250) | ||||||
|             } |             } | ||||||
|  | 
 | ||||||
|             // Some extra buffer...
 |             // Some extra buffer...
 | ||||||
|             setState("One second pause to make sure all images are loaded...") |             for (let i = 0; i < 5; i++) { | ||||||
|  |                 setState(5 - i + " seconds pause to make sure all images are loaded...") | ||||||
|                 await Utils.waitFor(1000) |                 await Utils.waitFor(1000) | ||||||
|  |             } | ||||||
|             setState( |             setState( | ||||||
|                 "Exporting png (" + |                 "Exporting png (" + | ||||||
|                     this._options.width + |                     this._options.width + | ||||||
|  | @ -82,7 +106,13 @@ export class PngMapCreator { | ||||||
|                     pixelRatio + |                     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 { |         } finally { | ||||||
|             div.parentElement.removeChild(div) |             div.parentElement.removeChild(div) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -974,6 +974,9 @@ class SvgToPdfPage { | ||||||
|         } |         } | ||||||
| //*/
 | //*/
 | ||||||
|         svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png)) |         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) |         smallestRect.parentElement.insertBefore(svgImage, smallestRect) | ||||||
|         await this.prepareElement(svgImage, [], false) |         await this.prepareElement(svgImage, [], false) | ||||||
|         const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style")) |         const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style")) | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue