From 27ccce70c0923c931be4d2b7846066510d137f3b Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 14 Sep 2022 12:18:51 +0200 Subject: [PATCH] More work on the flyers --- Models/ThemeConfig/LayoutConfig.ts | 4 +- UI/Input/LocationInput.ts | 59 +-- UI/NewPoint/ConfirmLocationOfPoint.ts | 1 + UI/Popup/MoveWizard.ts | 1 + UI/ShowDataLayer/ShowTileInfo.ts | 5 +- Utils.ts | 12 +- Utils/pngMapCreator.ts | 57 +-- Utils/svgToPdf.ts | 300 ++++++++++------ assets/tagRenderings/questions.json | 2 +- assets/templates/MapComplete-flyer.back.svg | 377 ++++++++++++++++---- assets/templates/MapComplete-flyer.svg | 86 ++--- langs/en.json | 11 +- langs/nl.json | 13 +- package-lock.json | 11 + package.json | 1 + test.ts | 55 ++- 16 files changed, 687 insertions(+), 308 deletions(-) diff --git a/Models/ThemeConfig/LayoutConfig.ts b/Models/ThemeConfig/LayoutConfig.ts index 705d21dfa..70d20ec3b 100644 --- a/Models/ThemeConfig/LayoutConfig.ts +++ b/Models/ThemeConfig/LayoutConfig.ts @@ -22,7 +22,7 @@ export default class LayoutConfig { public readonly startLat: number public readonly startLon: number public widenFactor: number - public readonly defaultBackgroundId?: string + public defaultBackgroundId?: string public layers: LayerConfig[] public tileLayerSources: TilesourceConfig[] public readonly clustering?: { @@ -46,7 +46,7 @@ export default class LayoutConfig { public readonly customCss?: string public readonly overpassUrl: string[] - public readonly overpassTimeout: number + public overpassTimeout: number public readonly overpassMaxZoom: number public readonly osmApiTileSize: number public readonly official: boolean diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index 809f6d77b..11a928b94 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -1,26 +1,27 @@ -import { ReadonlyInputElement } from "./InputElement" +import {ReadonlyInputElement} from "./InputElement" import Loc from "../../Models/Loc" -import { Store, UIEventSource } from "../../Logic/UIEventSource" -import Minimap, { MinimapObj } from "../Base/Minimap" +import {Store, UIEventSource} from "../../Logic/UIEventSource" +import Minimap, {MinimapObj} from "../Base/Minimap" import BaseLayer from "../../Models/BaseLayer" import Combine from "../Base/Combine" import Svg from "../../Svg" -import State from "../../State" -import { GeoOperations } from "../../Logic/GeoOperations" +import {GeoOperations} from "../../Logic/GeoOperations" import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer" import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" -import { BBox } from "../../Logic/BBox" -import { FixedUiElement } from "../Base/FixedUiElement" +import {BBox} from "../../Logic/BBox" +import {FixedUiElement} from "../Base/FixedUiElement" import ShowDataLayer from "../ShowDataLayer/ShowDataLayer" import BaseUIElement from "../BaseUIElement" import Toggle from "./Toggle" import * as matchpoint from "../../assets/layers/matchpoint/matchpoint.json" +import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; +import FilteredLayer from "../../Models/FilteredLayer"; +import {ElementStorage} from "../../Logic/ElementStorage"; export default class LocationInput extends BaseUIElement - implements ReadonlyInputElement, MinimapObj -{ + implements ReadonlyInputElement, MinimapObj { private static readonly matchLayer = new LayerConfig( matchpoint, "LocationInput.matchpoint", @@ -47,6 +48,13 @@ export default class LocationInput private readonly map: BaseUIElement & MinimapObj private readonly clickLocation: UIEventSource private readonly _minZoom: number + private readonly _state: { + readonly filteredLayers: Store; + readonly backgroundLayer: UIEventSource; + readonly layoutToUse: LayoutConfig; + readonly selectedElement: UIEventSource; + readonly allElements: ElementStorage + } constructor(options: { minZoom?: number @@ -57,6 +65,13 @@ export default class LocationInput requiresSnapping?: boolean centerLocation: UIEventSource bounds?: UIEventSource + state: { + readonly filteredLayers: Store; + readonly backgroundLayer: UIEventSource; + readonly layoutToUse: LayoutConfig; + readonly selectedElement: UIEventSource; + readonly allElements: ElementStorage + } }) { super() this._snapTo = options.snapTo?.map((features) => @@ -67,13 +82,14 @@ export default class LocationInput this._snappedPointTags = options.snappedPointTags this._bounds = options.bounds this._minZoom = options.minZoom + this._state = options.state if (this._snapTo === undefined) { this._value = this._centerLocation } else { const self = this if (self._snappedPointTags !== undefined) { - const layout = State.state.layoutToUse + const layout = this._state.layoutToUse let matchingLayer = LocationInput.matchLayer for (const layer of layout.layers) { @@ -129,7 +145,7 @@ export default class LocationInput return { type: "Feature", properties: options.snappedPointTags ?? min.properties, - geometry: { type: "Point", coordinates: [loc.lon, loc.lat] }, + geometry: {type: "Point", coordinates: [loc.lon, loc.lat]}, } } } @@ -149,14 +165,14 @@ export default class LocationInput } }) } - this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer + this.mapBackground = options.mapBackground ?? this._state?.backgroundLayer this.SetClass("block h-full") this.clickLocation = new UIEventSource(undefined) this.map = Minimap.createMiniMap({ location: this._centerLocation, background: this.mapBackground, - attribution: this.mapBackground !== State.state?.backgroundLayer, + attribution: this.mapBackground !== this._state?.backgroundLayer, lastClickLocation: this.clickLocation, bounds: this._bounds, addLayerControl: true, @@ -181,7 +197,7 @@ export default class LocationInput try { const self = this const hasMoved = new UIEventSource(false) - const startLocation = { ...this._centerLocation.data } + const startLocation = {...this._centerLocation.data} this._centerLocation.addCallbackD((newLocation) => { const f = 100000 console.log(newLocation.lon, startLocation.lon) @@ -204,14 +220,14 @@ export default class LocationInput features: StaticFeatureSource.fromDateless(this._snapTo), zoomToFeatures: false, leafletMap: this.map.leafletMap, - layers: State.state.filteredLayers, + layers: this._state.filteredLayers, }) // Show the central point const matchPoint = this._snappedPoint.map((loc) => { if (loc === undefined) { return [] } - return [{ feature: loc }] + return [{feature: loc}] }) console.log("Constructing the match layer", matchPoint) @@ -220,8 +236,8 @@ export default class LocationInput zoomToFeatures: false, leafletMap: this.map.leafletMap, layerToShow: this._matching_layer, - state: State.state, - selectedElement: State.state.selectedElement, + state: this._state, + selectedElement: this._state.selectedElement, }) } this.mapBackground.map( @@ -267,7 +283,10 @@ export default class LocationInput } } - TakeScreenshot(): Promise { - return this.map.TakeScreenshot() + TakeScreenshot(format: "image"): Promise; + TakeScreenshot(format: "blob"): Promise; + TakeScreenshot(format: "image" | "blob"): Promise; + TakeScreenshot(format: "image" | "blob"): Promise { + return this.map.TakeScreenshot(format) } } diff --git a/UI/NewPoint/ConfirmLocationOfPoint.ts b/UI/NewPoint/ConfirmLocationOfPoint.ts index bd47edc90..9e9fdc578 100644 --- a/UI/NewPoint/ConfirmLocationOfPoint.ts +++ b/UI/NewPoint/ConfirmLocationOfPoint.ts @@ -75,6 +75,7 @@ export default class ConfirmLocationOfPoint extends Combine { snappedPointTags: tags, maxSnapDistance: preset.preciseInput.maxSnapDistance, bounds: mapBounds, + state: state }) preciseInput.installBounds(preset.boundsFactor ?? 0.25, true) preciseInput diff --git a/UI/Popup/MoveWizard.ts b/UI/Popup/MoveWizard.ts index b6aec3db7..0c051a0ac 100644 --- a/UI/Popup/MoveWizard.ts +++ b/UI/Popup/MoveWizard.ts @@ -145,6 +145,7 @@ export default class MoveWizard extends Toggle { minZoom: reason.minZoom, centerLocation: loc, mapBackground: new UIEventSource(preferredBackground), // We detach the layer + state: state }) if (reason.lockBounds) { diff --git a/UI/ShowDataLayer/ShowTileInfo.ts b/UI/ShowDataLayer/ShowTileInfo.ts index 60542cf1f..9536fdabf 100644 --- a/UI/ShowDataLayer/ShowTileInfo.ts +++ b/UI/ShowDataLayer/ShowTileInfo.ts @@ -6,7 +6,6 @@ import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeature import { GeoOperations } from "../../Logic/GeoOperations" import { Tiles } from "../../Models/TileRange" import * as clusterstyle from "../../assets/layers/cluster_style/cluster_style.json" -import State from "../../State" export default class ShowTileInfo { public static readonly styling = new LayerConfig(clusterstyle, "ShowTileInfo", true) @@ -16,7 +15,7 @@ export default class ShowTileInfo { leafletMap: UIEventSource layer?: LayerConfig doShowLayer?: UIEventSource - }) { + }, state) { const source = options.source const metaFeature: Store<{ feature; freshness: Date }[]> = source.features.map( (features) => { @@ -56,7 +55,7 @@ export default class ShowTileInfo { features: new StaticFeatureSource(metaFeature), leafletMap: options.leafletMap, doShowLayer: options.doShowLayer, - state: State.state, + state }) } } diff --git a/Utils.ts b/Utils.ts index 2ccbdf90c..2a3901d24 100644 --- a/Utils.ts +++ b/Utils.ts @@ -12,8 +12,8 @@ export class Utils { url: string, headers?: any ) => Promise<{ content: string } | { redirect: string }> - public static Special_visualizations_tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`. -This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature. + public static Special_visualizations_tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`. +This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature. If a value to substitute is undefined, empty string will be used instead. @@ -41,11 +41,11 @@ There are also some technicalities in your theme to keep in mind: This should be used to change the appearance or even to hide it (eg by changing the icon size to zero) 3. There should be a way for the theme to detect previously imported points, even after reloading. A reference number to the original dataset is an excellent way to do this -4. When importing ways, the theme creator is also responsible of avoiding overlapping ways. - +4. When importing ways, the theme creator is also responsible of avoiding overlapping ways. + #### Disabled in unofficial themes -The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md). +The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md). The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console. In the case that MapComplete is pointed to the testing grounds, the edit will be made on https://master.apis.dev.openstreetmap.org` private static knownKeys = [ @@ -823,7 +823,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be } else if (xhr.status === 509 || xhr.status === 429) { reject("rate limited") } else { - reject(xhr.statusText) + reject("Could not download "+url+" due to "+xhr.statusText) } } xhr.open("GET", url) diff --git a/Utils/pngMapCreator.ts b/Utils/pngMapCreator.ts index 791c3c7d6..72d9fca85 100644 --- a/Utils/pngMapCreator.ts +++ b/Utils/pngMapCreator.ts @@ -6,20 +6,19 @@ import ShowDataLayer from "../UI/ShowDataLayer/ShowDataLayer"; import {BBox} from "../Logic/BBox"; import Minimap from "../UI/Base/Minimap"; import AvailableBaseLayers from "../Logic/Actors/AvailableBaseLayers"; -import AvailableBaseLayersImplementation from "../Logic/Actors/AvailableBaseLayersImplementation"; +import {Utils} from "../Utils"; +import {FixedUiElement} from "../UI/Base/FixedUiElement"; + +export interface PngMapCreatorOptions{ + readonly divId: string; readonly width: number; readonly height: number; readonly scaling?: 1 | number, + readonly dummyMode?: boolean +} export class PngMapCreator { - private readonly _state: FeaturePipelineState; - private readonly _options: { - readonly divId: string; readonly width: number; readonly height: number; readonly scaling?: 1 | number - }; + private readonly _state: FeaturePipelineState | undefined; + private readonly _options: PngMapCreatorOptions; - constructor(state: FeaturePipelineState, options: { - readonly divId: string - readonly width: number, - readonly height: number, - readonly scaling?: 1 | number - }) { + constructor(state: FeaturePipelineState | undefined, options: PngMapCreatorOptions) { this._state = state; this._options = {...options, scaling: options.scaling ?? 1}; } @@ -62,23 +61,26 @@ export class PngMapCreator { // Lets first init the minimap and wait for all background tiles to load const minimap = await this.createAndLoadMinimap() const state = this._state - + const freediv = this._options.divId + const dummyMode = this._options.dummyMode ?? false + console.log("Dummy mode is", dummyMode) return new Promise(resolve => { // Next: we prepare the features. Only fully contained features are shown minimap.leafletMap.addCallbackAndRunD(async (leaflet) => { - const bounds = BBox.fromLeafletBounds(leaflet.getBounds().pad(0.1).pad(-state.layoutToUse.widenFactor)) // Ping the featurepipeline to download what is needed - state.currentBounds.setData(bounds) - if(state.featurePipeline.runningQuery.data){ - // A query is running! - // Let's wait for it to complete - console.log("Waiting for the query to complete") - await state.featurePipeline.runningQuery.AsPromise(isRunning => !isRunning) - console.log("Query has completeted!") - } - - window.setTimeout(() => { + if (dummyMode) { + console.warn("Dummy mode is active - not loading map layers") + } else { + const bounds = BBox.fromLeafletBounds(leaflet.getBounds().pad(0.1).pad(-state.layoutToUse.widenFactor)) + state.currentBounds.setData(bounds) + if (state.featurePipeline.runningQuery.data) { + // A query is running! + // Let's wait for it to complete + console.log("Waiting for the query to complete") + await state.featurePipeline.runningQuery.AsPromise(isRunning => !isRunning) + console.log("Query has completeted!") + } state.featurePipeline.GetTilesPerLayerWithin(bounds, (tile) => { @@ -97,9 +99,14 @@ export class PngMapCreator { state: undefined, }) }) - minimap.TakeScreenshot(format).then(result => resolve(result)) - }, 2500) + await Utils.waitFor(2500) + } + minimap.TakeScreenshot(format).then(result => { + new FixedUiElement("Done!").AttachTo(freediv) + return resolve(result); + }) }) + state.AddAllOverlaysToMap(minimap.leafletMap) }) } diff --git a/Utils/svgToPdf.ts b/Utils/svgToPdf.ts index 39ffa7265..150b8517a 100644 --- a/Utils/svgToPdf.ts +++ b/Utils/svgToPdf.ts @@ -4,6 +4,11 @@ import {Translation, TypedTranslation} from "../UI/i18n/Translation"; import FeaturePipelineState from "../Logic/State/FeaturePipelineState"; import {PngMapCreator} from "./pngMapCreator"; import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"; +import {Store, UIEventSource} from "../Logic/UIEventSource"; +import "../assets/templates/Ubuntu-M-normal.js" +import "../assets/templates/Ubuntu-L-normal.js" +import "../assets/templates/UbuntuMono-B-bold.js" +import {parseSVG, makeAbsolute} from 'svg-path-parser'; class SvgToPdfInternals { private readonly doc: jsPDF; @@ -179,7 +184,7 @@ class SvgToPdfInternals { } private extractTranslation(text: string) { - const pathPart = text.match(/\$(([a-zA-Z0-9]+\.)+[a-zA-Z0-9]+)(.*)/) + const pathPart = text.match(/\$(([_a-zA-Z0-9]+\.)+[_a-zA-Z0-9]+)(.*)/) if (pathPart === null) { return text } @@ -315,6 +320,38 @@ class SvgToPdfInternals { } } + private drawPath(element: SVGPathElement): void { + const path = element.getAttribute("d") + const parsed: { code: string, x: number, y: number, x2?, y2?, x1?, y1? }[] = parseSVG(path) + makeAbsolute(parsed) + + for (const c of parsed) { + if (c.code === "C" || c.code === "c") { + const command = {op: "c", c: [c.x1, c.y1, c.x2, c.y2, c.x, c.y]} + this.doc.path([command]) + continue + } + + this.doc.path([{op: c.code.toLowerCase(), c: [c.x, c.y]}]) + } + + + const css = SvgToPdfInternals.css(element) + this.doc.setDrawColor(css["color"]) + this.doc.setFillColor(css["fill"]) + if (css["stroke-width"]) { + this.doc.setLineWidth(Number(css["stroke-width"])) + } + if (css["stroke-linejoin"] !== undefined) { + this.doc.setLineJoin(css["stroke-linejoin"]) + } + if (css["fill-rule"] === "evenodd") { + this.doc.fillEvenOdd() + } else { + this.doc.fill() + } + } + public handleElement(element: SVGSVGElement | Element): void { const isTransformed = this.setTransform(element) if (element.tagName === "tspan") { @@ -331,6 +368,10 @@ class SvgToPdfInternals { this.drawImage(element) } + if (element.tagName === "path") { + this.drawPath(element) + } + if (element.tagName === "g" || element.tagName === "text") { for (let child of Array.from(element.children)) { @@ -371,6 +412,13 @@ class SvgToPdfInternals { } } +export interface SvgToPdfOptions { + getFreeDiv: () => string, + disableMaps?: false | true + textSubstitutions?: Record, beforePage?: (i: number) => void + +} + export class SvgToPdf { private images: Record = {} @@ -379,18 +427,20 @@ export class SvgToPdf { private readonly _textSubstitutions: Record; private readonly _beforePage: ((i: number) => void) | undefined; public readonly _usedTranslations: Set = new Set() - private readonly _freeDivId: string | undefined; + private readonly _freeDivId: () => string; + private readonly _currentState = new UIEventSource("Initing") + public readonly currentState: Store + private readonly _disableMaps: boolean ; - constructor(pages: string[], options?: { - freeDivId?: string, - textSubstitutions?: Record, beforePage?: (i: number) => void - }) { + constructor(pages: string[], options?:SvgToPdfOptions) { + this.currentState = this._currentState this._textSubstitutions = options?.textSubstitutions ?? {}; this._beforePage = options?.beforePage; - this._freeDivId = options?.freeDivId + this._freeDivId = options?.getFreeDiv + this._disableMaps = options.disableMaps ?? false const parser = new DOMParser(); for (const page of pages) { - const xmlDoc = parser.parseFromString(page, "text/xml"); + const xmlDoc = parser.parseFromString(page, "image/svg+xml"); const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; this._svgRoots.push(svgRoot) } @@ -423,6 +473,7 @@ export class SvgToPdf { } this.images[xlink] = img + this.setState("Preparing: loading image " + Object.keys(this.images).length + ": " + img.src.substring(0, 30)) return new Promise((resolve) => { img.onload = _ => { resolve() @@ -466,11 +517,135 @@ export class SvgToPdf { private _isPrepared = false; + private setState(message: string) { + this._currentState.setData(message) + } + + private async prepareMap(mapSpec: SVGTSpanElement,): Promise { + // Upper left point of the tspan + const {x, y} = SvgToPdfInternals.GetActualXY(mapSpec) + + let textElement: Element = mapSpec + // We recurse up to get the actual, full specification + while (textElement.tagName !== "text") { + textElement = textElement.parentElement + } + const spec = textElement.textContent + const match = spec.match(/\$map\(([^)]+)\)$/) + if (match === null) { + throw "Invalid mapspec:" + spec + } + const params = SvgToPdfInternals.parseCss(match[1], ",") + const ctx = `Preparing map (theme ${params["theme"]})` + this.setState(ctx + "...") + + let smallestRect: SVGRectElement = undefined + let smallestSurface: number = undefined; + // We iterate over all the rectangles and pick the smallest (by surface area) that contains the upper left point of the tspan + for (const id in this.rects) { + const rect = this.rects[id] + const rx = SvgToPdfInternals.attrNumber(rect, "x") + const ry = SvgToPdfInternals.attrNumber(rect, "y") + const w = SvgToPdfInternals.attrNumber(rect, "width") + const h = SvgToPdfInternals.attrNumber(rect, "height") + const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h + if (!inBounds) { + continue + } + const surface = w * h + if (smallestSurface === undefined || smallestSurface > surface) { + smallestSurface = surface + smallestRect = rect + } + + } + + if (smallestRect === undefined) { + throw "No rectangle found around " + spec + ". Draw a rectangle around it, the map will be projected on that one" + } + + const svgImage = document.createElement('image') + svgImage.setAttribute("x", smallestRect.getAttribute("x")) + svgImage.setAttribute("y", smallestRect.getAttribute("y")) + const width = SvgToPdfInternals.attrNumber(smallestRect, "width") + const height = SvgToPdfInternals.attrNumber(smallestRect, "height") + svgImage.setAttribute("width", "" + width) + svgImage.setAttribute("height", "" + height) + + let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"]) + if (layout === undefined) { + console.error("Could not show map with parameters", params) + throw "Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. " + } + layout.widenFactor = 0 + layout.overpassTimeout = 180 + layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId + const zoom = Number(params["zoom"] ?? params["z"] ?? 14); + + const state = new FeaturePipelineState(layout) + state.locationControl.setData({ + zoom, + lat: Number(params["lat"] ?? 51.05016), + lon: Number(params["lon"] ?? 3.717842) + }) + + const fl = state.filteredLayers.data + for (const filteredLayer of fl) { + if (params["layers"] === "none") { + filteredLayer.isDisplayed.setData(false) + } else if (filteredLayer.layerDef.id.startsWith("note_import")) { + filteredLayer.isDisplayed.setData(false) + } + } + + for (const paramsKey in params) { + if (paramsKey.startsWith("layer-")) { + const layerName = paramsKey.substring("layer-".length) + const key = params[paramsKey].toLowerCase().trim() + const isDisplayed = key === "true" || key === "force"; + const layer = state.filteredLayers.data.find(l => l.layerDef.id === layerName) + layer.isDisplayed.setData( + isDisplayed + ) + if (key === "force") { + layer.layerDef.minzoom = zoom + } + } + } + + this.setState(ctx + ": loading map data...") + const pngCreator = new PngMapCreator( + state, + { + width, + height, + scaling: Number(params["scaling"] ?? 1.5), + divId: this._freeDivId(), + dummyMode : this._disableMaps + } + ) + this.setState(ctx + ": rendering png") + const png = await pngCreator.CreatePng("image") + + svgImage.setAttribute('xlink:href', png) + smallestRect.parentElement.insertBefore(svgImage, smallestRect) + await this.prepareElement(svgImage, []) + + + const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style")) + smallestRectCss["fill-opacity"] = "0" + smallestRect.setAttribute("style", Object.keys(smallestRectCss).map(k => k + ":" + smallestRectCss[k]).join(";")) + + + textElement.parentElement.removeChild(textElement) + } + public async Prepare() { if (this._isPrepared) { return } this._isPrepared = true; + this.setState("Preparing...") const mapSpecs: SVGTSpanElement[] = [] for (const svgRoot of this._svgRoots) { for (let child of Array.from(svgRoot.children)) { @@ -478,114 +653,16 @@ export class SvgToPdf { } } - for (const mapSpec of mapSpecs) { - // Upper left point of the tspan - const {x, y} = SvgToPdfInternals.GetActualXY(mapSpec) + const self = this; + await Promise.all(mapSpecs.map(ms => self.prepareMap(ms))) - let textElement: Element = mapSpec - // We recurse up to get the actual, full specification - while (textElement.tagName !== "text") { - textElement = textElement.parentElement - } - const spec = textElement.textContent - const match = spec.match(/\$map\(([^)]+)\)$/) - if (match === null) { - throw "Invalid mapspec:" + spec - } - const params = SvgToPdfInternals.parseCss(match[1], ",") - let smallestRect: SVGRectElement = undefined - let smallestSurface: number = undefined; - // We iterate over all the rectangles and pick the smallest (by surface area) that contains the upper left point of the tspan - for (const id in this.rects) { - const rect = this.rects[id] - const rx = SvgToPdfInternals.attrNumber(rect, "x") - const ry = SvgToPdfInternals.attrNumber(rect, "y") - const w = SvgToPdfInternals.attrNumber(rect, "width") - const h = SvgToPdfInternals.attrNumber(rect, "height") - const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h - if (!inBounds) { - continue - } - const surface = w * h - if (smallestSurface === undefined || smallestSurface > surface) { - smallestSurface = surface - smallestRect = rect - } - - } - - if (smallestRect === undefined) { - throw "No rectangle found around " + spec + ". Draw a rectangle around it, the map will be projected on that one" - } - - const svgImage = document.createElement('image') - svgImage.setAttribute("x", smallestRect.getAttribute("x")) - svgImage.setAttribute("y", smallestRect.getAttribute("y")) - const width = SvgToPdfInternals.attrNumber(smallestRect, "width") - const height = SvgToPdfInternals.attrNumber(smallestRect, "height") - svgImage.setAttribute("width", "" + width) - svgImage.setAttribute("height", "" + height) - - let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"]) - if (layout === undefined) { - console.error("Could not show map with parameters", params) - throw "Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. " - } - layout.widenFactor = 0 - const zoom = Number(params["zoom"] ?? params["z"] ?? 14); - for (const l of layout.layers) { - l.minzoom = zoom - } - const state = new FeaturePipelineState(layout) - state.backgroundLayer.addCallbackAndRunD(l => console.log("baselayer is", l.id)) - state.locationControl.setData({ - zoom, - lat: Number(params["lat"] ?? 51.05016), - lon: Number(params["lon"] ?? 3.717842) - }) - - const fl = state.filteredLayers.data - for (const filteredLayer of fl) { - if (params["layers"] === "none") { - filteredLayer.isDisplayed.setData(false) - }else if(filteredLayer.layerDef.id.startsWith("note_import")){ - filteredLayer.isDisplayed.setData(false) - } - } - - for (const paramsKey in params) { - if (paramsKey.startsWith("layer-")) { - const layerName = paramsKey.substring("layer-".length) - const isDisplayed = params[paramsKey].toLowerCase().trim() === "true"; - console.log("Setting display status of ", layerName, "to", isDisplayed) - state.filteredLayers.data.find(l => l.layerDef.id === layerName).isDisplayed.setData( - isDisplayed - ) - } - } - - const pngCreator = new PngMapCreator( - state, - { - width, - height, - scaling: Number(params["scaling"] ?? 1.5), - divId: this._freeDivId - } - ) - const png = await pngCreator.CreatePng("image") - - svgImage.setAttribute('xlink:href', png) - smallestRect.parentElement.insertBefore(svgImage, smallestRect) - await this.prepareElement(svgImage, []) - smallestRect.setAttribute("style", "fill:#ff00ff00;fill-opacity:0;stroke:#000000;stroke-width:0.202542;stroke-linecap:round;stroke-opacity:1") - textElement.parentElement.removeChild(textElement) - } } public async ConvertSvg(saveAs: string): Promise { await this.Prepare() + const ctx = "Rendering PDF" + this.setState(ctx + "...") const firstPage = this._svgRoots[0] const width = SvgToPdfInternals.attrNumber(firstPage, "width") const height = SvgToPdfInternals.attrNumber(firstPage, "height") @@ -598,6 +675,7 @@ export class SvgToPdf { doc.advancedAPI(advancedApi => { const internal = new SvgToPdfInternals(advancedApi, this._textSubstitutions, this.images, this.rects); for (let i = 0; i < this._svgRoots.length; i++) { + this.setState(ctx + ": page " + i + "/" + this._svgRoots.length) beforePage(i) const svgRoot = svgRoots[i]; for (let child of Array.from(svgRoot.children)) { @@ -608,7 +686,9 @@ export class SvgToPdf { } } }) + this.setState("Serving PDF...") await doc.save(saveAs); + this.setState("Done") } diff --git a/assets/tagRenderings/questions.json b/assets/tagRenderings/questions.json index e17f98083..c02bdaeb1 100644 --- a/assets/tagRenderings/questions.json +++ b/assets/tagRenderings/questions.json @@ -21,7 +21,7 @@ "render": "{export_as_geojson()}" }, "wikipedia": { - "description": "Shows a wikipedia box with the corresponding wikipedia article", + "description": "Shows a wikipedia box with the corresponding wikipedia article; the wikidata-item link can be changed by a contributor", "render": "{wikipedia():max-height:25rem}", "question": { "en": "What is the corresponding Wikidata entity?", diff --git a/assets/templates/MapComplete-flyer.back.svg b/assets/templates/MapComplete-flyer.back.svg index 1ca4481b6..0bbb7c9ce 100644 --- a/assets/templates/MapComplete-flyer.back.svg +++ b/assets/templates/MapComplete-flyer.back.svg @@ -26,9 +26,9 @@ showgrid="false" showguides="true" inkscape:guide-bbox="true" - inkscape:zoom="4.8910968" - inkscape:cx="1076.7523" - inkscape:cy="658.9524" + inkscape:zoom="1.0169528" + inkscape:cx="997.58814" + inkscape:cy="279.26568" inkscape:window-width="1920" inkscape:window-height="1007" inkscape:window-x="0" @@ -414,18 +414,18 @@ id="rect11461" width="83.939919" height="54.557529" - x="124.66862" - y="9.8645983" /> + x="122.85212" + y="4.482904" /> $map(theme:aed,z:14,lat:51.2098,lon:3.2284) + id="tspan24896">$map(theme:aed,z:14,lat:51.2098,lon:3.2284) $flyer.toerisme_vlaanderen + id="tspan24900">$flyer.toerisme_vlaanderen $map(theme:toerisme_vlaanderen,layer-$map(theme:toerisme_vlaanderen,layer-bench:false,layers:none, layer-bench:false,layers:none, layer-charging_station_ebikes:force,lat:51.02403,lon:charging_station_ebikes:force,lat:51.02403,lon:4.1, z:9) + id="tspan24916">4.1, z:9) + width="93.812988" + height="73.760445" + x="101.18607" + y="57.089542" /> $map(theme:cyclofix,z:14,lat:$map(theme:cyclofix,z:14,lat:51.05016,lon:51.05016,lon:3.717842,layers:none,layer-3.717842,layers:none,layer-bike_repair_station:true,layer-bike_repair_station:true,layer-drinking_water:true,layer-bike_cafe:true,layer-drinking_water:true,layer-bike_cafe:true,layer-bicycle_tube_vending_machine: true) + id="tspan24938">bicycle_tube_vending_machine: true) + x="213.14513" + y="129.44548" /> + x="203.58293" + y="79.200089" /> $map(theme:artwork,z:15,lat:51.2098,lon:$map(theme:artwork,z:15,lat:51.2098,lon:3.2284,background:AGIV) + id="tspan24946">3.2284,background:AGIV) + x="214.65353" + y="31.252468" /> $map(theme:cyclestreets,z:12,lat:51.2098,lon:$map(theme:cyclestreets,z:12,lat:51.2098,lon:3.2284) - + id="tspan24954">3.2284) @@ -594,29 +585,29 @@ $map(theme:benches,z:14,lat:51.2098,lon:$map(theme:benches,z:14,lat:51.2098,lon:3.2284, layers:none, layer-bench:force) + id="tspan24962">3.2284, layers:none, layer-bench:force) $flyer.aerial + id="tspan24966">$flyer.aerial $flyer.examples + id="tspan24970">$flyer.examples + id="path15616" + transform="matrix(-1,0,0,1,497.66957,-0.86523396)"> + $flyer.title + id="tspan24974">$flyer.title $flyer.frontParagraph + id="tspan39533">$flyer.frontParagraph $flyer.tagline + id="tspan39537">$flyer.tagline $flyer.title + id="tspan39541">$flyer.title $flyer.whatIsOsm + id="tspan39543">$flyer.whatIsOsm $flyer.mapcomplete.title + id="tspan39547">$flyer.mapcomplete.title $flyer.mapcomplete.intro + id="tspan39551">$flyer.mapcomplete.intro + id="tspan39555"> $list(flyer.mapcomplete.li) + id="tspan39559">$list(flyer.mapcomplete.li) + id="tspan39563"> + id="tspan39567"> + id="tspan39571"> + id="tspan39575"> + id="tspan39579"> + id="tspan39583"> $flyer.mapcomplete.customize + id="tspan39587"> +$flyer.mapcomplete.customize $flyer.callToAction$flyer.callToAction + id="tspan39597"> + id="tspan39601"> $flyer.osm + id="tspan39605">$flyer.osm diff --git a/langs/en.json b/langs/en.json index cd0fb8ecc..d2ea0926b 100644 --- a/langs/en.json +++ b/langs/en.json @@ -40,13 +40,12 @@ "reload": "Reload the data" }, "flyer": { + "aerial": "This map uses a different background, namely aerial imagery by Agentschap Informatie Vlaanderen", "callToAction": "Test it on mapcomplete.osm.be", + "examples": "There are many thematic maps available of which a few are printed here, namely the maps with AED's, artwork, cyclestreets, benches and cyclepumps.\n\nThere are many more thematic maps online: about healthcare, indoor navigation, wheelchair accessibility, waste facilities, public bookcases, pedestrian crossings with a rainbow-painting,... Discover them all on mapcomplete.osm.be ", "frontParagraph": "MapComplete is an easy to use web application to collect geodata in OpenStreetMap, enabling collecting and managing relevant data in an open, crowdsourced and reusable way.\n\nNew categories and attributes can be added upon request.", - "license": { - "text": "The webversion is free to use, both for viewing and adding data.\nAdding data requires a free account on OpenStreetMap.org.\n\n MapComplete can tailored to your needs, with new map layers, new functionalities or styled to your organisation styleguide. We also have experience with starting campaigns to crowdsource geodata.\nContact pietervdvn@posteo.net for a quote.\n\nMapComplete is fully Open Source (GPL-licenses).\n\nData on OpenStreetMap is under the ODbL-license, which means all data can be reused for all purposes, as long as attribution is given and all (improvements to) the data are republished under the same license.\nSee osm.org/copyright for more details", - "title": "License and pricing" - }, "mapcomplete": { + "customize": "MapComplete can tailored to your needs, with new map layers, new functionalities or styled to your organisation styleguide. We also have experience with starting campaigns to crowdsource geodata.\nContact pietervdvn@posteo.net for a quote.", "intro": "MapComplete is a website which has {mapCount} interactive maps. Every single map allows to add or update information.", "li0": "Communicate where POI are", "li1": "Add new points and update information on existing points", @@ -56,11 +55,13 @@ "li5": "See aerial imagery and map backgrounds", "li6": "Can be placed in other websites as iFrame", "li7": "Embedded within the OpenStreetMap-ecosystem, which has many tools available", + "li8": "Fully open source (GPL) and free to use", "title": "What is MapComplete?" }, - "osm": "OpenStreetMap is an online map which can be edited and reused by anyone for any purpose - just like Wikipedia.\n\nIt is the biggest geospatial database in the world and is reused by thousands of applications and websites.", + "osm": "OpenStreetMap is an online map which can be edited and reused by anyone for any purpose as long as attribution is given and the data is kept open.\n\nIt is the biggest geospatial database in the world and is reused by thousands of applications and websites.", "tagline": "Collect geodata easily with OpenStreetMap", "title": "MapComplete.osm.be", + "toerisme_vlaanderen": "For joint project with Toerism Flanders, 'Pin your point' was created. Over 160 contributors added a few thousand benches and picnictables and spotted 100 charging station for bicycles.", "whatIsOsm": "What is OpenStreetMap?" }, "general": { diff --git a/langs/nl.json b/langs/nl.json index d2b0d55dc..9933e142f 100644 --- a/langs/nl.json +++ b/langs/nl.json @@ -40,13 +40,12 @@ "reload": "Herlaad de data" }, "flyer": { + "aerial": "Deze kaart gebruikt luchtfoto's van het Agentschap Informatie Vlaanderen als achtergrond.\nOok het GRB is beschikbaar als achtergrondlaag.", "callToAction": "Probeer het uit op mapcomplete.osm.be", + "examples": "Er zijn vele thematische kaarten beschikbaar. Enkele voorbeelden zijn hier geprint, zoals de kaart met AEDs, kunstwerken, fietsstraten, banken en fietspompen.\n\nOnline zijn er nog kaarten met diverse thema's, zoals gezondheidszorg, binnenruimtes, rolstoeltoegankelijkheid, afvalcontainers, boekenruilkasten, regenboog-zebrapaden,... Ontdek ze allemaal mapcomplete.osm.be ", "frontParagraph": "MapComplete is een web-applicatie om OpenStreetMap-data te tonen en aan te passen op basis van thematische kaarten. Het maakt het mogelijk om open geodata te crowdsourcen en te managen op een makkelijke manier.\n\nNieuwe categorie�n en attributen kunnen op vraag worden toegevoegd.", - "license": { - "text": "De webversie is gratis te gebruiken, zowel voor het bekijken als voor het toevoegen van data.\nVoor het toevoegen van data is een gratis account op OpenStreetMap.org vereist.\n\nWil je een versie op maat? Wil je een versie in jullie huisstijl?\nWil je een nieuwe kaartlaag of functionaliteit? Wil je een crowdsourcing-campagne opzetten?\nNeem contact op met pietervdvn@posteo.net voor een offerte.\n\nMapComplete is volledig OpenSource (GPL-licentie).\n\nData op OpenStreetMap valt onder de ODbL-licentie. Data mag herbruikt worden voor alle doeleinden, mits bronvermelding en het openhouden van (verbeteringen aan) de data.\nZie osm.org/copyright voor alle details.", - "title": "Licentie and kostprijs" - }, "mapcomplete": { + "customize": "Wil je een versie op maat? Wil je een versie in jullie huisstijl?\nWil je een nieuwe kaartlaag of functionaliteit? Wil je een crowdsourcing-campagne opzetten?\nNeem contact op met pietervdvn@posteo.net voor een offerte.", "intro": "MapComplete is een website met {mapCount} interactieve kaarten. Op iedere kaart kunnen gebruikers data zien en updaten.", "li0": "Communiceer waar interessepunten zijn", "li1": "Voeg nieuwe punten toe en update informatie van reeds bestaande punten", @@ -56,11 +55,13 @@ "li5": "Wissel tussen kaart- en luchtfoto's als achtergrond", "li6": "Eenvoudig te embedden in een website als iFrame", "li7": "Deel van het OpenStreetMap-ecosysteem waarbinnen honderden andere tools bestaan", + "li8": "Volledig Open-Source (GPL) en gratis te gebruiken", "title": "Wat is MapComplete?" }, - "osm": "OpenStreetMap is een online kaart die door iedereen aangepast en herbruikt mag worden - net zoals Wikipedia.\n\nHet is de grootste geodatabank ter wereld en wordt herbruikt door miljoenen websites en applicaties.", - "tagline": "Verzamel geodata eenvoudig met OpenStreetMap", + "osm": "OpenStreetMap is een online kaart die door iedereen aangepast en herbruikt mag worden - mits bronvermelding en het openhouden van de data.\n\nHet is de grootste geodatabank ter wereld en wordt herbruikt door miljoenen websites en applicaties.", + "tagline": "Eenvoudig geodata verzamelen met OpenStreetMap", "title": "MapComplete.osm.be", + "toerisme_vlaanderen": "In samenwerking met Toerisme Vlaanderen werd 'Pin Je Punt' gecre�erd. Op enkele maanden tijd werden duizenden zitbanken en picnictafels en meer dan honderd oplaadpunten voor elektrische fietsen toegevoegd aan de kaart door meer dan 160 bijdragers.", "whatIsOsm": "Wat is OpenStreetMap?" }, "general": { diff --git a/package-lock.json b/package-lock.json index d7ef7ac8c..71aeedd3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "osmtogeojson": "^3.0.0-beta.4", "papaparse": "^5.3.1", "prompt-sync": "^4.2.0", + "svg-path-parser": "^1.1.0", "tailwindcss": "^3.1.8", "togpx": "^0.5.4", "wikibase-sdk": "^7.14.0", @@ -14430,6 +14431,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-path-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", + "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==" + }, "node_modules/svg-pathdata": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-5.0.5.tgz", @@ -28146,6 +28152,11 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, + "svg-path-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", + "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==" + }, "svg-pathdata": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-5.0.5.tgz", diff --git a/package.json b/package.json index 6b96b713a..b1492106b 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "osmtogeojson": "^3.0.0-beta.4", "papaparse": "^5.3.1", "prompt-sync": "^4.2.0", + "svg-path-parser": "^1.1.0", "tailwindcss": "^3.1.8", "togpx": "^0.5.4", "wikibase-sdk": "^7.14.0", diff --git a/test.ts b/test.ts index e3b2c042a..84add4451 100644 --- a/test.ts +++ b/test.ts @@ -1,36 +1,55 @@ -import "./assets/templates/Ubuntu-M-normal.js" -import "./assets/templates/Ubuntu-L-normal.js" -import "./assets/templates/UbuntuMono-B-bold.js" -import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; import MinimapImplementation from "./UI/Base/MinimapImplementation"; -import {Utils} from "./Utils"; -import FeaturePipelineState from "./Logic/State/FeaturePipelineState"; -import Locale from "./UI/i18n/Locale"; -import {SvgToPdf} from "./Utils/svgToPdf"; MinimapImplementation.initialize() +import {Utils} from "./Utils"; +import {SvgToPdf, SvgToPdfOptions} from "./Utils/svgToPdf"; +import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; +import Locale from "./UI/i18n/Locale"; + +let i = 0 + +function createElement(): string { + const div = document.createElement("div") + div.id = "freediv-" + (i++) + document.getElementById("extradiv").append(div) + return div.id +} + async function main() { + const svg = await Utils.download(window.location.protocol + "//" + window.location.host + "/assets/templates/MapComplete-flyer.svg") const svgBack = await Utils.download(window.location.protocol + "//" + window.location.host + "/assets/templates/MapComplete-flyer.back.svg") - Locale.language.setData("en") - /* - await new SvgToPdf([svg], { - state, - textSubstitutions: { - mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length - } - }).ConvertSvg("flyer_en.pdf") - //*/ + const options = { + getFreeDiv: createElement, + textSubstitutions: { + mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length + }, + disableMaps: false + } + Locale.language.setData("nl") + const back = new SvgToPdf([svgBack], options) + const front = await new SvgToPdf([svg], options) + await back.ConvertSvg("Flyer-back-nl.pdf") + await front.ConvertSvg("Flyer-front-nl.pdf") + Locale.language.setData("en") + await back.ConvertSvg("Flyer-back-en.pdf") + await front.ConvertSvg("Flyer-front-en.pdf") + + + /* + const svg = await Utils.download(window.location.protocol + "//" + window.location.host + "/assets/templates/MapComplete-flyer.svg") + Locale.language.setData("en") const svgToPdf = new SvgToPdf([svgBack], { - freeDivId: "extradiv", + getFreeDiv: createElement, textSubstitutions: { mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length } }) + new VariableUiElement(svgToPdf.currentState).AttachTo("maindiv") await svgToPdf.Prepare() console.log("Used translations", svgToPdf._usedTranslations) await svgToPdf.ConvertSvg("flyer_nl.pdf")