From 308ab74a08d1fae3a010c6b363fe9910f7381fe4 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Fri, 18 Mar 2022 01:21:00 +0100 Subject: [PATCH] Add download-as-svg options --- UI/BigComponents/DownloadPanel.ts | 172 ++++++++++++++++--- Utils.ts | 89 ++++++---- assets/layers/shops/shops.json | 2 +- assets/themes/buurtnatuur/buurtnatuur.json | 4 +- assets/themes/campersite/campersite.json | 4 +- assets/themes/uk_addresses/uk_addresses.json | 23 ++- langs/en.json | 2 + package-lock.json | 27 +++ package.json | 1 + 9 files changed, 266 insertions(+), 58 deletions(-) diff --git a/UI/BigComponents/DownloadPanel.ts b/UI/BigComponents/DownloadPanel.ts index aeefbd640..9601def11 100644 --- a/UI/BigComponents/DownloadPanel.ts +++ b/UI/BigComponents/DownloadPanel.ts @@ -14,6 +14,9 @@ import SimpleMetaTagger from "../../Logic/SimpleMetaTagger"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import {BBox} from "../../Logic/BBox"; import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; +import geojson2svg from "geojson2svg" +import Constants from "../../Models/Constants"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; export class DownloadPanel extends Toggle { @@ -21,9 +24,9 @@ export class DownloadPanel extends Toggle { filteredLayers: UIEventSource featurePipeline: FeaturePipeline, layoutToUse: LayoutConfig, - currentBounds: UIEventSource - }) { + currentBounds: UIEventSource, + }) { const t = Translations.t.general.download const name = State.state.layoutToUse.id; @@ -35,7 +38,7 @@ export class DownloadPanel extends Toggle { const buttonGeoJson = new SubtleButton(Svg.floppy_ui(), new Combine([t.downloadGeojson.SetClass("font-bold"), t.downloadGeoJsonHelper]).SetClass("flex flex-col")) - .OnClickWithLoading(t.exporting,async () => { + .OnClickWithLoading(t.exporting, async () => { const geojson = DownloadPanel.getCleanGeoJson(state, metaisIncluded.data) Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), `MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.geojson`, { @@ -57,10 +60,31 @@ export class DownloadPanel extends Toggle { }); }) + const buttonSvg = new SubtleButton(Svg.floppy_ui(), new Combine( + [t.downloadAsSvg.SetClass("font-bold"), + t.downloadAsSvgHelper]).SetClass("flex flex-col")) + .OnClickWithLoading(t.exporting, async () => { + const geojson = DownloadPanel.getCleanGeoJsonPerLayer(state, metaisIncluded.data) + const leafletdiv = document.getElementById("leafletDiv") + const csv = DownloadPanel.asSvg(geojson, + { + layers: state.filteredLayers.data.map(l => l.layerDef), + mapExtent: state.currentBounds.data, + width: leafletdiv.offsetWidth, + height: leafletdiv.offsetHeight + }) + + Utils.offerContentsAsDownloadableFile(csv, + `MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.svg`, { + mimetype: "image/svg+xml" + }); + }) + const downloadButtons = new Combine( [new Title(t.title), buttonGeoJson, buttonCSV, + buttonSvg, includeMetaToggle, t.licenseInfo.SetClass("link-underline")]) .SetClass("w-full flex flex-col border-4 border-gray-300 rounded-3xl p-4") @@ -71,41 +95,152 @@ export class DownloadPanel extends Toggle { state.featurePipeline.somethingLoaded) } + /** + * Converts a geojson to an SVG + * + * const feature = { + * "type": "Feature", + * "properties": {}, + * "geometry": { + * "type": "LineString", + * "coordinates": [ + * [-180, 80], + * [180, -80] + * ] + * } + * } + * const perLayer = new Map([["testlayer", [feature]]]) + * DownloadPanel.asSvg(perLayer).replace(/\n/g, "") // => ` ` + */ + public static asSvg(perLayer: Map, + options?: + { + layers?: LayerConfig[], + width?: 1000 | number, + height?: 1000 | number, + mapExtent?: BBox + unit?: "px" | "mm" | string + }) { + options = options ?? {} + const w = options.width ?? 1000 + const h = options.height ?? 1000 + const unit = options.unit ?? "px" + const mapExtent = {left: -180, bottom: -90, right: 180, top: 90} + if (options.mapExtent !== undefined) { + const bbox = options.mapExtent + mapExtent.left = bbox.minLon + mapExtent.right = bbox.maxLon + mapExtent.bottom = bbox.minLat + mapExtent.top = bbox.maxLat + } + + const elements: string [] = [] + + for (const layer of Array.from(perLayer.keys())) { + const features = perLayer.get(layer) + if(features.length === 0){ + continue + } + + const layerDef = options?.layers?.find(l => l.id === layer) + const rendering = layerDef?.lineRendering[0] + + const converter = geojson2svg({ + viewportSize: {width: w, height: h}, + mapExtent, + output: 'svg', + attributes:[ + { + property: "style", + type:'static', + value: "fill:none;stroke-width:1" + }, + { + property: 'properties.stroke', + type:'dynamic', + key: 'stroke' + } + ] + + }); + + for (const feature of features) { + const stroke = rendering?.color?.GetRenderValue(feature.properties)?.txt ?? "#ff0000" + const color = Utils.colorAsHex( Utils.color(stroke)) + feature.properties.stroke = color + } + + + const groupPaths: string[] = converter.convert({type: "FeatureCollection", features}) + const group = ` \n` + + groupPaths.map(p => " " + p).join("\n") + + "\n " + elements.push(group) + } + + + const header = `` + return header + "\n" + elements.join("\n") + "\n" + } + + /** + * Gets all geojson as geojson feature + * @param state + * @param includeMetaData + * @private + */ private static getCleanGeoJson(state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource, filteredLayers: UIEventSource }, includeMetaData: boolean) { + const perLayer = DownloadPanel.getCleanGeoJsonPerLayer(state, includeMetaData) + const features = [].concat(...Array.from(perLayer.values())) + return { + type: "FeatureCollection", + features + } + } - const resultFeatures = [] + private static getCleanGeoJsonPerLayer(state: { + featurePipeline: FeaturePipeline, + currentBounds: UIEventSource, + filteredLayers: UIEventSource + }, includeMetaData: boolean): Map /*{layerId --> geojsonFeatures[]}*/ { + + const perLayer = new Map(); const neededLayers = state.filteredLayers.data.map(l => l.layerDef.id) const bbox = state.currentBounds.data const featureList = state.featurePipeline.GetAllFeaturesAndMetaWithin(bbox, new Set(neededLayers)); - for (const tile of featureList) { + outer : for (const tile of featureList) { + + if(Constants.priviliged_layers.indexOf(tile.layer) >= 0){ + continue + } + const layer = state.filteredLayers.data.find(fl => fl.layerDef.id === tile.layer) + if (!perLayer.has(tile.layer)) { + perLayer.set(tile.layer, []) + } + const featureList = perLayer.get(tile.layer) const filters = layer.appliedFilters.data for (const feature of tile.features) { - - if(!bbox.overlapsWith(BBox.get(feature))){ + + if (!bbox.overlapsWith(BBox.get(feature))) { continue } - + if (filters !== undefined) { - let featureDoesMatchAllFilters = true; for (let key of Array.from(filters.keys())) { const filter: FilterState = filters.get(key) - if(filter?.currentFilter === undefined){ + if (filter?.currentFilter === undefined) { continue } if (!filter.currentFilter.matchesProperties(feature.properties)) { - featureDoesMatchAllFilters = false; - break + continue outer; } } - if(!featureDoesMatchAllFilters){ - continue; // the outer loop - } } const cleaned = { @@ -130,14 +265,11 @@ export class DownloadPanel extends Toggle { delete feature.properties[key] } - resultFeatures.push(feature) + featureList.push(feature) } } - return { - type: "FeatureCollection", - features: resultFeatures - } + return perLayer } } \ No newline at end of file diff --git a/Utils.ts b/Utils.ts index ee9f04497..29d9e9f3e 100644 --- a/Utils.ts +++ b/Utils.ts @@ -111,7 +111,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be /** * Converts a number to a string with one number after the comma - * + * * Utils.Round(15) // => "15.0" * Utils.Round(1) // => "1.0" * Utils.Round(1.5) // => "1.5" @@ -310,17 +310,17 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be /** * Copies all key-value pairs of the source into the target. This will change the target * If the key starts with a '+', the values of the list will be appended to the target instead of overwritten - * + * * const obj = {someValue: 42}; * const override = {someValue: null}; * Utils.Merge(override, obj); * obj.someValue // => null - * + * * const obj = {someValue: 42}; * const override = {someValue: null}; * const returned = Utils.Merge(override, obj); * returned == obj // => true - * + * * const source = { * abc: "def", * foo: "bar", @@ -347,7 +347,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be */ static Merge(source: S, target: T): (T & S) { if (target === null) { - return source + return source } for (const key in source) { @@ -457,9 +457,9 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return collectedList } if (Array.isArray(leaf)) { - for (let i = 0; i < (leaf).length; i++){ + for (let i = 0; i < (leaf).length; i++) { const l = (leaf)[i]; - collectedList.push({leaf: l, path: [...travelledPath, ""+i]}) + collectedList.push({leaf: l, path: [...travelledPath, "" + i]}) } } else { collectedList.push({leaf, path: travelledPath}) @@ -489,15 +489,15 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return f(undefined) } const jtp = typeof json - if(isLeaf !== undefined) { - if(jtp === "object"){ - if(isLeaf(json)){ + if (isLeaf !== undefined) { + if (jtp === "object") { + if (isLeaf(json)) { return f(json) } } else { return json } - }else if (jtp === "boolean" || jtp === "string" || jtp === "number") { + } else if (jtp === "boolean" || jtp === "string" || jtp === "number") { return f(json) } if (Array.isArray(json)) { @@ -661,7 +661,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be * Triggers a 'download file' popup which will download the contents */ public static offerContentsAsDownloadableFile(contents: string | Blob, fileName: string = "download.txt", - options?: { mimetype: string | "text/plain" | "text/csv" | "application/vnd.geo+json" | "{gpx=application/gpx+xml}"}) { + options?: { mimetype: string | "text/plain" | "text/csv" | "application/vnd.geo+json" | "{gpx=application/gpx+xml}" }) { const element = document.createElement("a"); let file; if (typeof (contents) === "string") { @@ -713,7 +713,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be /** * Reorders an object: creates a new object where the keys have been added alphabetically - * + * * const sorted = Utils.sortKeys({ x: 'x', abc: {'x': 'x', 'a': 'a'}, def: 'def'}) * JSON.stringify(sorted) // => '{"abc":{"a":"a","x":"x"},"def":"def","x":"x"}' */ @@ -816,15 +816,56 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return track[str2.length][str1.length]; } + public static MapToObj(d: Map, onValue: ((t: T, key: string) => any) = undefined): object { + const o = {} + d.forEach((value, key) => { + if (onValue !== undefined) { + value = onValue(value, key) + } + o[key] = value; + }) + return o + } + private static colorDiff(c0: { r: number, g: number, b: number }, c1: { r: number, g: number, b: number }) { return Math.abs(c0.r - c1.r) + Math.abs(c0.g - c1.g) + Math.abs(c0.b - c1.b); } - private static color(hex: string): { r: number, g: number, b: number } { - if (hex.startsWith == undefined) { - console.trace("WUT?", hex) - throw "wut?" + /** + * Utils.colorAsHex({r: 255, g: 128, b: 0}) // => "#ff8000" + * Utils.colorAsHex(undefined) // => undefined + */ + public static colorAsHex(c:{ r: number, g: number, b: number } ){ + if(c === undefined){ + return undefined } + function componentToHex(n) { + let hex = n.toString(16); + return hex.length == 1 ? "0" + hex : hex; + } + return "#" + componentToHex(c.r) + componentToHex(c.g) + componentToHex(c.b); + } + + /** + * + * Utils.color("#ff8000") // => {r: 255, g:128, b: 0} + * Utils.color(" rgba (12,34,56) ") // => {r: 12, g:34, b: 56} + * Utils.color(" rgba (12,34,56,0.5) ") // => {r: 12, g:34, b: 56} + * Utils.color(undefined) // => undefined + */ + public static color(hex: string): { r: number, g: number, b: number } { + if(hex === undefined){ + return undefined + } + hex = hex.replace(/[ \t]/g, "") + if (hex.startsWith("rgba(")) { + const match = hex.match(/rgba\(([0-9.]+),([0-9.]+),([0-9.]+)(,[0-9.]*)?\)/) + if(match == undefined){ + return undefined + } + return {r: Number(match[1]), g: Number(match[2]), b:Number( match[3])} + } + if (!hex.startsWith("#")) { return undefined; } @@ -842,17 +883,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be b: parseInt(hex.substr(5, 2), 16), } } - - - public static MapToObj(d : Map, onValue: ((t:T, key: string) => any) = undefined): object{ - const o = {} - d.forEach((value, key) => { - if(onValue !== undefined){ - value = onValue(value, key) - } - o[key] = value; - }) - return o - } + + } diff --git a/assets/layers/shops/shops.json b/assets/layers/shops/shops.json index 5df901100..5899cf895 100644 --- a/assets/layers/shops/shops.json +++ b/assets/layers/shops/shops.json @@ -404,4 +404,4 @@ } } ] -} +} \ No newline at end of file diff --git a/assets/themes/buurtnatuur/buurtnatuur.json b/assets/themes/buurtnatuur/buurtnatuur.json index 705397547..d6d16daca 100644 --- a/assets/themes/buurtnatuur/buurtnatuur.json +++ b/assets/themes/buurtnatuur/buurtnatuur.json @@ -538,7 +538,9 @@ ] }, "then": "Beheer door een privépersoon", - "addExtraTags": ["operator="] + "addExtraTags": [ + "operator=" + ] } ], "condition": { diff --git a/assets/themes/campersite/campersite.json b/assets/themes/campersite/campersite.json index fedd61276..e7c6fc25c 100644 --- a/assets/themes/campersite/campersite.json +++ b/assets/themes/campersite/campersite.json @@ -200,7 +200,9 @@ "pt_BR": "Pode ser usado de graça", "de": "Nutzung kostenlos" }, - "addExtraTags": ["charge="] + "addExtraTags": [ + "charge=" + ] } ] }, diff --git a/assets/themes/uk_addresses/uk_addresses.json b/assets/themes/uk_addresses/uk_addresses.json index e09a24ae0..75a10030c 100644 --- a/assets/themes/uk_addresses/uk_addresses.json +++ b/assets/themes/uk_addresses/uk_addresses.json @@ -258,7 +258,9 @@ { "if": "not:addr:unit=yes", "then": "There is no sub-unit within this address", - "addExtraTags": ["addr:unit="] + "addExtraTags": [ + "addr:unit=" + ] }, { "if": "addr:unit=", @@ -332,7 +334,9 @@ "nl": "Dit gebouw heeft geen huisnummer", "de": "Dieses Gebäude hat keine Hausnummer" }, - "addExtraTags": [ "addr:housenumber="] + "addExtraTags": [ + "addr:housenumber=" + ] }, { "if": { @@ -368,8 +372,11 @@ "then": { "en": "No extra place name is given or needed" }, - "addExtraTags": ["addr:substreet="] - },{ + "addExtraTags": [ + "addr:substreet=" + ] + }, + { "if": "addr:substreet=", "then": { "en": "
Place (e.g. \"Castle Mews\", \"West Business Park\")
" @@ -399,7 +406,9 @@ "then": { "en": "No extra place name is given or needed" }, - "addExtraTags": ["addr:substreet="] + "addExtraTags": [ + "addr:substreet=" + ] }, { "if": "addr:substreet=", @@ -473,7 +482,9 @@ { "if": "not:addr:parentstreet=yes", "then": "No parent street name is needed within this address", - "addExtraTags": ["addr:parentstreet="] + "addExtraTags": [ + "addr:parentstreet=" + ] }, { "if": "addr:parentstreet:={_closest_street:0:name}", diff --git a/langs/en.json b/langs/en.json index 1bbbf763d..a78bd1296 100644 --- a/langs/en.json +++ b/langs/en.json @@ -102,6 +102,8 @@ "download": { "downloadAsPdf": "Download a PDF of the current map", "downloadAsPdfHelper": "Ideal to print the current map", + "downloadAsSvg": "Download an SVG of the current map", + "downloadAsSvgHelper": "Compatible Inkscape or Adobe Illustrator; will need further processing ", "downloadCSV": "Download visible data as CSV", "downloadCSVHelper": "Compatible with LibreOffice Calc, Excel, …", "downloadFeatureAsGeojson": "Download as GeoJson-file", diff --git a/package-lock.json b/package-lock.json index 22220c716..061a7938b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "doctest-ts": "^0.5.0", "email-validator": "^2.0.4", "escape-html": "^1.0.3", + "geojson2svg": "^1.3.1", "i18next-client": "^1.11.4", "idb-keyval": "^6.0.3", "jquery": "^3.6.0", @@ -7309,6 +7310,14 @@ "quickselect": "^2.0.0" } }, + "node_modules/geojson2svg": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/geojson2svg/-/geojson2svg-1.3.1.tgz", + "integrity": "sha512-rurp7ebV+qG+pKNmPa2DI7J/hqtHsAkweamfbx0UXo/i/rIL7S2miFyRphOR9EzB38Q5DJThFUfa+m1LDILMkA==", + "dependencies": { + "multigeojson": "~0.0.1" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -9990,6 +9999,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/multigeojson": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/multigeojson/-/multigeojson-0.0.1.tgz", + "integrity": "sha1-8kBKgLbuWpZCq7F9sBqefxgJ7z4=" + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -22725,6 +22739,14 @@ } } }, + "geojson2svg": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/geojson2svg/-/geojson2svg-1.3.1.tgz", + "integrity": "sha512-rurp7ebV+qG+pKNmPa2DI7J/hqtHsAkweamfbx0UXo/i/rIL7S2miFyRphOR9EzB38Q5DJThFUfa+m1LDILMkA==", + "requires": { + "multigeojson": "~0.0.1" + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -24766,6 +24788,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "multigeojson": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/multigeojson/-/multigeojson-0.0.1.tgz", + "integrity": "sha1-8kBKgLbuWpZCq7F9sBqefxgJ7z4=" + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", diff --git a/package.json b/package.json index 3ec0dc8a5..de03a8c58 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "doctest-ts": "^0.5.0", "email-validator": "^2.0.4", "escape-html": "^1.0.3", + "geojson2svg": "^1.3.1", "i18next-client": "^1.11.4", "idb-keyval": "^6.0.3", "jquery": "^3.6.0",