Performance: fix PDF export for maps with many items

This commit is contained in:
Pieter Vander Vennet 2023-11-14 16:14:27 +01:00
parent 7ae1a6c00f
commit 03d976c567
6 changed files with 131 additions and 57 deletions

View file

@ -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>{

View file

@ -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">

View file

@ -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)

View file

@ -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 {

View file

@ -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++) {
await Utils.waitFor(1000) setState(5 - i + " seconds pause to make sure all images are loaded...")
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)
} }

View file

@ -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"))