forked from MapComplete/MapComplete
Merge branch 'develop'
This commit is contained in:
commit
4876b8f426
137 changed files with 30516 additions and 1815 deletions
|
@ -15,7 +15,7 @@ Requirements
|
|||
Before you start, you should have the following qualifications:
|
||||
|
||||
- You are a longtime contributor and do know the OpenStreetMap tagging scheme very well.
|
||||
- You are not afraid of editing a JSON file. If you don't know what a JSON-file is, [read this intro](https://www.w3schools.com/whatis/whatis_json.asp)
|
||||
- You are not afraid of editing a JSON file. If you don't know what a JSON file is, [read this intro](https://www.w3schools.com/whatis/whatis_json.asp)
|
||||
- Your theme will add well-understood tags (aka: the tags have a wiki page, are not controversial and are objective)
|
||||
- You are in contact with your local OpenStreetMap community and do know some other members to discuss tagging and to
|
||||
help testing
|
||||
|
@ -106,13 +106,13 @@ It asks some relevant questions, with the most important and easiests questions
|
|||
#### Don't: use a layer to filter
|
||||
|
||||
**Do not define a layer which filters on an attribute**, such as <del>all restaurants with a vegetarian diet</del>, <del>all shops which accept bitcoin</del>.
|
||||
This makes _addition_ of new points difficult as information might not yet be known. Conser the following situation:
|
||||
This makes _addition_ of new points difficult as information might not yet be known. Consider the following situation:
|
||||
|
||||
1. A theme defines a layer `vegetarian restaurants`, which matches `amenity=restaurant` & `diet:vegetarian=yes`.
|
||||
2. An object exists in OSM with `amenity=restaurant`;`name=Fancy Food`;`diet:vegan=yes`;`phone=...`;...
|
||||
3. A contributor visits the themes and will notice that _Fancy Food_ is missing
|
||||
4. The contributor will add _Fancy Food_
|
||||
5. There are now **two** Fancy Food objects in OSM.
|
||||
5. There are now **two** _Fancy Food_ objects in OSM.
|
||||
|
||||
Instead, use the filter functionality instead. This can be used from the layer to hide some objects based on their properties.
|
||||
When the contributor wants to add a new point, they'll be notified that some features might be hidden and only be allowed to add a new point when the points are shown.
|
||||
|
@ -230,7 +230,7 @@ The entire tagRendering will thus be:
|
|||
The template
|
||||
------------
|
||||
|
||||
[A basic template is available here](https://github.com/pietervdvn/MapComplete/blob/develop/Docs/theme-template.json)
|
||||
[A basic template is available here](https://github.com/pietervdvn/MapComplete/blob/develop/Docs/theme-template.json).
|
||||
|
||||
The custom theme generator
|
||||
--------------------------
|
||||
|
@ -258,7 +258,7 @@ If you have your JSON file, there are three ways to distribute your theme:
|
|||
up `https://mapcomplete.osm.be?userlayout=<url-to-the-raw.json>`
|
||||
- Ask to have your theme included into the official MapComplete - requirements below
|
||||
|
||||
### Getting your theme included into the official mapcomplete
|
||||
### Getting your theme included into the official MapComplete
|
||||
|
||||
Did you make an awesome theme that you want to share with the OpenStreetMap community? Have it included in the main
|
||||
application. This makes sure that:
|
||||
|
@ -314,15 +314,15 @@ There are three important levels in the JSON file:
|
|||
- A `layer` describes a layer. It contains the `name`, `icon`, `tags of objects to download from overpass`, and
|
||||
especially the `icon` and a way to dynamically render tags and ask questions. A lot of those fields (`icon`
|
||||
, `title`, ...) are actually a `TagRendering`.
|
||||
- A `TagRendering` is an object describing a relationship between what should be shown on screen and the OSM-tagging. It
|
||||
- A `TagRendering` is an object describing a relationship between what should be shown on screen and the OSM tagging. It
|
||||
works in two ways: if the correct tag is known, the appropriate text will be shown. If the tag is missing (and a
|
||||
question is defined), the question will be shown.
|
||||
|
||||
Every field is documented in the source code itself - you can find them here:
|
||||
|
||||
- [The top level `LayoutConfig`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/LayoutConfigJson.ts)
|
||||
- [A layer object `LayerConfig`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/LayerConfigJson.ts)
|
||||
- [The `TagRendering`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/TagRenderingConfigJson.ts)
|
||||
- [The top level `LayoutConfig`](/Models/ThemeConfig/Json/LayoutConfigJson.ts)
|
||||
- [A layer object `LayerConfig`](/Models/ThemeConfig/Json/LayerConfigJson.ts)
|
||||
- [The `TagRendering`](/Models/ThemeConfig/Json/TagRenderingConfigJson.ts)
|
||||
- At last, the exact semantics of tags are documented [here](Tags_format.md)
|
||||
|
||||
A JSON schema file is available in `Docs/Schemas` - use `LayoutConfig.schema.json` to validate a theme file.
|
||||
|
@ -334,7 +334,7 @@ in. [An overview of all these metatags is available here](CalculatedTags.md).
|
|||
|
||||
### TagRendering groups
|
||||
|
||||
A `tagRendering` can have a `group`-attribute, which acts as a tag. All `tagRendering`s with the same group name will be
|
||||
A `tagRendering` can have a `group` attribute, which acts as a tag. All `tagRendering`s with the same group name will be
|
||||
rendered together, in the same order as they were defined.
|
||||
|
||||
For example, if the defined `tagRendering`s have groups `A A B A A B B B`, the group order is `A B` and first all
|
||||
|
@ -411,7 +411,7 @@ Some pitfalls
|
|||
### Not publishing
|
||||
|
||||
Not publishing because 'it is not good enough'. _Share your theme, even if it is still not great, let the community help
|
||||
it improve_
|
||||
improve it._
|
||||
|
||||
### Thinking in terms of a question
|
||||
|
||||
|
@ -423,7 +423,7 @@ The correct way to handle this is to use _This bench does have a backrest_ and _
|
|||
answers.
|
||||
|
||||
One has to think first in terms of _what is shown to the user if it is known_, only then in terms of _what is the
|
||||
question I want to ask_
|
||||
question I want to ask_.
|
||||
|
||||
### Forgetting the casual/noob mapper
|
||||
|
||||
|
@ -437,7 +437,7 @@ In order to maximize contribution:
|
|||
4. Make sure the icons (on the map and in the questions) are big enough, clear enough and contrast enough with the
|
||||
background map
|
||||
|
||||
### Using layers to distinguish on attributes
|
||||
### Using layers to distinguish different object subtypes by attributes
|
||||
|
||||
One layer should portray one kind of physical object, e.g. "benches" or "restaurants". It should contain all of them,
|
||||
disregarding other properties.
|
||||
|
@ -459,6 +459,6 @@ through!
|
|||
Some new contributors might add a POI to indicate something that resembles it, but quite isn't.
|
||||
|
||||
For example, if they are only offered a layer with public bookcases, they might map their local library with a public bookcase.
|
||||
The perfect solution for this is to provide both the library-layer and public bookcases layer - but this requires having both layers.
|
||||
The perfect solution for this is to provide both the library layer and public bookcases layer - but this requires having both layers.
|
||||
|
||||
A good solution is to clearly explain what a certain feature is and what it is not.
|
||||
A good solution is to clearly explain what a certain feature is and what it is not.
|
||||
|
|
BIN
Docs/Misc/PlantDetection.gif
Normal file
BIN
Docs/Misc/PlantDetection.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 MiB |
BIN
Docs/Misc/vrg_en_antw.908.pdf
Normal file
BIN
Docs/Misc/vrg_en_antw.908.pdf
Normal file
Binary file not shown.
BIN
Docs/Promo/MapComplete-PosterPrototype.jpg
Normal file
BIN
Docs/Promo/MapComplete-PosterPrototype.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 464 KiB |
4933
Docs/Promo/flyer_a4.en.pdf
Normal file
4933
Docs/Promo/flyer_a4.en.pdf
Normal file
File diff suppressed because one or more lines are too long
4965
Docs/Promo/flyer_a4.nl.pdf
Normal file
4965
Docs/Promo/flyer_a4.nl.pdf
Normal file
File diff suppressed because one or more lines are too long
5115
Docs/Promo/poster_a3.en.pdf
Normal file
5115
Docs/Promo/poster_a3.en.pdf
Normal file
File diff suppressed because one or more lines are too long
5143
Docs/Promo/poster_a3.nl.pdf
Normal file
5143
Docs/Promo/poster_a3.nl.pdf
Normal file
File diff suppressed because one or more lines are too long
|
@ -55,12 +55,11 @@ class StatsDownloader {
|
|||
if (existsSync(path)) {
|
||||
let features = JSON.parse(readFileSync(path, "UTF-8"))
|
||||
features = features?.features ?? features
|
||||
console.log(features)
|
||||
features.push(...features.features) // day-stats are generally a list already, but in some ad-hoc cases might be a geojson-collection too
|
||||
console.log(
|
||||
"Loaded ",
|
||||
path,
|
||||
"from disk, got",
|
||||
"from disk, has",
|
||||
features.length,
|
||||
"features now"
|
||||
)
|
||||
|
|
|
@ -1 +1 @@
|
|||
["file-overview.json","missing_editor.json","stats.2020-10.json","stats.2020-11.json","stats.2020-12.json","stats.2020-5.json","stats.2020-6.json","stats.2020-7.json","stats.2020-8.json","stats.2020-9.json","stats.2021-1.json","stats.2021-10.json","stats.2021-11.json","stats.2021-12.json","stats.2021-2.json","stats.2021-3.json","stats.2021-4.json","stats.2021-5.json","stats.2021-6.json","stats.2021-7.json","stats.2021-8.json","stats.2021-9.json","stats.2022-1.json","stats.2022-2.json","stats.2022-3.json","stats.2022-4.json","stats.2022-5.json","stats.2022-6.json","stats.2022-7.json","stats.2022-8.json","stats.2022-9-01.day.json","stats.2022-9-02.day.json"]
|
||||
["file-overview.json","missing_editor.json","stats.2020-10.json","stats.2020-11.json","stats.2020-12.json","stats.2020-5.json","stats.2020-6.json","stats.2020-7.json","stats.2020-8.json","stats.2020-9.json","stats.2021-1.json","stats.2021-10.json","stats.2021-11.json","stats.2021-12.json","stats.2021-2.json","stats.2021-3.json","stats.2021-4.json","stats.2021-5.json","stats.2021-6.json","stats.2021-7.json","stats.2021-8.json","stats.2021-9.json","stats.2022-1.json","stats.2022-2.json","stats.2022-3.json","stats.2022-4.json","stats.2022-5.json","stats.2022-6.json","stats.2022-7.json","stats.2022-8.json","stats.2022-9-01.day.json","stats.2022-9-02.day.json","stats.2022-9-03.day.json","stats.2022-9-04.day.json","stats.2022-9-05.day.json","stats.2022-9-06.day.json","stats.2022-9-07.day.json","stats.2022-9-08.day.json","stats.2022-9-09.day.json","stats.2022-9-10.day.json","stats.2022-9-11.day.json","stats.2022-9-12.day.json","stats.2022-9-13.day.json","stats.2022-9-14.day.json","stats.2022-9-15.day.json","stats.2022-9-16.day.json","stats.2022-9-17.day.json","stats.2022-9-18.day.json","stats.2022-9-19.day.json","stats.2022-9-20.day.json","stats.2022-9-21.day.json","stats.2022-9-22.day.json","stats.2022-9-23.day.json","stats.2022-9-24.day.json","stats.2022-9-25.day.json"]
|
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-03.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-03.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-04.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-04.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-05.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-05.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-06.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-06.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-07.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-07.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-08.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-08.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-09.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-09.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-10.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-10.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-11.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-11.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-12.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-12.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-13.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-13.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-14.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-14.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-15.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-15.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-16.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-16.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-17.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-17.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-18.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-18.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-19.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-19.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-20.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-20.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-21.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-21.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-22.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-22.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-23.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-23.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-24.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-24.day.json
Normal file
File diff suppressed because one or more lines are too long
1
Docs/Tools/stats/stats.2022-9-25.day.json
Normal file
1
Docs/Tools/stats/stats.2022-9-25.day.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -137,10 +137,13 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
l("Stamen.TonerBackground", "Toner Background - no labels (by Stamen)"),
|
||||
l("Stamen.Watercolor", "Watercolor (by Stamen)"),
|
||||
l("Stadia.OSMBright", "Osm Bright (by Stadia)"),
|
||||
l("Stadia.AlidadeSmoothDark", "Alidade Smooth Dark (by Stadia)"),
|
||||
l("CartoDB.Positron", "Positron (by CartoDB)"),
|
||||
l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"),
|
||||
l("CartoDB.Voyager", "Voyager (by CartoDB)"),
|
||||
l("CartoDB.VoyagerNoLabels", "Voyager - no labels (by CartoDB)"),
|
||||
l("CartoDB.DarkMatter", "Dark Matter (by CartoDB)"),
|
||||
l("CartoDB.DarkMatterNoLabels", "Dark Matter - no labels (by CartoDB)"),
|
||||
]
|
||||
return Utils.NoNull(layers)
|
||||
}
|
||||
|
|
|
@ -191,6 +191,9 @@ export default class OverpassFeatureSource implements FeatureSource {
|
|||
|
||||
const self = this
|
||||
const overpassUrls = self.state.overpassUrl.data
|
||||
if(overpassUrls === undefined || overpassUrls.length === 0){
|
||||
throw "Panic: overpassFeatureSource didn't receive any overpassUrls"
|
||||
}
|
||||
let bounds: BBox
|
||||
do {
|
||||
try {
|
||||
|
|
|
@ -46,7 +46,11 @@ export default class TitleHandler {
|
|||
if (Utils.runningFromConsole) {
|
||||
return
|
||||
}
|
||||
try{
|
||||
document.title = title
|
||||
}catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
||||
import { QueryParameters } from "./Web/QueryParameters"
|
||||
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
|
||||
import { FixedUiElement } from "../UI/Base/FixedUiElement"
|
||||
import { Utils } from "../Utils"
|
||||
import {QueryParameters} from "./Web/QueryParameters"
|
||||
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"
|
||||
import {FixedUiElement} from "../UI/Base/FixedUiElement"
|
||||
import {Utils} from "../Utils"
|
||||
import Combine from "../UI/Base/Combine"
|
||||
import { SubtleButton } from "../UI/Base/SubtleButton"
|
||||
import {SubtleButton} from "../UI/Base/SubtleButton"
|
||||
import BaseUIElement from "../UI/BaseUIElement"
|
||||
import { UIEventSource } from "./UIEventSource"
|
||||
import { LocalStorageSource } from "./Web/LocalStorageSource"
|
||||
import {UIEventSource} from "./UIEventSource"
|
||||
import {LocalStorageSource} from "./Web/LocalStorageSource"
|
||||
import LZString from "lz-string"
|
||||
import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
|
||||
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
|
||||
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
|
||||
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"
|
||||
import SharedTagRenderings from "../Customizations/SharedTagRenderings"
|
||||
import * as known_layers from "../assets/generated/known_layers.json"
|
||||
import { PrepareTheme } from "../Models/ThemeConfig/Conversion/PrepareTheme"
|
||||
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"
|
||||
import * as licenses from "../assets/generated/license_info.json"
|
||||
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
|
||||
import { FixImages } from "../Models/ThemeConfig/Conversion/FixImages"
|
||||
import {FixImages} from "../Models/ThemeConfig/Conversion/FixImages"
|
||||
import Svg from "../Svg"
|
||||
import {DoesImageExist, PrevalidateTheme, ValidateThemeAndLayers} from "../Models/ThemeConfig/Conversion/Validation";
|
||||
|
||||
export default class DetermineLayout {
|
||||
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
|
||||
|
@ -129,11 +130,11 @@ export default class DetermineLayout {
|
|||
}),
|
||||
json !== undefined
|
||||
? new SubtleButton(Svg.download_svg(), "Download the JSON file").onClick(() => {
|
||||
Utils.offerContentsAsDownloadableFile(
|
||||
JSON.stringify(json, null, " "),
|
||||
"theme_definition.json"
|
||||
)
|
||||
})
|
||||
Utils.offerContentsAsDownloadableFile(
|
||||
JSON.stringify(json, null, " "),
|
||||
"theme_definition.json"
|
||||
)
|
||||
})
|
||||
: undefined,
|
||||
])
|
||||
.SetClass("flex flex-col clickable")
|
||||
|
@ -179,6 +180,23 @@ export default class DetermineLayout {
|
|||
|
||||
json.id = forceId ?? json.id
|
||||
|
||||
{
|
||||
let {errors} = new PrevalidateTheme().convert(json, "validation")
|
||||
if (errors.length > 0) {
|
||||
throw "Detected errors: " + errors.join("\n")
|
||||
}
|
||||
}
|
||||
{
|
||||
let {errors} = new ValidateThemeAndLayers(
|
||||
new DoesImageExist(new Set<string>(), _ => true),
|
||||
"",
|
||||
false,
|
||||
SharedTagRenderings.SharedTagRendering
|
||||
).convert(json, "validation")
|
||||
if (errors.length > 0) {
|
||||
throw "Detected errors: " + errors.join("\n")
|
||||
}
|
||||
}
|
||||
return new LayoutConfig(json, false, {
|
||||
definitionRaw: JSON.stringify(raw, null, " "),
|
||||
definedAtUrl: sourceUrl,
|
||||
|
|
|
@ -84,8 +84,8 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
|
|||
}
|
||||
|
||||
try {
|
||||
const tags: OsmTags = {
|
||||
id: <OsmId>(change.type + "/" + change.id),
|
||||
const tags: OsmTags & {id: OsmId & string} = {
|
||||
id: <OsmId & string>(change.type + "/" + change.id),
|
||||
}
|
||||
for (const kv of change.tags) {
|
||||
tags[kv.k] = kv.v
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource"
|
||||
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
|
||||
import { stat } from "fs"
|
||||
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"
|
||||
import {ImmutableStore, Store} from "../../UIEventSource"
|
||||
import FilteredLayer from "../../../Models/FilteredLayer"
|
||||
import { BBox } from "../../BBox"
|
||||
import { Feature } from "@turf/turf"
|
||||
import {BBox} from "../../BBox"
|
||||
import {Feature} from "geojson";
|
||||
|
||||
/**
|
||||
* A simple, read only feature store.
|
||||
|
|
|
@ -8,11 +8,12 @@ import {
|
|||
booleanWithin,
|
||||
Coord,
|
||||
Feature,
|
||||
Geometry,
|
||||
Geometry, Lines,
|
||||
MultiPolygon,
|
||||
Polygon,
|
||||
Properties,
|
||||
} from "@turf/turf"
|
||||
import {GeoJSON, LineString, Point} from "geojson";
|
||||
|
||||
export class GeoOperations {
|
||||
private static readonly _earthRadius = 6378137
|
||||
|
@ -26,8 +27,8 @@ export class GeoOperations {
|
|||
* Converts a GeoJson feature to a point GeoJson feature
|
||||
* @param feature
|
||||
*/
|
||||
static centerpoint(feature: any) {
|
||||
const newFeature = turf.center(feature)
|
||||
static centerpoint(feature: any): Feature<Point> {
|
||||
const newFeature : Feature<Point> = turf.center(feature)
|
||||
newFeature.properties = feature.properties
|
||||
newFeature.id = feature.id
|
||||
return newFeature
|
||||
|
@ -270,7 +271,8 @@ export class GeoOperations {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generates the closest point on a way from a given point
|
||||
* Generates the closest point on a way from a given point.
|
||||
* If the passed-in geojson object is a polygon, the outer ring will be used as linestring
|
||||
*
|
||||
* The properties object will contain three values:
|
||||
// - `index`: closest point was found on nth line part,
|
||||
|
@ -279,15 +281,15 @@ export class GeoOperations {
|
|||
* @param way The road on which you want to find a point
|
||||
* @param point Point defined as [lon, lat]
|
||||
*/
|
||||
public static nearestPoint(way, point: [number, number]) {
|
||||
public static nearestPoint(way: Feature<LineString | Polygon>, point: [number, number]) {
|
||||
if (way.geometry.type === "Polygon") {
|
||||
way = { ...way }
|
||||
way.geometry = { ...way.geometry }
|
||||
way.geometry.type = "LineString"
|
||||
way.geometry.coordinates = way.geometry.coordinates[0]
|
||||
way.geometry.coordinates = (<Polygon> way.geometry).coordinates[0]
|
||||
}
|
||||
|
||||
return turf.nearestPointOnLine(way, point, { units: "kilometers" })
|
||||
return turf.nearestPointOnLine(<Feature<LineString>> way, point, { units: "kilometers" })
|
||||
}
|
||||
|
||||
public static toCSV(features: any[]): string {
|
||||
|
|
|
@ -19,7 +19,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
private readonly _lon: number
|
||||
private readonly _snapOnto: OsmWay
|
||||
private readonly _reusePointDistance: number
|
||||
private meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string }
|
||||
private readonly meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string }
|
||||
private readonly _reusePreviouslyCreatedPoint: boolean
|
||||
|
||||
constructor(
|
||||
|
|
|
@ -3,9 +3,10 @@ import OsmChangeAction from "./OsmChangeAction"
|
|||
import { Changes } from "../Changes"
|
||||
import { ChangeDescription } from "./ChangeDescription"
|
||||
import ChangeTagAction from "./ChangeTagAction"
|
||||
import { TagsFilter } from "../../Tags/TagsFilter"
|
||||
import { And } from "../../Tags/And"
|
||||
import { Tag } from "../../Tags/Tag"
|
||||
import {TagsFilter} from "../../Tags/TagsFilter"
|
||||
import {And} from "../../Tags/And"
|
||||
import {Tag} from "../../Tags/Tag"
|
||||
import {OsmId} from "../../../Models/OsmFeature";
|
||||
import { Utils } from "../../../Utils"
|
||||
|
||||
export default class DeleteAction extends OsmChangeAction {
|
||||
|
@ -15,12 +16,13 @@ export default class DeleteAction extends OsmChangeAction {
|
|||
specialMotivation: string
|
||||
changeType: "deletion"
|
||||
}
|
||||
private readonly _id: string
|
||||
private _hardDelete: boolean
|
||||
private readonly _id: OsmId
|
||||
private readonly _hardDelete: boolean
|
||||
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
softDeletionTags: TagsFilter,
|
||||
id: OsmId,
|
||||
softDeletionTags: TagsFilter | undefined,
|
||||
meta: {
|
||||
theme: string
|
||||
specialMotivation: string
|
||||
|
@ -37,17 +39,32 @@ export default class DeleteAction extends OsmChangeAction {
|
|||
this._softDeletionTags = new And(
|
||||
Utils.NoNull([
|
||||
softDeletionTags,
|
||||
new Tag(
|
||||
"fixme",
|
||||
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`
|
||||
),
|
||||
])
|
||||
)
|
||||
new Tag(
|
||||
"fixme",
|
||||
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`
|
||||
)
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
|
||||
const osmObject = await OsmObject.DownloadObjectAsync(this._id)
|
||||
/**
|
||||
*
|
||||
* import {OsmNode} from "../OsmObject"
|
||||
*
|
||||
* const obj : OsmNode= new OsmNode(1)
|
||||
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
|
||||
* const da = new DeleteAction("node/1", new Tag("man_made",""), {theme: "test", specialMotivation: "Testcase"}, true)
|
||||
* const descr = await da.CreateChangeDescriptions(new Changes(), obj)
|
||||
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase",changeType: "deletion"}, type: "node",id: 1 }
|
||||
*
|
||||
* // Must not crash if softDeletionTags are undefined
|
||||
* const da = new DeleteAction("node/1", undefined, {theme: "test", specialMotivation: "Testcase"}, true)
|
||||
* const obj : OsmNode= new OsmNode(1)
|
||||
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
|
||||
* const descr = await da.CreateChangeDescriptions(new Changes(), obj)
|
||||
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase", changeType: "deletion"}, type: "node",id: 1 }
|
||||
*/
|
||||
public async CreateChangeDescriptions(changes: Changes, object?: OsmObject): Promise<ChangeDescription[]> {
|
||||
const osmObject = object ?? await OsmObject.DownloadObjectAsync(this._id)
|
||||
|
||||
if (this._hardDelete) {
|
||||
return [
|
||||
|
|
|
@ -13,6 +13,7 @@ import { Utils } from "../../../Utils"
|
|||
import { OsmConnection } from "../OsmConnection"
|
||||
import { Feature } from "@turf/turf"
|
||||
import FeaturePipeline from "../../FeatureSource/FeaturePipeline"
|
||||
import {Geometry, LineString, Point, Polygon} from "geojson";
|
||||
|
||||
export default class ReplaceGeometryAction extends OsmChangeAction {
|
||||
/**
|
||||
|
@ -84,7 +85,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
public async getPreview(): Promise<FeatureSource> {
|
||||
const { closestIds, allNodesById, detachedNodes, reprojectedNodes } =
|
||||
await this.GetClosestIds()
|
||||
const preview: Feature[] = closestIds.map((newId, i) => {
|
||||
const preview: Feature<Geometry> [] = closestIds.map((newId, i) => {
|
||||
if (this.identicalTo[i] !== undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -121,7 +122,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
|
||||
reprojectedNodes.forEach(({ newLat, newLon, nodeId }) => {
|
||||
const origNode = allNodesById.get(nodeId)
|
||||
const feature: Feature = {
|
||||
const feature: Feature<LineString> = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
move: "yes",
|
||||
|
@ -143,7 +144,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
|
||||
detachedNodes.forEach(({ reason }, id) => {
|
||||
const origNode = allNodesById.get(id)
|
||||
const feature: Feature = {
|
||||
const feature: Feature<Point> = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
detach: "yes",
|
||||
|
@ -389,7 +390,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
const node = allNodesById.get(id)
|
||||
|
||||
// Project the node onto the target way to calculate the new coordinates
|
||||
const way = {
|
||||
const way = <Feature<LineString>> {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Utils } from "../../Utils"
|
||||
import {Utils} from "../../Utils"
|
||||
import * as polygon_features from "../../assets/polygon-features.json"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { BBox } from "../BBox"
|
||||
import {Store, UIEventSource} from "../UIEventSource"
|
||||
import {BBox} from "../BBox"
|
||||
import * as OsmToGeoJson from "osmtogeojson"
|
||||
import { NodeId, OsmFeature, OsmId, OsmTags, RelationId, WayId } from "../../Models/OsmFeature"
|
||||
import {NodeId, OsmFeature, OsmId, OsmTags, RelationId, WayId} from "../../Models/OsmFeature"
|
||||
import {Feature, LineString, Polygon} from "geojson";
|
||||
|
||||
export abstract class OsmObject {
|
||||
private static defaultBackend = "https://www.openstreetmap.org/"
|
||||
|
@ -16,7 +17,7 @@ export abstract class OsmObject {
|
|||
/**
|
||||
* The OSM tags as simple object
|
||||
*/
|
||||
tags: OsmTags
|
||||
tags: OsmTags & {id: OsmId}
|
||||
version: number
|
||||
public changed: boolean = false
|
||||
timestamp: Date
|
||||
|
@ -40,6 +41,9 @@ export abstract class OsmObject {
|
|||
this.backendURL = url
|
||||
}
|
||||
|
||||
public static DownloadObject(id: NodeId, forceRefresh?: boolean): Store<OsmNode> ;
|
||||
public static DownloadObject(id: RelationId, forceRefresh?: boolean): Store<OsmRelation> ;
|
||||
public static DownloadObject(id: WayId, forceRefresh?: boolean): Store<OsmWay> ;
|
||||
public static DownloadObject(id: string, forceRefresh: boolean = false): Store<OsmObject> {
|
||||
let src: UIEventSource<OsmObject>
|
||||
if (OsmObject.objectCache.has(id)) {
|
||||
|
@ -69,12 +73,12 @@ export abstract class OsmObject {
|
|||
return rawData.elements[0].tags
|
||||
}
|
||||
|
||||
static async DownloadObjectAsync(id: NodeId): Promise<OsmNode | undefined>
|
||||
static async DownloadObjectAsync(id: WayId): Promise<OsmWay | undefined>
|
||||
static async DownloadObjectAsync(id: RelationId): Promise<OsmRelation | undefined>
|
||||
static async DownloadObjectAsync(id: OsmId): Promise<OsmObject | undefined>
|
||||
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined>
|
||||
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined> {
|
||||
static async DownloadObjectAsync(id: NodeId, maxCacheAgeInSecs?: number): Promise<OsmNode | undefined>
|
||||
static async DownloadObjectAsync(id: WayId, maxCacheAgeInSecs?: number): Promise<OsmWay | undefined>
|
||||
static async DownloadObjectAsync(id: RelationId, maxCacheAgeInSecs?: number): Promise<OsmRelation | undefined>
|
||||
static async DownloadObjectAsync(id: OsmId, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined>
|
||||
static async DownloadObjectAsync(id: string, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined>
|
||||
static async DownloadObjectAsync(id: string, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined> {
|
||||
const splitted = id.split("/")
|
||||
const type = splitted[0]
|
||||
const idN = Number(splitted[1])
|
||||
|
@ -84,7 +88,7 @@ export abstract class OsmObject {
|
|||
|
||||
const full = !id.startsWith("node") ? "/full" : ""
|
||||
const url = `${OsmObject.backendURL}api/0.6/${id}${full}`
|
||||
const rawData = await Utils.downloadJsonCached(url, 10000)
|
||||
const rawData = await Utils.downloadJsonCached(url, (maxCacheAgeInSecs ?? 10) * 1000)
|
||||
if (rawData === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -206,7 +210,7 @@ export abstract class OsmObject {
|
|||
case "relation":
|
||||
osmObject = new OsmRelation(idN)
|
||||
const allGeojsons = OsmToGeoJson.default(
|
||||
{ elements },
|
||||
{elements},
|
||||
// @ts-ignore
|
||||
{
|
||||
flatProperties: true,
|
||||
|
@ -260,16 +264,14 @@ export abstract class OsmObject {
|
|||
return false
|
||||
}
|
||||
|
||||
private static constructPolygonFeatures(): Map<
|
||||
string,
|
||||
{ values: Set<string>; blacklist: boolean }
|
||||
> {
|
||||
private static constructPolygonFeatures(): Map<string,
|
||||
{ values: Set<string>; blacklist: boolean }> {
|
||||
const result = new Map<string, { values: Set<string>; blacklist: boolean }>()
|
||||
for (const polygonFeature of polygon_features["default"] ?? polygon_features) {
|
||||
const key = polygonFeature.key
|
||||
|
||||
if (polygonFeature.polygon === "all") {
|
||||
result.set(key, { values: null, blacklist: false })
|
||||
result.set(key, {values: null, blacklist: false})
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -458,20 +460,27 @@ export class OsmWay extends OsmObject {
|
|||
this.nodes = element.nodes
|
||||
}
|
||||
|
||||
public asGeoJson() {
|
||||
public asGeoJson(): Feature<Polygon | LineString> & { properties: { id: WayId } } {
|
||||
let coordinates: [number, number][] | [number, number][][] = this.coordinates.map(
|
||||
([lat, lon]) => [lon, lat]
|
||||
)
|
||||
let geometry: LineString | Polygon
|
||||
|
||||
if (this.isPolygon()) {
|
||||
coordinates = [coordinates]
|
||||
geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [coordinates],
|
||||
}
|
||||
} else {
|
||||
geometry = {
|
||||
type: "LineString",
|
||||
coordinates: coordinates,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: this.tags,
|
||||
geometry: {
|
||||
type: this.isPolygon() ? "Polygon" : "LineString",
|
||||
coordinates: coordinates,
|
||||
},
|
||||
properties: <any>this.tags,
|
||||
geometry,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ export class Overpass {
|
|||
private readonly _interpreterUrl: string
|
||||
private readonly _timeout: Store<number>
|
||||
private readonly _extraScripts: string[]
|
||||
private _includeMeta: boolean
|
||||
private readonly _includeMeta: boolean
|
||||
private _relationTracker: RelationsTracker
|
||||
|
||||
constructor(
|
||||
|
|
|
@ -9,6 +9,7 @@ import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
|||
import { CountryCoder } from "latlon2country"
|
||||
import Constants from "../Models/Constants"
|
||||
import { TagUtils } from "./Tags/TagUtils"
|
||||
import {Feature, LineString} from "geojson";
|
||||
|
||||
export class SimpleMetaTagger {
|
||||
public readonly keys: string[]
|
||||
|
@ -420,6 +421,38 @@ export default class SimpleMetaTaggers {
|
|||
return true
|
||||
}
|
||||
)
|
||||
|
||||
private static directionCenterpoint = new SimpleMetaTagger(
|
||||
{
|
||||
keys:["_direction:centerpoint"],
|
||||
isLazy: true,
|
||||
doc: "_direction:centerpoint is the direction of the linestring (in degrees) if one were standing at the projected centerpoint."
|
||||
},
|
||||
(feature: Feature) => {
|
||||
if(feature.geometry.type !== "LineString"){
|
||||
return false
|
||||
}
|
||||
|
||||
const ls = <Feature<LineString>> feature;
|
||||
|
||||
Object.defineProperty(feature.properties, "_direction:centerpoint", {
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const centroid = GeoOperations.centerpoint(feature)
|
||||
const projected = GeoOperations.nearestPoint(ls, <[number,number]> centroid.geometry.coordinates)
|
||||
const nextPoint = ls.geometry.coordinates[projected.properties.index + 1]
|
||||
const bearing = GeoOperations.bearing(projected.geometry.coordinates, nextPoint)
|
||||
delete feature.properties["_direction:centerpoint"]
|
||||
feature.properties["_direction:centerpoint"] = bearing
|
||||
return bearing
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
private static currentTime = new SimpleMetaTagger(
|
||||
{
|
||||
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
|
||||
|
@ -457,6 +490,7 @@ export default class SimpleMetaTaggers {
|
|||
SimpleMetaTaggers.country,
|
||||
SimpleMetaTaggers.isOpen,
|
||||
SimpleMetaTaggers.directionSimplified,
|
||||
SimpleMetaTaggers.directionCenterpoint,
|
||||
SimpleMetaTaggers.currentTime,
|
||||
SimpleMetaTaggers.objectMetaInfo,
|
||||
SimpleMetaTaggers.noBothButLeftRight,
|
||||
|
|
|
@ -166,9 +166,9 @@ export default class FeatureSwitchState {
|
|||
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
|
||||
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
|
||||
).sync(
|
||||
(param) => param.split(","),
|
||||
(param) => param?.split(","),
|
||||
[],
|
||||
(urls) => urls.join(",")
|
||||
(urls) => urls?.join(",")
|
||||
)
|
||||
|
||||
this.overpassTimeout = UIEventSource.asFloat(
|
||||
|
|
|
@ -354,11 +354,11 @@ export class TagUtils {
|
|||
value: string
|
||||
modifier: "i" | ""
|
||||
} | null {
|
||||
const match = tag.match(/^([_a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/)
|
||||
const match = tag.match(/^([_|a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/)
|
||||
if (match == null) {
|
||||
return null
|
||||
}
|
||||
const [, key, invert, modifier, value] = match
|
||||
const [_, key, invert, modifier, value] = match
|
||||
return { key, value, invert: invert == "!", modifier: modifier == "i~" ? "i" : "" }
|
||||
}
|
||||
|
||||
|
|
|
@ -678,6 +678,18 @@ export class UIEventSource<T> extends Store<T> {
|
|||
public map<J>(f: (t: T) => J, extraSources: Store<any>[] = []): Store<J> {
|
||||
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data))
|
||||
}
|
||||
/**
|
||||
* Monoidal map which results in a read-only store. 'undefined' is passed 'as is'
|
||||
* Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)'
|
||||
*/
|
||||
public mapD<J>(f: (t: T) => J, extraSources: Store<any>[] = []): Store<J | undefined> {
|
||||
return new MappedStore(this, t => {
|
||||
if(t === undefined){
|
||||
return undefined
|
||||
}
|
||||
return f(t)
|
||||
}, extraSources, this._callbacks, this.data === undefined ? undefined : f(this.data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Two way sync with functions in both directions
|
||||
|
|
|
@ -6,11 +6,11 @@ import Hash from "./Hash"
|
|||
import { Utils } from "../../Utils"
|
||||
|
||||
export class QueryParameters {
|
||||
static defaults = {}
|
||||
static defaults : Record<string, string> = {}
|
||||
static documentation: Map<string, string> = new Map<string, string>()
|
||||
private static order: string[] = ["layout", "test", "z", "lat", "lon"]
|
||||
private static _wasInitialized: Set<string> = new Set()
|
||||
private static knownSources = {}
|
||||
protected static readonly _wasInitialized: Set<string> = new Set()
|
||||
protected static readonly knownSources: Record<string, UIEventSource<string>> = {}
|
||||
private static initialized = false
|
||||
|
||||
public static GetQueryParameter(
|
||||
|
@ -105,7 +105,19 @@ export class QueryParameters {
|
|||
}
|
||||
if (!Utils.runningFromConsole) {
|
||||
// Don't pollute the history every time a parameter changes
|
||||
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current())
|
||||
try{
|
||||
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current())
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ClearAll() {
|
||||
for (const name in QueryParameters.knownSources) {
|
||||
QueryParameters.knownSources[name].setData(undefined)
|
||||
}
|
||||
QueryParameters._wasInitialized.clear()
|
||||
QueryParameters.order = []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,65 @@
|
|||
import {
|
||||
Concat,
|
||||
Conversion,
|
||||
DesugaringContext,
|
||||
DesugaringStep,
|
||||
Each,
|
||||
FirstOf,
|
||||
Fuse,
|
||||
On,
|
||||
SetDefault,
|
||||
} from "./Conversion"
|
||||
import { LayerConfigJson } from "../Json/LayerConfigJson"
|
||||
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
|
||||
import { Utils } from "../../../Utils"
|
||||
import {Concat, Conversion, DesugaringContext, DesugaringStep, Each, FirstOf, Fuse, On, SetDefault,} from "./Conversion"
|
||||
import {LayerConfigJson} from "../Json/LayerConfigJson"
|
||||
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"
|
||||
import {Utils} from "../../../Utils"
|
||||
import RewritableConfigJson from "../Json/RewritableConfigJson"
|
||||
import SpecialVisualizations from "../../../UI/SpecialVisualizations"
|
||||
import Translations from "../../../UI/i18n/Translations"
|
||||
import { Translation } from "../../../UI/i18n/Translation"
|
||||
import {Translation} from "../../../UI/i18n/Translation"
|
||||
import * as tagrenderingconfigmeta from "../../../assets/tagrenderingconfigmeta.json"
|
||||
import { AddContextToTranslations } from "./AddContextToTranslations"
|
||||
import {AddContextToTranslations} from "./AddContextToTranslations"
|
||||
import FilterConfigJson from "../Json/FilterConfigJson";
|
||||
import * as predifined_filters from "../../../assets/layers/filters/filters.json"
|
||||
|
||||
class ExpandFilter extends DesugaringStep<LayerConfigJson>{
|
||||
|
||||
|
||||
private static load_filters(): Map<string, FilterConfigJson>{
|
||||
let filters = new Map<string, FilterConfigJson>();
|
||||
for (const filter of (<FilterConfigJson[]>predifined_filters.filter)) {
|
||||
filters.set(filter.id, filter)
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
private static readonly predefinedFilters = ExpandFilter.load_filters();
|
||||
|
||||
constructor() {
|
||||
super("Expands filters: replaces a shorthand by the value found in 'filters.json'", ["filter"], "ExpandFilter");
|
||||
}
|
||||
|
||||
convert(json: LayerConfigJson, context: string): { result: LayerConfigJson; errors?: string[]; warnings?: string[]; information?: string[] } {
|
||||
if(json.filter === undefined || json.filter === null){
|
||||
return {result: json} // Nothing to change here
|
||||
}
|
||||
|
||||
if( json.filter["sameAs"] !== undefined){
|
||||
return {result: json} // Nothing to change here
|
||||
}
|
||||
|
||||
const newFilters : FilterConfigJson[] = []
|
||||
const errors :string[]= []
|
||||
for (const filter of (<(FilterConfigJson|string)[]> json.filter)) {
|
||||
if (typeof filter !== "string") {
|
||||
newFilters.push(filter)
|
||||
continue
|
||||
}
|
||||
// Search for the filter:
|
||||
const found = ExpandFilter.predefinedFilters.get(filter)
|
||||
if(found === undefined){
|
||||
const suggestions = Utils.sortedByLevenshteinDistance(filter, Array.from(ExpandFilter.predefinedFilters.keys()), t => t)
|
||||
const err = context+".filter: while searching for predifined filter "+filter+": this filter is not found. Perhaps you meant one of: "+suggestions
|
||||
errors.push(err)
|
||||
}
|
||||
newFilters.push(found)
|
||||
}
|
||||
return {result: {
|
||||
...json, filter: newFilters
|
||||
}, errors};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class ExpandTagRendering extends Conversion<
|
||||
string | TagRenderingConfigJson | { builtin: string | string[]; override: any },
|
||||
|
@ -178,7 +220,7 @@ class ExpandTagRendering extends Conversion<
|
|||
if (lookup === undefined) {
|
||||
let candidates = Array.from(state.tagRenderings.keys())
|
||||
if (name.indexOf(".") > 0) {
|
||||
const [layerName, search] = name.split(".")
|
||||
const [layerName] = name.split(".")
|
||||
let layer = state.sharedLayers.get(layerName)
|
||||
if (layerName === this._self.id) {
|
||||
layer = this._self
|
||||
|
@ -699,7 +741,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
|
|||
)
|
||||
),
|
||||
new SetDefault("titleIcons", ["defaults"]),
|
||||
new On("titleIcons", (layer) => new Concat(new ExpandTagRendering(state, layer)))
|
||||
new On("titleIcons", (layer) => new Concat(new ExpandTagRendering(state, layer))),
|
||||
new ExpandFilter()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import { DesugaringStep, Each, Fuse, On } from "./Conversion"
|
||||
import { LayerConfigJson } from "../Json/LayerConfigJson"
|
||||
import {DesugaringStep, Each, Fuse, On} from "./Conversion"
|
||||
import {LayerConfigJson} from "../Json/LayerConfigJson"
|
||||
import LayerConfig from "../LayerConfig"
|
||||
import { Utils } from "../../../Utils"
|
||||
import {Utils} from "../../../Utils"
|
||||
import Constants from "../../Constants"
|
||||
import { Translation } from "../../../UI/i18n/Translation"
|
||||
import { LayoutConfigJson } from "../Json/LayoutConfigJson"
|
||||
import {Translation} from "../../../UI/i18n/Translation"
|
||||
import {LayoutConfigJson} from "../Json/LayoutConfigJson"
|
||||
import LayoutConfig from "../LayoutConfig"
|
||||
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
|
||||
import { TagUtils } from "../../../Logic/Tags/TagUtils"
|
||||
import { ExtractImages } from "./FixImages"
|
||||
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"
|
||||
import {TagUtils} from "../../../Logic/Tags/TagUtils"
|
||||
import {ExtractImages} from "./FixImages"
|
||||
import ScriptUtils from "../../../scripts/ScriptUtils"
|
||||
import { And } from "../../../Logic/Tags/And"
|
||||
import {And} from "../../../Logic/Tags/And"
|
||||
import Translations from "../../../UI/i18n/Translations"
|
||||
import Svg from "../../../Svg"
|
||||
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
|
||||
import {QuestionableTagRenderingConfigJson} from "../Json/QuestionableTagRenderingConfigJson"
|
||||
import FilterConfigJson from "../Json/FilterConfigJson";
|
||||
import {control} from "leaflet";
|
||||
import layers = control.layers;
|
||||
import DeleteConfig from "../DeleteConfig";
|
||||
|
||||
class ValidateLanguageCompleteness extends DesugaringStep<any> {
|
||||
private readonly _languages: string[]
|
||||
|
@ -40,12 +44,12 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> {
|
|||
.forEach((missing) => {
|
||||
errors.push(
|
||||
context +
|
||||
"A theme should be translation-complete for " +
|
||||
neededLanguage +
|
||||
", but it lacks a translation for " +
|
||||
missing.context +
|
||||
".\n\tThe known translation is " +
|
||||
missing.tr.textFor("en")
|
||||
"A theme should be translation-complete for " +
|
||||
neededLanguage +
|
||||
", but it lacks a translation for " +
|
||||
missing.context +
|
||||
".\n\tThe known translation is " +
|
||||
missing.tr.textFor("en")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -79,16 +83,16 @@ export class DoesImageExist extends DesugaringStep<string> {
|
|||
const information = []
|
||||
if (image.indexOf("{") >= 0) {
|
||||
information.push("Ignoring image with { in the path: " + image)
|
||||
return { result: image }
|
||||
return {result: image}
|
||||
}
|
||||
|
||||
if (image === "assets/SocialImage.png") {
|
||||
return { result: image }
|
||||
return {result: image}
|
||||
}
|
||||
if (image.match(/[a-z]*/)) {
|
||||
if (Svg.All[image + ".svg"] !== undefined) {
|
||||
// This is a builtin img, e.g. 'checkmark' or 'crosshair'
|
||||
return { result: image }
|
||||
return {result: image}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,7 +151,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
const warnings = []
|
||||
const information = []
|
||||
|
||||
const theme = new LayoutConfig(json, true)
|
||||
const theme = new LayoutConfig(json, this._isBuiltin)
|
||||
|
||||
{
|
||||
// Legacy format checks
|
||||
|
@ -155,20 +159,20 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
if (json["units"] !== undefined) {
|
||||
errors.push(
|
||||
"The theme " +
|
||||
json.id +
|
||||
" has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) "
|
||||
json.id +
|
||||
" has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) "
|
||||
)
|
||||
}
|
||||
if (json["roamingRenderings"] !== undefined) {
|
||||
errors.push(
|
||||
"Theme " +
|
||||
json.id +
|
||||
" contains an old 'roamingRenderings'. Use an 'overrideAll' instead"
|
||||
json.id +
|
||||
" contains an old 'roamingRenderings'. Use an 'overrideAll' instead"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
if (this._isBuiltin) {
|
||||
// Check images: are they local, are the licenses there, is the theme icon square, ...
|
||||
const images = new ExtractImages(
|
||||
this._isBuiltin,
|
||||
|
@ -178,10 +182,10 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
for (const remoteImage of remoteImages) {
|
||||
errors.push(
|
||||
"Found a remote image: " +
|
||||
remoteImage +
|
||||
" in theme " +
|
||||
json.id +
|
||||
", please download it."
|
||||
remoteImage +
|
||||
" in theme " +
|
||||
json.id +
|
||||
", please download it."
|
||||
)
|
||||
}
|
||||
for (const image of images) {
|
||||
|
@ -210,10 +214,10 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
const h = parseInt(height)
|
||||
if (w < 370 || h < 370) {
|
||||
const e: string = [
|
||||
`the icon for theme ${json.id} is too small. Please rescale the icon at ${json.icon}`,
|
||||
`Even though an SVG is 'infinitely scaleable', the icon should be dimensioned bigger. One of the build steps of the theme does convert the image to a PNG (to serve as PWA-icon) and having a small dimension will cause blurry images.`,
|
||||
` Width = ${width} height = ${height}; we recommend a size of at least 500px * 500px and to use a square aspect ratio.`,
|
||||
].join("\n")
|
||||
`the icon for theme ${json.id} is too small. Please rescale the icon at ${json.icon}`,
|
||||
`Even though an SVG is 'infinitely scaleable', the icon should be dimensioned bigger. One of the build steps of the theme does convert the image to a PNG (to serve as PWA-icon) and having a small dimension will cause blurry images.`,
|
||||
` Width = ${width} height = ${height}; we recommend a size of at least 500px * 500px and to use a square aspect ratio.`,
|
||||
].join("\n")
|
||||
;(json.hideFromOverview ? warnings : errors).push(e)
|
||||
}
|
||||
})
|
||||
|
@ -224,32 +228,35 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
}
|
||||
|
||||
try {
|
||||
if (theme.id !== theme.id.toLowerCase()) {
|
||||
errors.push("Theme ids should be in lowercase, but it is " + theme.id)
|
||||
}
|
||||
if (this._isBuiltin) {
|
||||
|
||||
const filename = this._path.substring(
|
||||
this._path.lastIndexOf("/") + 1,
|
||||
this._path.length - 5
|
||||
)
|
||||
if (theme.id !== filename) {
|
||||
errors.push(
|
||||
"Theme ids should be the same as the name.json, but we got id: " +
|
||||
if (theme.id !== theme.id.toLowerCase()) {
|
||||
errors.push("Theme ids should be in lowercase, but it is " + theme.id)
|
||||
}
|
||||
|
||||
const filename = this._path.substring(
|
||||
this._path.lastIndexOf("/") + 1,
|
||||
this._path.length - 5
|
||||
)
|
||||
if (theme.id !== filename) {
|
||||
errors.push(
|
||||
"Theme ids should be the same as the name.json, but we got id: " +
|
||||
theme.id +
|
||||
" and filename " +
|
||||
filename +
|
||||
" (" +
|
||||
this._path +
|
||||
")"
|
||||
)
|
||||
}
|
||||
this._validateImage.convertJoin(
|
||||
theme.icon,
|
||||
context + ".icon",
|
||||
errors,
|
||||
warnings,
|
||||
information
|
||||
)
|
||||
}
|
||||
this._validateImage.convertJoin(
|
||||
theme.icon,
|
||||
context + ".icon",
|
||||
errors,
|
||||
warnings,
|
||||
information
|
||||
)
|
||||
const dups = Utils.Dupiclates(json.layers.map((layer) => layer["id"]))
|
||||
if (dups.length > 0) {
|
||||
errors.push(
|
||||
|
@ -298,7 +305,7 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
|
|||
super(
|
||||
"Validates a theme and the contained layers",
|
||||
new ValidateTheme(doesImageExist, path, isBuiltin, sharedTagRenderings),
|
||||
new On("layers", new Each(new ValidateLayer(undefined, false, doesImageExist)))
|
||||
new On("layers", new Each(new ValidateLayer(undefined, isBuiltin, doesImageExist)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -318,7 +325,7 @@ class OverrideShadowingCheck extends DesugaringStep<LayoutConfigJson> {
|
|||
): { result: LayoutConfigJson; errors?: string[]; warnings?: string[] } {
|
||||
const overrideAll = json.overrideAll
|
||||
if (overrideAll === undefined) {
|
||||
return { result: json }
|
||||
return {result: json}
|
||||
}
|
||||
|
||||
const errors = []
|
||||
|
@ -345,7 +352,7 @@ class OverrideShadowingCheck extends DesugaringStep<LayoutConfigJson> {
|
|||
}
|
||||
}
|
||||
|
||||
return { result: json, errors }
|
||||
return {result: json, errors}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -455,7 +462,7 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
|
|||
const errors = []
|
||||
const warnings = []
|
||||
if (json.mappings === undefined || json.mappings.length === 0) {
|
||||
return { result: json }
|
||||
return {result: json}
|
||||
}
|
||||
const defaultProperties = {}
|
||||
for (const calculatedTagName of this._calculatedTagNames) {
|
||||
|
@ -484,7 +491,7 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
|
|||
}
|
||||
const keyValues = parsedConditions[i].asChange(defaultProperties)
|
||||
const properties = {}
|
||||
keyValues.forEach(({ k, v }) => {
|
||||
keyValues.forEach(({k, v}) => {
|
||||
properties[k] = v
|
||||
})
|
||||
for (let j = 0; j < i; j++) {
|
||||
|
@ -501,12 +508,12 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
|
|||
// The current mapping is shadowed!
|
||||
errors.push(`At ${context}: Mapping ${i} is shadowed by mapping ${j} and will thus never be shown:
|
||||
The mapping ${parsedConditions[i].asHumanString(
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)} is fully matched by a previous mapping (namely ${j}), which matches:
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)} is fully matched by a previous mapping (namely ${j}), which matches:
|
||||
${parsedConditions[j].asHumanString(false, false, {})}.
|
||||
|
||||
|
||||
To fix this problem, you can try to:
|
||||
- Move the shadowed mapping up
|
||||
- Do you want to use a different text in 'question mode'? Add 'hideInAnswer=true' to the first mapping
|
||||
|
@ -573,7 +580,7 @@ export class DetectMappingsWithImages extends DesugaringStep<TagRenderingConfigJ
|
|||
const warnings: string[] = []
|
||||
const information: string[] = []
|
||||
if (json.mappings === undefined || json.mappings.length === 0) {
|
||||
return { result: json }
|
||||
return {result: json}
|
||||
}
|
||||
const ignoreToken = "ignore-image-in-then"
|
||||
for (let i = 0; i < json.mappings.length; i++) {
|
||||
|
@ -659,13 +666,13 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
if (json.title === undefined) {
|
||||
errors.push(
|
||||
context +
|
||||
": this layer does not have a title defined but it does have tagRenderings. Not having a title will disable the popups, resulting in an unclickable element. Please add a title. If not having a popup is intended and the tagrenderings need to be kept (e.g. in a library layer), set `title: null` to disable this error."
|
||||
": this layer does not have a title defined but it does have tagRenderings. Not having a title will disable the popups, resulting in an unclickable element. Please add a title. If not having a popup is intended and the tagrenderings need to be kept (e.g. in a library layer), set `title: null` to disable this error."
|
||||
)
|
||||
}
|
||||
if (json.title === null) {
|
||||
information.push(
|
||||
context +
|
||||
": title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set."
|
||||
": title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -692,22 +699,28 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
if (duplicates.length > 0) {
|
||||
errors.push(
|
||||
"At " +
|
||||
context +
|
||||
": some tagrenderings have a duplicate id: " +
|
||||
duplicates.join(", ")
|
||||
context +
|
||||
": some tagrenderings have a duplicate id: " +
|
||||
duplicates.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if(json.deletion !== undefined && json.deletion instanceof DeleteConfig){
|
||||
if(json.deletion.softDeletionTags === undefined){
|
||||
warnings.push("No soft-deletion tags in deletion block for layer "+json.id)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
if (this._isBuiltin) {
|
||||
// Some checks for legacy elements
|
||||
|
||||
if (json["overpassTags"] !== undefined) {
|
||||
errors.push(
|
||||
"Layer " +
|
||||
json.id +
|
||||
'still uses the old \'overpassTags\'-format. Please use "source": {"osmTags": <tags>}\' instead of "overpassTags": <tags> (note: this isn\'t your fault, the custom theme generator still spits out the old format)'
|
||||
json.id +
|
||||
'still uses the old \'overpassTags\'-format. Please use "source": {"osmTags": <tags>}\' instead of "overpassTags": <tags> (note: this isn\'t your fault, the custom theme generator still spits out the old format)'
|
||||
)
|
||||
}
|
||||
const forbiddenTopLevel = [
|
||||
|
@ -725,18 +738,18 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
if (json[forbiddenKey] !== undefined)
|
||||
errors.push(
|
||||
context +
|
||||
": layer " +
|
||||
json.id +
|
||||
" still has a forbidden key " +
|
||||
forbiddenKey
|
||||
": layer " +
|
||||
json.id +
|
||||
" still has a forbidden key " +
|
||||
forbiddenKey
|
||||
)
|
||||
}
|
||||
if (json["hideUnderlayingFeaturesMinPercentage"] !== undefined) {
|
||||
errors.push(
|
||||
context +
|
||||
": layer " +
|
||||
json.id +
|
||||
" contains an old 'hideUnderlayingFeaturesMinPercentage'"
|
||||
": layer " +
|
||||
json.id +
|
||||
" contains an old 'hideUnderlayingFeaturesMinPercentage'"
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -747,15 +760,15 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
warnings.push(context + " has a tagRendering as `isShown`")
|
||||
}
|
||||
}
|
||||
{
|
||||
if (this._isBuiltin) {
|
||||
// Check location of layer file
|
||||
const expected: string = `assets/layers/${json.id}/${json.id}.json`
|
||||
if (this._path != undefined && this._path.indexOf(expected) < 0) {
|
||||
errors.push(
|
||||
"Layer is in an incorrect place. The path is " +
|
||||
this._path +
|
||||
", but expected " +
|
||||
expected
|
||||
this._path +
|
||||
", but expected " +
|
||||
expected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -795,6 +808,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (json.tagRenderings !== undefined) {
|
||||
const r = new On(
|
||||
"tagRenderings",
|
||||
|
@ -812,9 +826,9 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
if (hasCondition?.length > 0) {
|
||||
errors.push(
|
||||
"At " +
|
||||
context +
|
||||
":\n One or more icons in the mapRenderings have a condition set. Don't do this, as this will result in an invisible but clickable element. Use extra filters in the source instead. The offending mapRenderings are:\n" +
|
||||
JSON.stringify(hasCondition, null, " ")
|
||||
context +
|
||||
":\n One or more icons in the mapRenderings have a condition set. Don't do this, as this will result in an invisible but clickable element. Use extra filters in the source instead. The offending mapRenderings are:\n" +
|
||||
JSON.stringify(hasCondition, null, " ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -826,7 +840,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
const preset = json.presets[i]
|
||||
const tags: { k: string; v: string }[] = new And(
|
||||
preset.tags.map((t) => TagUtils.Tag(t))
|
||||
).asChange({ id: "node/-1" })
|
||||
).asChange({id: "node/-1"})
|
||||
const properties = {}
|
||||
for (const tag of tags) {
|
||||
properties[tag.k] = tag.v
|
||||
|
@ -835,12 +849,12 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
if (!doMatch) {
|
||||
errors.push(
|
||||
context +
|
||||
".presets[" +
|
||||
i +
|
||||
"]: This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " +
|
||||
JSON.stringify(properties) +
|
||||
"\n The required tags are: " +
|
||||
baseTags.asHumanString(false, false, {})
|
||||
".presets[" +
|
||||
i +
|
||||
"]: This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " +
|
||||
JSON.stringify(properties) +
|
||||
"\n The required tags are: " +
|
||||
baseTags.asHumanString(false, false, {})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -857,3 +871,109 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DetectDuplicateFilters extends DesugaringStep<{ layers: LayerConfigJson[], themes: LayoutConfigJson[]}> {
|
||||
|
||||
constructor() {
|
||||
super("Tries to detect layers where a shared filter can be used (or where similar filters occur)", [], "DetectDuplicateFilters");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all filter options into 'perOsmTag'
|
||||
*/
|
||||
private addLayerFilters(layer: LayerConfigJson, perOsmTag: Map<string, {
|
||||
layer: LayerConfigJson,
|
||||
layout: LayoutConfigJson | undefined,
|
||||
filter: FilterConfigJson
|
||||
}[]>, layout?: LayoutConfigJson | undefined): void {
|
||||
if (layer.filter === undefined || layer.filter === null) {
|
||||
return;
|
||||
}
|
||||
if (layer.filter["sameAs"] !== undefined) {
|
||||
return;
|
||||
}
|
||||
for (const filter of (<(string | FilterConfigJson) []>layer.filter)) {
|
||||
if (typeof filter === "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
if(filter["#"]?.indexOf("ignore-possible-duplicate")>=0){
|
||||
continue
|
||||
}
|
||||
|
||||
for (const option of filter.options) {
|
||||
if (option.osmTags === undefined) {
|
||||
continue
|
||||
}
|
||||
const key = JSON.stringify(option.osmTags)
|
||||
if (!perOsmTag.has(key)) {
|
||||
perOsmTag.set(key, [])
|
||||
}
|
||||
perOsmTag.get(key).push({
|
||||
layer, filter, layout
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
convert(json: { layers: LayerConfigJson[]; themes: LayoutConfigJson[] }, context: string): { result: { layers: LayerConfigJson[]; themes: LayoutConfigJson[] }; errors?: string[]; warnings?: string[]; information?: string[] } {
|
||||
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const information: string[] = []
|
||||
|
||||
const {layers, themes} = json
|
||||
const perOsmTag = new Map<string, {
|
||||
layer: LayerConfigJson,
|
||||
layout: LayoutConfigJson | undefined,
|
||||
filter: FilterConfigJson
|
||||
}[]>()
|
||||
|
||||
for (const layer of layers) {
|
||||
this.addLayerFilters(layer, perOsmTag)
|
||||
}
|
||||
|
||||
for (const theme of themes) {
|
||||
if(theme.id === "personal"){
|
||||
continue
|
||||
}
|
||||
for (const layer of theme.layers) {
|
||||
if(typeof layer === "string"){
|
||||
continue
|
||||
}
|
||||
if(layer["builtin"] !== undefined){
|
||||
continue
|
||||
}
|
||||
this.addLayerFilters(<LayerConfigJson> layer, perOsmTag, theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// At this point, we have gathered all filters per tag - time to find duplicates
|
||||
perOsmTag.forEach((value, key) => {
|
||||
if(value.length <= 1){
|
||||
// Seen this key just once, it is unique
|
||||
return;
|
||||
}
|
||||
let msg = "Possible duplicate filter: "+ key
|
||||
for (const {filter, layer, layout} of value) {
|
||||
let id = ""
|
||||
if(layout !== undefined){
|
||||
id = layout.id + ":"
|
||||
}
|
||||
msg += `\n - ${id}${layer.id}.${filter.id}`
|
||||
}
|
||||
warnings.push(msg)
|
||||
})
|
||||
|
||||
return {
|
||||
result: json,
|
||||
errors,
|
||||
warnings,
|
||||
information
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -316,9 +316,10 @@ export interface LayerConfigJson {
|
|||
)[]
|
||||
|
||||
/**
|
||||
* All the extra questions for filtering
|
||||
* All the extra questions for filtering.
|
||||
* If a string is given, mapComplete will search in 'filters.json' for the appropriate filter
|
||||
*/
|
||||
filter?: FilterConfigJson[] | { sameAs: string }
|
||||
filter?: (FilterConfigJson | string)[] | { sameAs: string }
|
||||
|
||||
/**
|
||||
* This block defines under what circumstances the delete dialog is shown for objects of this layer.
|
||||
|
|
|
@ -371,7 +371,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
throw "Error in " + context + ": use 'filter' instead of 'filters'"
|
||||
}
|
||||
|
||||
this.titleIcons = this.ParseTagRenderings(<TagRenderingConfigJson[]>json.titleIcons, {
|
||||
this.titleIcons = this.ParseTagRenderings(<TagRenderingConfigJson[]>json.titleIcons ?? [], {
|
||||
readOnlyMode: true,
|
||||
})
|
||||
|
||||
|
|
|
@ -21,8 +21,8 @@ export default class LayoutConfig {
|
|||
public readonly startZoom: number
|
||||
public readonly startLat: number
|
||||
public readonly startLon: number
|
||||
public readonly widenFactor: number
|
||||
public readonly defaultBackgroundId?: string
|
||||
public widenFactor: number
|
||||
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
|
||||
|
|
|
@ -2,7 +2,7 @@ import { TagsFilter } from "../../Logic/Tags/TagsFilter"
|
|||
import { RegexTag } from "../../Logic/Tags/RegexTag"
|
||||
|
||||
export default class SourceConfig {
|
||||
public readonly osmTags?: TagsFilter
|
||||
public osmTags?: TagsFilter
|
||||
public readonly overpassScript?: string
|
||||
public geojsonSource?: string
|
||||
public geojsonZoomLevel?: number
|
||||
|
|
|
@ -267,6 +267,9 @@ export default class TagRenderingConfig {
|
|||
if (this.freeform.key === "wikidata" && txt.indexOf("{wikipedia()") >= 0) {
|
||||
continue
|
||||
}
|
||||
if (this.freeform.type === "wikidata" && txt.indexOf(`{wikidata_label(${this.freeform.key})`) >= 0) {
|
||||
continue
|
||||
}
|
||||
throw `${context}: The rendering for language ${ln} does not contain the freeform key {${this.freeform.key}}. This is a bug, as this rendering should show exactly this freeform key!\nThe rendering is ${txt} `
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,11 @@ import Locale from "../i18n/Locale"
|
|||
import Link from "./Link"
|
||||
import Svg from "../../Svg"
|
||||
|
||||
/**
|
||||
* The little 'translate'-icon next to every icon + some static helper functions
|
||||
*/
|
||||
export default class LinkToWeblate extends VariableUiElement {
|
||||
private static URI: any
|
||||
|
||||
constructor(context: string, availableTranslations: object) {
|
||||
super(
|
||||
Locale.language.map(
|
||||
|
|
|
@ -3,6 +3,7 @@ import Loc from "../../Models/Loc"
|
|||
import BaseLayer from "../../Models/BaseLayer"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { BBox } from "../../Logic/BBox"
|
||||
import {deprecate} from "util";
|
||||
|
||||
export interface MinimapOptions {
|
||||
background?: UIEventSource<BaseLayer>
|
||||
|
@ -24,7 +25,10 @@ export interface MinimapObj {
|
|||
|
||||
installBounds(factor: number | BBox, showRange?: boolean): void
|
||||
|
||||
TakeScreenshot(): Promise<any>
|
||||
TakeScreenshot(format): Promise<string>
|
||||
TakeScreenshot(format: "image"): Promise<string>
|
||||
TakeScreenshot(format:"blob"): Promise<Blob>
|
||||
TakeScreenshot(format?: "image" | "blob"): Promise<string | Blob>
|
||||
}
|
||||
|
||||
export default class Minimap {
|
||||
|
|
|
@ -109,10 +109,27 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
|
|||
mp.remove()
|
||||
}
|
||||
|
||||
public async TakeScreenshot() {
|
||||
/**
|
||||
* Takes a screenshot of the current map
|
||||
* @param format: image: give a base64 encoded png image;
|
||||
* @constructor
|
||||
*/
|
||||
public async TakeScreenshot(): Promise<string> ;
|
||||
public async TakeScreenshot(format: "image"): Promise<string> ;
|
||||
public async TakeScreenshot(format: "blob"): Promise<Blob> ;
|
||||
public async TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> ;
|
||||
public async TakeScreenshot(format: "image" | "blob" = "image"): Promise<string | Blob> {
|
||||
console.log("Taking a screenshot...")
|
||||
const screenshotter = new SimpleMapScreenshoter()
|
||||
screenshotter.addTo(this.leafletMap.data)
|
||||
return await screenshotter.takeScreen("image")
|
||||
const result = <any> await screenshotter.takeScreen((<any> format) ?? "image")
|
||||
if(format === "image" && typeof result === "string"){
|
||||
return result
|
||||
}
|
||||
if(format === "blob" && result instanceof Blob){
|
||||
return result
|
||||
}
|
||||
throw "Something went wrong while creating the screenshot: "+result
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
|
|
|
@ -148,7 +148,7 @@ export default abstract class BaseUIElement {
|
|||
} catch (e) {
|
||||
const domExc = e as DOMException
|
||||
if (domExc) {
|
||||
console.log("An exception occured", domExc.code, domExc.message, domExc.name)
|
||||
console.error("An exception occured", domExc.code, domExc.message, domExc.name, domExc)
|
||||
}
|
||||
console.error(e)
|
||||
}
|
||||
|
|
347
UI/BigComponents/PdfExportGui.ts
Normal file
347
UI/BigComponents/PdfExportGui.ts
Normal file
|
@ -0,0 +1,347 @@
|
|||
import Combine from "../Base/Combine";
|
||||
import {FlowPanelFactory, FlowStep} from "../ImportFlow/FlowStep";
|
||||
import {ImmutableStore, Store, UIEventSource} from "../../Logic/UIEventSource";
|
||||
import {InputElement} from "../Input/InputElement";
|
||||
import {SvgToPdf, SvgToPdfOptions} from "../../Utils/svgToPdf";
|
||||
import {FixedInputElement} from "../Input/FixedInputElement";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import FileSelectorButton from "../Input/FileSelectorButton";
|
||||
import InputElementMap from "../Input/InputElementMap";
|
||||
import {RadioButton} from "../Input/RadioButton";
|
||||
import {Utils} from "../../Utils";
|
||||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import Loading from "../Base/Loading";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
import Img from "../Base/Img";
|
||||
import Title from "../Base/Title";
|
||||
import {CheckBox} from "../Input/Checkboxes";
|
||||
import Minimap from "../Base/Minimap";
|
||||
import SearchAndGo from "./SearchAndGo";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import List from "../Base/List";
|
||||
import LeftIndex from "../Base/LeftIndex";
|
||||
import Constants from "../../Models/Constants";
|
||||
import Toggleable from "../Base/Toggleable";
|
||||
import Lazy from "../Base/Lazy";
|
||||
import LinkToWeblate from "../Base/LinkToWeblate";
|
||||
import Link from "../Base/Link";
|
||||
import {SearchablePillsSelector} from "../Input/SearchableMappingsSelector";
|
||||
import * as languages from "../../assets/language_translations.json"
|
||||
import {Translation} from "../i18n/Translation";
|
||||
|
||||
class SelectTemplate extends Combine implements FlowStep<{ title: string, pages: string[] }> {
|
||||
readonly IsValid: Store<boolean>;
|
||||
readonly Value: Store<{ title: string, pages: string[] }>;
|
||||
|
||||
constructor() {
|
||||
const elements: InputElement<{ templateName: string, pages: string[] }>[] = []
|
||||
for (const templateName in SvgToPdf.templates) {
|
||||
const template = SvgToPdf.templates[templateName]
|
||||
elements.push(new FixedInputElement(
|
||||
new Combine([new FixedUiElement(templateName).SetClass("font-bold pr-2"),
|
||||
template.description
|
||||
])
|
||||
, new UIEventSource({templateName, pages: template.pages})))
|
||||
}
|
||||
|
||||
const file = new FileSelectorButton(new FixedUiElement("Select an svg image which acts as template"), {
|
||||
acceptType: "image/svg+xml",
|
||||
allowMultiple: true
|
||||
})
|
||||
const fileMapped = new InputElementMap<FileList, { templateName: string, pages: string[], fromFile: true }>(file, (x0, x1) => x0 === x1,
|
||||
(filelist) => {
|
||||
if (filelist === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const pages = []
|
||||
let templateName: string = undefined;
|
||||
for (const file of Array.from(filelist)) {
|
||||
|
||||
if (templateName == undefined) {
|
||||
templateName = file.name.substring(file.name.lastIndexOf("/") + 1)
|
||||
templateName = templateName.substring(0, templateName.lastIndexOf("."))
|
||||
}
|
||||
pages.push(file.text())
|
||||
}
|
||||
|
||||
return {
|
||||
templateName,
|
||||
pages,
|
||||
fromFile: true
|
||||
}
|
||||
|
||||
},
|
||||
_ => undefined
|
||||
)
|
||||
elements.push(fileMapped)
|
||||
const radio = new RadioButton(elements, {selectFirstAsDefault: true})
|
||||
|
||||
const loaded: Store<{ success: { title: string, pages: string[] } } | { error: any }> = radio.GetValue().bind(template => {
|
||||
if (template === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (template["fromFile"]) {
|
||||
return UIEventSource.FromPromiseWithErr(Promise.all(template.pages).then(pages => ({
|
||||
title: template.templateName,
|
||||
pages
|
||||
})))
|
||||
}
|
||||
const urls = template.pages.map(p => SelectTemplate.ToUrl(p))
|
||||
const dloadAll: Promise<{ title: string, pages: string[] }> = Promise.all(urls.map(url => Utils.download(url))).then(pages => ({
|
||||
pages,
|
||||
title: template.templateName
|
||||
}))
|
||||
|
||||
return UIEventSource.FromPromiseWithErr(dloadAll)
|
||||
})
|
||||
const preview = new VariableUiElement(
|
||||
loaded.map(pages => {
|
||||
if (pages === undefined) {
|
||||
return new Loading()
|
||||
}
|
||||
if (pages["error"] !== undefined) {
|
||||
return new FixedUiElement("Loading preview failed: " + pages["err"]).SetClass("alert")
|
||||
}
|
||||
const svgs = pages["success"].pages
|
||||
if (svgs.length === 0) {
|
||||
return new FixedUiElement("No pages loaded...").SetClass("alert")
|
||||
}
|
||||
const els: BaseUIElement[] = []
|
||||
for (const pageSrc of svgs) {
|
||||
const el = new Img(pageSrc, true)
|
||||
.SetClass("w-96 m-2 border-black border-2")
|
||||
els.push(el)
|
||||
}
|
||||
return new Combine(els).SetClass("flex border border-subtle rounded-xl");
|
||||
})
|
||||
)
|
||||
|
||||
super([
|
||||
new Title("Select template"),
|
||||
radio,
|
||||
new Title("Preview"),
|
||||
preview
|
||||
]);
|
||||
this.Value = loaded.map(l => l === undefined ? undefined : l["success"])
|
||||
this.IsValid = this.Value.map(v => v !== undefined)
|
||||
}
|
||||
|
||||
public static ToUrl(spec: string) {
|
||||
if (spec.startsWith("http")) {
|
||||
return spec
|
||||
}
|
||||
let path = window.location.pathname
|
||||
path = path.substring(0, path.lastIndexOf("/"))
|
||||
return window.location.protocol + "//" + window.location.host + path + "/" + spec
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SelectPdfOptions extends Combine implements FlowStep<{ title: string, pages: string[], options: SvgToPdfOptions }> {
|
||||
readonly IsValid: Store<boolean>;
|
||||
readonly Value: Store<{ title: string, pages: string[], options: SvgToPdfOptions }>;
|
||||
|
||||
constructor(title: string, pages: string[], getFreeDiv: () => string) {
|
||||
const dummy = new CheckBox("Don't add data to the map (to quickly preview the PDF)", false)
|
||||
const overrideMapLocation = new CheckBox("Override map location: use a selected location instead of the location set in the template", false)
|
||||
const locationInput = Minimap.createMiniMap().SetClass("block w-full")
|
||||
const searchField = new SearchAndGo({leafletMap: locationInput.leafletMap})
|
||||
const selectLocation =
|
||||
new Combine([
|
||||
new Toggle(new Combine([new Title("Select override location"), searchField]).SetClass("flex"), undefined, overrideMapLocation.GetValue()),
|
||||
new Toggle(locationInput.SetStyle("height: 20rem"), undefined, overrideMapLocation.GetValue()).SetStyle("height: 20rem")
|
||||
]).SetClass("block").SetStyle("height: 25rem")
|
||||
super([new Title("Select options"),
|
||||
dummy,
|
||||
overrideMapLocation,
|
||||
selectLocation
|
||||
]);
|
||||
this.Value = dummy.GetValue().map((disableMaps) => {
|
||||
return {
|
||||
pages,
|
||||
title,
|
||||
options: <SvgToPdfOptions>{
|
||||
disableMaps,
|
||||
getFreeDiv,
|
||||
overrideLocation: overrideMapLocation.GetValue().data ? locationInput.location.data : undefined
|
||||
}
|
||||
}
|
||||
}, [overrideMapLocation.GetValue(), locationInput.location])
|
||||
this.IsValid = new ImmutableStore(true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PreparePdf extends Combine implements FlowStep<{ svgToPdf: SvgToPdf, languages: string[] }> {
|
||||
readonly IsValid: Store<boolean>;
|
||||
readonly Value: Store<{ svgToPdf: SvgToPdf, languages: string[] }>;
|
||||
|
||||
constructor(title: string, pages: string[], options: SvgToPdfOptions) {
|
||||
const svgToPdf = new SvgToPdf(title, pages, options)
|
||||
const languageOptions = [
|
||||
new FixedInputElement("Nederlands", "nl"),
|
||||
new FixedInputElement("English", "en")
|
||||
]
|
||||
const langs: string[] = Array.from(Object.keys(languages["default"] ?? languages))
|
||||
console.log("Available languages are:", langs)
|
||||
const languageSelector = new SearchablePillsSelector(
|
||||
langs.map(l => ({
|
||||
show: new Translation(languages[l]),
|
||||
value: l,
|
||||
mainTerm: languages[l]
|
||||
})), {
|
||||
mode: "select-many"
|
||||
}
|
||||
)
|
||||
|
||||
const isPrepared = UIEventSource.FromPromiseWithErr(svgToPdf.Prepare())
|
||||
|
||||
super([
|
||||
new Title("Select languages..."),
|
||||
languageSelector,
|
||||
new Toggle(
|
||||
new Loading("Preparing maps..."),
|
||||
undefined,
|
||||
isPrepared.map(p => p === undefined)
|
||||
)
|
||||
]);
|
||||
this.Value = isPrepared.map(isPrepped => {
|
||||
if (isPrepped === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (isPrepped["success"] !== undefined) {
|
||||
const svgToPdf = isPrepped["success"]
|
||||
const langs = languageSelector.GetValue().data
|
||||
console.log("Languages are", langs)
|
||||
if (langs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return {svgToPdf, languages: langs}
|
||||
}
|
||||
return undefined;
|
||||
}, [languageSelector.GetValue()])
|
||||
this.IsValid = this.Value.map(v => v !== undefined)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class InspectStrings extends Toggle implements FlowStep<{ svgToPdf: SvgToPdf, languages: string[] }> {
|
||||
readonly IsValid: Store<boolean>;
|
||||
readonly Value: Store<{ svgToPdf: SvgToPdf; languages: string[] }>;
|
||||
|
||||
constructor(svgToPdf: SvgToPdf, languages: string[]) {
|
||||
|
||||
const didLoadLanguages = UIEventSource.FromPromiseWithErr(svgToPdf.PrepareLanguages(languages)).map(l => l !== undefined && l["success"] !== undefined)
|
||||
|
||||
super(new Combine([
|
||||
new Title("Inspect translation strings"),
|
||||
...languages.map(l => new Lazy(() => InspectStrings.createOverviewPanel(svgToPdf, l)))
|
||||
]),
|
||||
new Loading(),
|
||||
didLoadLanguages
|
||||
);
|
||||
this.Value = new ImmutableStore({svgToPdf, languages})
|
||||
this.IsValid = didLoadLanguages
|
||||
}
|
||||
|
||||
private static createOverviewPanel(svgToPdf: SvgToPdf, language: string): BaseUIElement {
|
||||
const elements: BaseUIElement[] = []
|
||||
let foundTranslations = 0
|
||||
const allKeys = Array.from(svgToPdf.translationKeys())
|
||||
for (const translationKey of allKeys) {
|
||||
let spec = translationKey
|
||||
if (translationKey.startsWith("layer.")) {
|
||||
spec = "layers:" + translationKey.substring(6)
|
||||
} else {
|
||||
spec = "core:" + translationKey
|
||||
}
|
||||
const translated = svgToPdf.getTranslation("$" + translationKey, language, true)
|
||||
if (translated) {
|
||||
foundTranslations++
|
||||
}
|
||||
const linkToWeblate = new Link(spec, LinkToWeblate.hrefToWeblate(language, spec), true).SetClass("font-bold link-underline")
|
||||
elements.push(new Combine([
|
||||
linkToWeblate,
|
||||
" ",
|
||||
translated ?? new FixedUiElement("No translation found!").SetClass("alert")
|
||||
|
||||
]))
|
||||
}
|
||||
|
||||
return new Toggleable(
|
||||
new Title("Translations for " + language),
|
||||
new Combine([
|
||||
`${foundTranslations}/${allKeys.length} of translations are found (${Math.floor(100 * foundTranslations / allKeys.length)}%)`,
|
||||
"The following keys are used:",
|
||||
new List(elements)
|
||||
]),
|
||||
{closeOnClick: false, height: "15rem"})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SavePdf extends Combine {
|
||||
|
||||
constructor(svgToPdf: SvgToPdf, languages: string[]) {
|
||||
|
||||
super([
|
||||
new Title("Generating your pdfs..."),
|
||||
new List(languages.map(lng => new Toggle(
|
||||
lng + " is done!",
|
||||
new Loading("Creating pdf for " + lng),
|
||||
UIEventSource.FromPromiseWithErr(svgToPdf.ConvertSvg(lng).then(() => true))
|
||||
.map(x => x !== undefined && x["success"] === true)
|
||||
)))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export class PdfExportGui extends LeftIndex {
|
||||
|
||||
|
||||
constructor(freeDivId: string) {
|
||||
|
||||
let i = 0
|
||||
const createDiv = (): string => {
|
||||
const div = document.createElement("div")
|
||||
div.id = "freediv-" + (i++)
|
||||
document.getElementById(freeDivId).append(div)
|
||||
return div.id
|
||||
}
|
||||
|
||||
Constants.defaultOverpassUrls.splice(0, 1)
|
||||
const {flow, furthestStep, titles} = FlowPanelFactory.start(
|
||||
new Title("Select template"), new SelectTemplate()
|
||||
).then(new Title("Select options"), ({title, pages}) => new SelectPdfOptions(title, pages, createDiv))
|
||||
.then("Generate maps...", ({title, pages, options}) => new PreparePdf(title, pages, options))
|
||||
.then("Inspect translations", (({svgToPdf, languages}) => new InspectStrings(svgToPdf, languages)))
|
||||
.finish("Generating...", ({svgToPdf, languages}) => new SavePdf(svgToPdf, languages))
|
||||
|
||||
|
||||
const toc = new List(
|
||||
titles.map(
|
||||
(title, i) =>
|
||||
new VariableUiElement(
|
||||
furthestStep.map((currentStep) => {
|
||||
if (i > currentStep) {
|
||||
return new Combine([title]).SetClass("subtle")
|
||||
}
|
||||
if (i == currentStep) {
|
||||
return new Combine([title]).SetClass("font-bold")
|
||||
}
|
||||
if (i < currentStep) {
|
||||
return title
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
const leftContents: BaseUIElement[] = [
|
||||
toc
|
||||
].map((el) => el?.SetClass("pl-4"))
|
||||
|
||||
super(leftContents, flow)
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import Combine from "../Base/Combine"
|
|||
import Locale from "../i18n/Locale"
|
||||
|
||||
export default class SearchAndGo extends Combine {
|
||||
constructor(state: { leafletMap: UIEventSource<any>; selectedElement: UIEventSource<any> }) {
|
||||
constructor(state: { leafletMap: UIEventSource<any>; selectedElement?: UIEventSource<any> }) {
|
||||
const goButton = Svg.search_ui().SetClass("w-8 h-8 full-rounded border-black float-right")
|
||||
|
||||
const placeholder = new UIEventSource<Translation>(Translations.t.general.search.search)
|
||||
|
@ -63,7 +63,7 @@ export default class SearchAndGo extends Combine {
|
|||
[bb[0], bb[2]],
|
||||
[bb[1], bb[3]],
|
||||
]
|
||||
state.selectedElement.setData(undefined)
|
||||
state.selectedElement?.setData(undefined)
|
||||
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id)
|
||||
state.leafletMap.data.fitBounds(bounds)
|
||||
placeholder.setData(Translations.t.general.search.search)
|
||||
|
|
|
@ -26,6 +26,7 @@ import BaseLayer from "../../Models/BaseLayer"
|
|||
import Loading from "../Base/Loading"
|
||||
import Hash from "../../Logic/Web/Hash"
|
||||
import { GlobalFilter } from "../../Logic/State/MapState"
|
||||
import {WayId} from "../../Models/OsmFeature";
|
||||
|
||||
/*
|
||||
* The SimpleAddUI is a single panel, which can have multiple states:
|
||||
|
@ -123,13 +124,13 @@ export default class SimpleAddUI extends Toggle {
|
|||
function confirm(
|
||||
tags: any[],
|
||||
location: { lat: number; lon: number },
|
||||
snapOntoWayId?: string
|
||||
snapOntoWayId?: WayId
|
||||
) {
|
||||
if (snapOntoWayId === undefined) {
|
||||
createNewPoint(tags, location, undefined)
|
||||
} else {
|
||||
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD((way) => {
|
||||
createNewPoint(tags, location, <OsmWay>way)
|
||||
createNewPoint(tags, location, way)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import Link from "../Base/Link"
|
|||
import LinkToWeblate from "../Base/LinkToWeblate"
|
||||
import Toggleable from "../Base/Toggleable"
|
||||
import Title from "../Base/Title"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import { SubtleButton } from "../Base/SubtleButton"
|
||||
import Svg from "../../Svg"
|
||||
import * as native_languages from "../../assets/language_native.json"
|
||||
|
@ -89,8 +89,6 @@ class TranslatorsPanelContent extends Combine {
|
|||
]
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
|
||||
const translated = seed.Subs({
|
||||
total,
|
||||
|
|
|
@ -70,7 +70,7 @@ export default class ExportPDF {
|
|||
console.error(e)
|
||||
self.cleanup()
|
||||
}
|
||||
}, 500),
|
||||
}, 500)
|
||||
})
|
||||
|
||||
minimap.SetStyle(
|
||||
|
@ -166,7 +166,7 @@ export default class ExportPDF {
|
|||
// Add the logo of the layout
|
||||
let img = document.createElement("img")
|
||||
const imgSource = layout.icon
|
||||
const imgType = imgSource.substr(imgSource.lastIndexOf(".") + 1)
|
||||
const imgType = imgSource.substring(imgSource.lastIndexOf(".") + 1)
|
||||
img.src = imgSource
|
||||
if (imgType.toLowerCase() === "svg") {
|
||||
new FixedUiElement("").AttachTo(this.freeDivId)
|
||||
|
|
|
@ -3,11 +3,12 @@ import { UIEventSource } from "../../Logic/UIEventSource"
|
|||
import { Utils } from "../../Utils"
|
||||
import BaseUIElement from "../BaseUIElement"
|
||||
import InputElementMap from "./InputElementMap"
|
||||
import Translations from "../i18n/Translations";
|
||||
|
||||
export class CheckBox extends InputElementMap<number[], boolean> {
|
||||
constructor(el: BaseUIElement, defaultValue?: boolean) {
|
||||
constructor(el: (BaseUIElement | string), defaultValue?: boolean) {
|
||||
super(
|
||||
new CheckBoxes([el]),
|
||||
new CheckBoxes([Translations.W(el)]),
|
||||
(x0, x1) => x0 === x1,
|
||||
(t) => t.length > 0,
|
||||
(x) => (x ? [0] : [])
|
||||
|
|
|
@ -1,44 +1,55 @@
|
|||
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";
|
||||
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
|
||||
import {RelationId, WayId} from "../../Models/OsmFeature";
|
||||
import {Feature, LineString, Polygon} from "geojson";
|
||||
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
|
||||
|
||||
export default class LocationInput
|
||||
extends BaseUIElement
|
||||
implements ReadonlyInputElement<Loc>, MinimapObj
|
||||
{
|
||||
implements ReadonlyInputElement<Loc>, MinimapObj {
|
||||
private static readonly matchLayer = new LayerConfig(
|
||||
matchpoint,
|
||||
"LocationInput.matchpoint",
|
||||
true
|
||||
)
|
||||
|
||||
public readonly snappedOnto: UIEventSource<any> = new UIEventSource<any>(undefined)
|
||||
public readonly snappedOnto: UIEventSource<Feature & { properties : { id : WayId} }> = new UIEventSource(undefined)
|
||||
public readonly _matching_layer: LayerConfig
|
||||
public readonly leafletMap: UIEventSource<any>
|
||||
public readonly bounds
|
||||
public readonly location
|
||||
private _centerLocation: UIEventSource<Loc>
|
||||
private readonly _centerLocation: UIEventSource<Loc>
|
||||
private readonly mapBackground: UIEventSource<BaseLayer>
|
||||
/**
|
||||
* The features to which the input should be snapped
|
||||
* @private
|
||||
*/
|
||||
private readonly _snapTo: Store<{ feature: any }[]>
|
||||
private readonly _snapTo: Store< (Feature<LineString | Polygon> & {properties: {id : WayId}})[]>
|
||||
/**
|
||||
* The features to which the input should be snapped without cleanup of relations and memberships
|
||||
* Used for rendering
|
||||
* @private
|
||||
*/
|
||||
private readonly _snapToRaw: Store< {feature: Feature}[]>
|
||||
private readonly _value: Store<Loc>
|
||||
private readonly _snappedPoint: Store<any>
|
||||
private readonly _maxSnapDistance: number
|
||||
|
@ -47,33 +58,80 @@ export default class LocationInput
|
|||
private readonly map: BaseUIElement & MinimapObj
|
||||
private readonly clickLocation: UIEventSource<Loc>
|
||||
private readonly _minZoom: number
|
||||
private readonly _state: {
|
||||
readonly filteredLayers: Store<FilteredLayer[]>;
|
||||
readonly backgroundLayer: UIEventSource<BaseLayer>;
|
||||
readonly layoutToUse: LayoutConfig;
|
||||
readonly selectedElement: UIEventSource<any>;
|
||||
readonly allElements: ElementStorage
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
/**
|
||||
* Given a list of geojson-features, will prepare these features to be snappable:
|
||||
* - points are removed
|
||||
* - LineStrings are passed as-is
|
||||
* - Multipolygons are decomposed into their member ways by downloading them
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private static async prepareSnapOnto(features: Feature[]): Promise<(Feature<LineString | Polygon> & {properties : {id: WayId}})[]> {
|
||||
const linesAndPolygon : Feature<LineString | Polygon>[] = <any> features.filter(f => f.geometry.type !== "Point")
|
||||
// Clean the features: multipolygons are split into their it's members
|
||||
const linestrings : (Feature<LineString | Polygon> & {properties: {id: WayId}})[] = []
|
||||
for (const feature of linesAndPolygon) {
|
||||
if(feature.properties.id.startsWith("way")){
|
||||
// A normal way - we continue
|
||||
linestrings.push(<any> feature)
|
||||
continue
|
||||
}
|
||||
|
||||
// We have a multipolygon, thus: a relation
|
||||
// Download the members
|
||||
const relation = await OsmObject.DownloadObjectAsync(<RelationId> feature.properties.id, 60 * 60)
|
||||
const members: OsmWay[] = await Promise.all(relation.members
|
||||
.filter(m => m.type === "way")
|
||||
.map(m => OsmObject.DownloadObjectAsync(<WayId> ("way/"+m.ref), 60 * 60)))
|
||||
linestrings.push(...members.map(m => m.asGeoJson()))
|
||||
}
|
||||
return linestrings
|
||||
|
||||
}
|
||||
|
||||
constructor(options?: {
|
||||
minZoom?: number
|
||||
mapBackground?: UIEventSource<BaseLayer>
|
||||
snapTo?: UIEventSource<{ feature: any }[]>
|
||||
snapTo?: UIEventSource<{ feature: Feature }[]>
|
||||
maxSnapDistance?: number
|
||||
snappedPointTags?: any
|
||||
requiresSnapping?: boolean
|
||||
centerLocation: UIEventSource<Loc>
|
||||
centerLocation?: UIEventSource<Loc>
|
||||
bounds?: UIEventSource<BBox>
|
||||
state?: {
|
||||
readonly filteredLayers: Store<FilteredLayer[]>;
|
||||
readonly backgroundLayer: UIEventSource<BaseLayer>;
|
||||
readonly layoutToUse: LayoutConfig;
|
||||
readonly selectedElement: UIEventSource<any>;
|
||||
readonly allElements: ElementStorage
|
||||
}
|
||||
}) {
|
||||
super()
|
||||
this._snapTo = options.snapTo?.map((features) =>
|
||||
features?.filter((feat) => feat.feature.geometry.type !== "Point")
|
||||
)
|
||||
this._maxSnapDistance = options.maxSnapDistance
|
||||
this._centerLocation = options.centerLocation
|
||||
this._snappedPointTags = options.snappedPointTags
|
||||
this._bounds = options.bounds
|
||||
this._minZoom = options.minZoom
|
||||
this._snapToRaw = options?.snapTo?.map(feats => feats.filter(f => f.feature.geometry.type !== "Point"))
|
||||
this._snapTo = options?.snapTo?.bind((features) => UIEventSource.FromPromise(LocationInput.prepareSnapOnto(features.map(f => f.feature))))?.map(f => f ?? [])
|
||||
this._maxSnapDistance = options?.maxSnapDistance
|
||||
this._centerLocation = options?.centerLocation ?? new UIEventSource<Loc>({
|
||||
lat: 0, lon: 0, zoom: 0
|
||||
})
|
||||
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) {
|
||||
|
@ -86,36 +144,39 @@ export default class LocationInput
|
|||
this._matching_layer = LocationInput.matchLayer
|
||||
}
|
||||
|
||||
this._snappedPoint = options.centerLocation.map(
|
||||
// Calculate the location of the point based by snapping it onto a way
|
||||
// As a side-effect, the actual snapped-onto way (if any) is saved into 'snappedOnto'
|
||||
this._snappedPoint = this._centerLocation.map(
|
||||
(loc) => {
|
||||
if (loc === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
// We reproject the location onto every 'snap-to-feature' and select the closest
|
||||
|
||||
let min = undefined
|
||||
let matchedWay = undefined
|
||||
let matchedWay: Feature<LineString | Polygon> & {properties : {id : WayId}} = undefined
|
||||
for (const feature of self._snapTo.data ?? []) {
|
||||
try {
|
||||
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [
|
||||
const nearestPointOnLine = GeoOperations.nearestPoint(feature, [
|
||||
loc.lon,
|
||||
loc.lat,
|
||||
])
|
||||
if (min === undefined) {
|
||||
min = nearestPointOnLine
|
||||
matchedWay = feature.feature
|
||||
matchedWay = feature
|
||||
continue
|
||||
}
|
||||
|
||||
if (min.properties.dist > nearestPointOnLine.properties.dist) {
|
||||
min = nearestPointOnLine
|
||||
matchedWay = feature.feature
|
||||
matchedWay = feature
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Snapping to a nearest point failed for ",
|
||||
feature.feature,
|
||||
feature,
|
||||
"due to ",
|
||||
e
|
||||
)
|
||||
|
@ -123,18 +184,25 @@ export default class LocationInput
|
|||
}
|
||||
|
||||
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
|
||||
if (options.requiresSnapping) {
|
||||
if (options?.requiresSnapping) {
|
||||
return undefined
|
||||
} else {
|
||||
// No match found - the original coordinates are returned as is
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: options.snappedPointTags ?? min.properties,
|
||||
geometry: { type: "Point", coordinates: [loc.lon, loc.lat] },
|
||||
properties: options?.snappedPointTags ?? min.properties,
|
||||
geometry: {type: "Point", coordinates: [loc.lon, loc.lat]},
|
||||
}
|
||||
}
|
||||
}
|
||||
min.properties = options.snappedPointTags ?? min.properties
|
||||
self.snappedOnto.setData(matchedWay)
|
||||
min.properties = options?.snappedPointTags ?? min.properties
|
||||
if(matchedWay.properties.id.startsWith("relation/")){
|
||||
// We matched a relation instead of a way
|
||||
console.log("Snapping onto a relation. The relation is", matchedWay)
|
||||
|
||||
|
||||
}
|
||||
self.snappedOnto.setData(<any> matchedWay)
|
||||
return min
|
||||
},
|
||||
[this._snapTo]
|
||||
|
@ -149,14 +217,14 @@ export default class LocationInput
|
|||
}
|
||||
})
|
||||
}
|
||||
this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer
|
||||
this.mapBackground = options?.mapBackground ?? this._state?.backgroundLayer ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
|
||||
this.SetClass("block h-full")
|
||||
|
||||
this.clickLocation = new UIEventSource<Loc>(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,
|
||||
|
@ -177,15 +245,11 @@ export default class LocationInput
|
|||
this.map.installBounds(factor, showRange)
|
||||
}
|
||||
|
||||
TakeScreenshot(): Promise<any> {
|
||||
return this.map.TakeScreenshot()
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
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)
|
||||
|
@ -201,21 +265,21 @@ export default class LocationInput
|
|||
this.clickLocation.addCallbackAndRunD((location) =>
|
||||
this._centerLocation.setData(location)
|
||||
)
|
||||
if (this._snapTo !== undefined) {
|
||||
if (this._snapToRaw !== undefined) {
|
||||
// Show the lines to snap to
|
||||
console.log("Constructing the snap-to layer", this._snapTo)
|
||||
console.log("Constructing the snap-to layer", this._snapToRaw)
|
||||
new ShowDataMultiLayer({
|
||||
features: StaticFeatureSource.fromDateless(this._snapTo),
|
||||
features: StaticFeatureSource.fromDateless(this._snapToRaw),
|
||||
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)
|
||||
|
||||
|
@ -224,8 +288,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(
|
||||
|
@ -270,4 +334,11 @@ export default class LocationInput
|
|||
.ConstructElement()
|
||||
}
|
||||
}
|
||||
|
||||
TakeScreenshot(format: "image"): Promise<string>;
|
||||
TakeScreenshot(format: "blob"): Promise<Blob>;
|
||||
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob>;
|
||||
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> {
|
||||
return this.map.TakeScreenshot(format)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,16 +10,15 @@ import Toggle from "./Input/Toggle"
|
|||
|
||||
export default class LanguagePicker extends Toggle {
|
||||
constructor(languages: string[], label: string | BaseUIElement = "") {
|
||||
console.log("Constructing a language pîcker for languages", languages)
|
||||
if (languages === undefined || languages.length <= 1) {
|
||||
super(undefined, undefined, undefined)
|
||||
return undefined
|
||||
}else {
|
||||
const normalPicker = LanguagePicker.dropdownFor(languages, label)
|
||||
const fullPicker = new Lazy(() => LanguagePicker.dropdownFor(allLanguages, label))
|
||||
super(fullPicker, normalPicker, Locale.showLinkToWeblate)
|
||||
const allLanguages: string[] = used_languages.languages
|
||||
}
|
||||
|
||||
const allLanguages: string[] = used_languages.languages
|
||||
|
||||
const normalPicker = LanguagePicker.dropdownFor(languages, label)
|
||||
const fullPicker = new Lazy(() => LanguagePicker.dropdownFor(allLanguages, label))
|
||||
super(fullPicker, normalPicker, Locale.showLinkToWeblate)
|
||||
}
|
||||
|
||||
private static dropdownFor(languages: string[], label: string | BaseUIElement): BaseUIElement {
|
||||
|
|
|
@ -18,6 +18,7 @@ import Title from "../Base/Title"
|
|||
import { GlobalFilter } from "../../Logic/State/MapState"
|
||||
import { VariableUiElement } from "../Base/VariableUIElement"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import {WayId} from "../../Models/OsmFeature";
|
||||
|
||||
export default class ConfirmLocationOfPoint extends Combine {
|
||||
constructor(
|
||||
|
@ -35,7 +36,7 @@ export default class ConfirmLocationOfPoint extends Combine {
|
|||
confirm: (
|
||||
tags: any[],
|
||||
location: { lat: number; lon: number },
|
||||
snapOntoWayId: string
|
||||
snapOntoWayId: WayId | undefined
|
||||
) => void,
|
||||
cancel: () => void,
|
||||
closePopup: () => void
|
||||
|
@ -75,6 +76,7 @@ export default class ConfirmLocationOfPoint extends Combine {
|
|||
snappedPointTags: tags,
|
||||
maxSnapDistance: preset.preciseInput.maxSnapDistance,
|
||||
bounds: mapBounds,
|
||||
state: <any> state
|
||||
})
|
||||
preciseInput.installBounds(preset.boundsFactor ?? 0.25, true)
|
||||
preciseInput
|
||||
|
|
|
@ -22,6 +22,7 @@ import Title from "../Base/Title"
|
|||
import { SubstitutedTranslation } from "../SubstitutedTranslation"
|
||||
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState"
|
||||
import TagRenderingQuestion from "./TagRenderingQuestion"
|
||||
import {OsmId} from "../../Models/OsmFeature";
|
||||
|
||||
export default class DeleteWizard extends Toggle {
|
||||
/**
|
||||
|
@ -43,7 +44,7 @@ export default class DeleteWizard extends Toggle {
|
|||
* @param state: the state of the application
|
||||
* @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted
|
||||
*/
|
||||
constructor(id: string, state: FeaturePipelineState, options: DeleteConfig) {
|
||||
constructor(id: OsmId, state: FeaturePipelineState, options: DeleteConfig) {
|
||||
const deleteAbility = new DeleteabilityChecker(id, state, options.neededChangesets)
|
||||
const tagsSource = state.allElements.getEventSourceById(id)
|
||||
|
||||
|
|
|
@ -248,31 +248,29 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
|
|||
)
|
||||
|
||||
editElements.push(
|
||||
new VariableUiElement(
|
||||
state.featureSwitchIsDebugging.map((isDebugging) => {
|
||||
if (isDebugging) {
|
||||
const config_all_tags: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{all_tags()}" },
|
||||
""
|
||||
)
|
||||
const config_download: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{export_as_geojson()}" },
|
||||
""
|
||||
)
|
||||
const config_id: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{open_in_iD()}" },
|
||||
""
|
||||
)
|
||||
Toggle.If(state.featureSwitchIsDebugging,
|
||||
() => {
|
||||
const config_all_tags: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{all_tags()}" },
|
||||
""
|
||||
)
|
||||
const config_download: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{export_as_geojson()}" },
|
||||
""
|
||||
)
|
||||
const config_id: TagRenderingConfig = new TagRenderingConfig(
|
||||
{ render: "{open_in_iD()}" },
|
||||
""
|
||||
)
|
||||
|
||||
return new Combine([
|
||||
new TagRenderingAnswer(tags, config_all_tags, state),
|
||||
new TagRenderingAnswer(tags, config_download, state),
|
||||
new TagRenderingAnswer(tags, config_id, state),
|
||||
"This is layer " + layerConfig.id,
|
||||
])
|
||||
}
|
||||
})
|
||||
)
|
||||
return new Combine([
|
||||
new TagRenderingAnswer(tags, config_all_tags, state),
|
||||
new TagRenderingAnswer(tags, config_download, state),
|
||||
new TagRenderingAnswer(tags, config_id, state),
|
||||
"This is layer " + layerConfig.id,
|
||||
])
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return new Combine(editElements).SetClass("flex flex-col")
|
||||
|
|
|
@ -145,6 +145,7 @@ export default class MoveWizard extends Toggle {
|
|||
minZoom: reason.minZoom,
|
||||
centerLocation: loc,
|
||||
mapBackground: new UIEventSource<BaseLayer>(preferredBackground), // We detach the layer
|
||||
state: <any> state
|
||||
})
|
||||
|
||||
if (reason.lockBounds) {
|
||||
|
|
|
@ -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<any>
|
||||
layer?: LayerConfig
|
||||
doShowLayer?: UIEventSource<boolean>
|
||||
}) {
|
||||
}, 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +1,27 @@
|
|||
/**
|
||||
* The statistics-gui shows statistics from previous MapComplete-edits
|
||||
*/
|
||||
import { UIEventSource } from "../Logic/UIEventSource"
|
||||
import { VariableUiElement } from "./Base/VariableUIElement"
|
||||
import {UIEventSource} from "../Logic/UIEventSource"
|
||||
import {VariableUiElement} from "./Base/VariableUIElement"
|
||||
import Loading from "./Base/Loading"
|
||||
import { Utils } from "../Utils"
|
||||
import {Utils} from "../Utils"
|
||||
import Combine from "./Base/Combine"
|
||||
import { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
|
||||
import { LayerFilterPanel } from "./BigComponents/FilterView"
|
||||
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
|
||||
import {StackedRenderingChart} from "./BigComponents/TagRenderingChart"
|
||||
import {LayerFilterPanel} from "./BigComponents/FilterView"
|
||||
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"
|
||||
import MapState from "../Logic/State/MapState"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import Title from "./Base/Title"
|
||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||
import List from "./Base/List";
|
||||
|
||||
class StatisticsForOverviewFile extends Combine {
|
||||
|
||||
constructor(homeUrl: string, paths: string[]) {
|
||||
paths = paths.filter(p => !p.endsWith("file-overview.json"))
|
||||
const layer = AllKnownLayouts.allKnownLayouts.get("mapcomplete-changes").layers[0]
|
||||
const filteredLayer = MapState.InitializeFilteredLayers(
|
||||
{ id: "statistics-view", layers: [layer] },
|
||||
{id: "statistics-view", layers: [layer]},
|
||||
undefined
|
||||
)[0]
|
||||
const filterPanel = new LayerFilterPanel(undefined, filteredLayer)
|
||||
|
@ -27,9 +30,18 @@ class StatisticsForOverviewFile extends Combine {
|
|||
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
|
||||
|
||||
for (const filepath of paths) {
|
||||
if(filepath.endsWith("file-overview.json")){
|
||||
continue
|
||||
}
|
||||
Utils.downloadJson(homeUrl + filepath).then((data) => {
|
||||
if (data === undefined) {
|
||||
return
|
||||
}
|
||||
if (data.features === undefined) {
|
||||
data.features = data
|
||||
}
|
||||
data?.features?.forEach((item) => {
|
||||
item.properties = { ...item.properties, ...item.properties.metadata }
|
||||
item.properties = {...item.properties, ...item.properties.metadata}
|
||||
delete item.properties.metadata
|
||||
})
|
||||
downloaded.data.push(data)
|
||||
|
@ -43,6 +55,7 @@ class StatisticsForOverviewFile extends Combine {
|
|||
)
|
||||
)
|
||||
|
||||
|
||||
super([
|
||||
filterPanel,
|
||||
new VariableUiElement(
|
||||
|
@ -83,7 +96,49 @@ class StatisticsForOverviewFile extends Combine {
|
|||
const trs = layer.tagRenderings.filter(
|
||||
(tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined
|
||||
)
|
||||
const elements: BaseUIElement[] = []
|
||||
|
||||
const allKeys = new Set<string>()
|
||||
for (const cs of overview._meta) {
|
||||
for (const propertiesKey in cs.properties) {
|
||||
allKeys.add(propertiesKey)
|
||||
}
|
||||
}
|
||||
console.log("All keys:", allKeys)
|
||||
|
||||
const valuesToSum = [
|
||||
"create",
|
||||
"modify",
|
||||
"delete",
|
||||
"answer",
|
||||
"move",
|
||||
"deletion",
|
||||
"add-image",
|
||||
"plantnet-ai-detection",
|
||||
"import",
|
||||
"conflation",
|
||||
"link-image",
|
||||
"soft-delete"]
|
||||
|
||||
const allThemes = Utils.Dedup(overview._meta.map(f => f.properties.theme))
|
||||
|
||||
const excludedThemes = new Set<string>()
|
||||
if(allThemes.length > 1){
|
||||
excludedThemes.add("grb")
|
||||
excludedThemes.add("etymology")
|
||||
}
|
||||
const summedValues = valuesToSum
|
||||
.map(key => [key, overview.sum(key, excludedThemes)])
|
||||
.filter(kv => kv[1] != 0)
|
||||
.map(kv => kv.join(": "))
|
||||
const elements: BaseUIElement[] = [
|
||||
new Title(allThemes .length === 1 ? "General statistics for "+allThemes[0] :"General statistics (excluding etymology- and GRB-theme changes)"),
|
||||
new Combine([
|
||||
overview._meta.length + " changesets match the filters",
|
||||
new List(summedValues)
|
||||
]).SetClass("flex flex-col border rounded-xl"),
|
||||
|
||||
new Title("Breakdown")
|
||||
]
|
||||
for (const tr of trs) {
|
||||
let total = undefined
|
||||
if (tr.freeform?.key !== undefined) {
|
||||
|
@ -186,6 +241,20 @@ class ChangesetsOverview {
|
|||
return new ChangesetsOverview(this._meta.filter(predicate))
|
||||
}
|
||||
|
||||
public sum(key: string, excludeThemes: Set<string>): number {
|
||||
let s = 0
|
||||
for (const feature of this._meta) {
|
||||
if(excludeThemes.has(feature.properties.theme)){
|
||||
continue
|
||||
}
|
||||
const parsed = Number(feature.properties[key])
|
||||
if (!isNaN(parsed)) {
|
||||
s += parsed
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
|
||||
if (cs === undefined) {
|
||||
return undefined
|
||||
|
@ -211,7 +280,8 @@ class ChangesetsOverview {
|
|||
}
|
||||
try {
|
||||
cs.properties.host = new URL(cs.properties.host).host
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
}
|
||||
return cs
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ export default class Translations {
|
|||
* translation.textFor("nl") // => "Nederlands"
|
||||
*
|
||||
*/
|
||||
static T(t: string | any, context = undefined): TypedTranslation<object> {
|
||||
static T(t: string | undefined | null | Translation | TypedTranslation<object>, context = undefined): TypedTranslation<object> {
|
||||
if (t === undefined || t === null) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ export default class Translations {
|
|||
if (typeof t === "string") {
|
||||
return new TypedTranslation<object>({ "*": t }, context)
|
||||
}
|
||||
if (t.render !== undefined) {
|
||||
if (t["render"] !== undefined) {
|
||||
const msg =
|
||||
"Creating a translation, but this object contains a 'render'-field. Use the translation directly"
|
||||
console.error(msg, t)
|
||||
|
|
12
Utils.ts
12
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)
|
||||
|
|
113
Utils/pngMapCreator.ts
Normal file
113
Utils/pngMapCreator.ts
Normal file
|
@ -0,0 +1,113 @@
|
|||
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
|
||||
import MinimapImplementation from "../UI/Base/MinimapImplementation";
|
||||
import {UIEventSource} from "../Logic/UIEventSource";
|
||||
import Loc from "../Models/Loc";
|
||||
import ShowDataLayer from "../UI/ShowDataLayer/ShowDataLayer";
|
||||
import {BBox} from "../Logic/BBox";
|
||||
import Minimap from "../UI/Base/Minimap";
|
||||
import AvailableBaseLayers from "../Logic/Actors/AvailableBaseLayers";
|
||||
import {Utils} from "../Utils";
|
||||
|
||||
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 | undefined;
|
||||
private readonly _options: PngMapCreatorOptions;
|
||||
|
||||
constructor(state: FeaturePipelineState | undefined, options: PngMapCreatorOptions) {
|
||||
this._state = state;
|
||||
this._options = {...options, scaling: options.scaling ?? 1};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimap, waits till all needed tiles are loaded before returning
|
||||
* @private
|
||||
*/
|
||||
private async createAndLoadMinimap(): Promise<MinimapImplementation> {
|
||||
const state = this._state;
|
||||
const options = this._options
|
||||
const baselayer = AvailableBaseLayers.layerOverview.find(bl => bl.id === state.layoutToUse.defaultBackgroundId) ?? AvailableBaseLayers.osmCarto
|
||||
return new Promise(resolve => {
|
||||
const minimap = Minimap.createMiniMap({
|
||||
location: new UIEventSource<Loc>(state.locationControl.data), // We remove the link between the old and the new UI-event source as moving the map while the export is running fucks up the screenshot
|
||||
background: new UIEventSource(baselayer),
|
||||
allowMoving: false,
|
||||
onFullyLoaded: (_) =>
|
||||
window.setTimeout(() => {
|
||||
resolve(<MinimapImplementation>minimap)
|
||||
}, 250)
|
||||
})
|
||||
const style = `width: ${options.width * options.scaling}mm; height: ${options.height * options.scaling}mm;`
|
||||
minimap.SetStyle(style)
|
||||
minimap.AttachTo(options.divId)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a base64-encoded PNG image
|
||||
* @constructor
|
||||
*/
|
||||
public async CreatePng(format: "image" ): Promise<string > ;
|
||||
public async CreatePng(format: "blob"): Promise<Blob> ;
|
||||
public async CreatePng(format: "image" | "blob"): Promise<string | Blob>;
|
||||
public async CreatePng(format: "image" | "blob"): Promise<string | Blob> {
|
||||
|
||||
// Lets first init the minimap and wait for all background tiles to load
|
||||
const minimap = await this.createAndLoadMinimap()
|
||||
const state = this._state
|
||||
const dummyMode = this._options.dummyMode ?? false
|
||||
return new Promise<string | Blob>((resolve, reject) => {
|
||||
// Next: we prepare the features. Only fully contained features are shown
|
||||
minimap.leafletMap.addCallbackAndRunD(async (leaflet) => {
|
||||
// Ping the featurepipeline to download what is needed
|
||||
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.sufficientlyZoomed.data){
|
||||
console.warn("Not sufficiently zoomed!")
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (tile.layer.layerDef.id.startsWith("note_import")) {
|
||||
// Don't export notes to import
|
||||
return
|
||||
}
|
||||
new ShowDataLayer({
|
||||
features: tile,
|
||||
leafletMap: minimap.leafletMap,
|
||||
layerToShow: tile.layer.layerDef,
|
||||
doShowLayer: tile.layer.isDisplayed,
|
||||
state: undefined,
|
||||
})
|
||||
})
|
||||
await Utils.waitFor(2000)
|
||||
}
|
||||
minimap.TakeScreenshot(format).then(async result => {
|
||||
const divId = this._options.divId
|
||||
await Utils.waitFor(250)
|
||||
document.getElementById(divId).removeChild(/*Will fetch the cached htmlelement:*/minimap.ConstructElement())
|
||||
return resolve(result);
|
||||
}).catch(failreason => {
|
||||
console.error("Could no make a screenshot due to ",failreason)
|
||||
reject(failreason)
|
||||
})
|
||||
})
|
||||
|
||||
state.AddAllOverlaysToMap(minimap.leafletMap)
|
||||
})
|
||||
}
|
||||
}
|
948
Utils/svgToPdf.ts
Normal file
948
Utils/svgToPdf.ts
Normal file
|
@ -0,0 +1,948 @@
|
|||
import jsPDF, {Matrix} from "jspdf";
|
||||
import {Translation, TypedTranslation} from "../UI/i18n/Translation";
|
||||
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
|
||||
import {PngMapCreator} from "./pngMapCreator";
|
||||
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts";
|
||||
import {Store} 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 {makeAbsolute, parseSVG} from 'svg-path-parser';
|
||||
import Translations from "../UI/i18n/Translations";
|
||||
import {Utils} from "../Utils";
|
||||
import Constants from "../Models/Constants";
|
||||
import Hash from "../Logic/Web/Hash";
|
||||
|
||||
class SvgToPdfInternals {
|
||||
private readonly doc: jsPDF;
|
||||
private static readonly dummyDoc: jsPDF = new jsPDF()
|
||||
private readonly matrices: Matrix[] = []
|
||||
private readonly matricesInverted: Matrix[] = []
|
||||
|
||||
private currentMatrix: Matrix;
|
||||
private currentMatrixInverted: Matrix;
|
||||
|
||||
private readonly _images: Record<string, HTMLImageElement>;
|
||||
private readonly _rects: Record<string, SVGRectElement>;
|
||||
private readonly extractTranslation: (string) => string;
|
||||
|
||||
constructor(advancedApi: jsPDF, images: Record<string, HTMLImageElement>, rects: Record<string, SVGRectElement>, extractTranslation: (string) => string) {
|
||||
this.doc = advancedApi;
|
||||
this._images = images;
|
||||
this._rects = rects;
|
||||
this.extractTranslation = s => extractTranslation(s)?.replace(/ /g, " ");
|
||||
this.currentMatrix = this.doc.unitMatrix;
|
||||
this.currentMatrixInverted = this.doc.unitMatrix;
|
||||
}
|
||||
|
||||
applyMatrices(): void {
|
||||
let multiplied = this.doc.unitMatrix;
|
||||
let multipliedInv = this.doc.unitMatrix;
|
||||
for (const matrix of this.matrices) {
|
||||
multiplied = this.doc.matrixMult(multiplied, matrix)
|
||||
}
|
||||
for (const matrix of this.matricesInverted) {
|
||||
multipliedInv = this.doc.matrixMult(multiplied, matrix)
|
||||
}
|
||||
this.currentMatrix = multiplied
|
||||
this.currentMatrixInverted = multipliedInv
|
||||
}
|
||||
|
||||
addMatrix(m: Matrix) {
|
||||
this.matrices.push(m)
|
||||
this.matricesInverted.push(m.inversed())
|
||||
this.doc.setCurrentTransformationMatrix(m);
|
||||
this.applyMatrices()
|
||||
|
||||
}
|
||||
|
||||
public static extractMatrix(element: Element): Matrix {
|
||||
|
||||
const t = element.getAttribute("transform")
|
||||
if (t === null) {
|
||||
return null;
|
||||
}
|
||||
const scaleMatch = t.match(/scale\(([-0-9.]+)\)/)
|
||||
if (scaleMatch !== null) {
|
||||
const s = Number(scaleMatch[1])
|
||||
return SvgToPdfInternals.dummyDoc.Matrix(1 / s, 0, 0, 1 / s, 0, 0);
|
||||
}
|
||||
|
||||
const translateMatch = t.match(/translate\(([-0-9.]+), ?([-0-9.]*)\)/)
|
||||
if (translateMatch !== null) {
|
||||
const dx = Number(translateMatch[1])
|
||||
const dy = Number(translateMatch[2])
|
||||
console.log("Translating", dx, dy)
|
||||
return SvgToPdfInternals.dummyDoc.Matrix(1, 0, 0, 1, dx, dy);
|
||||
}
|
||||
|
||||
|
||||
const transformMatch = t.match(/matrix\(([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*)\)/)
|
||||
if (transformMatch !== null) {
|
||||
const vals = [1, 0, 0, 1, 0, 0]
|
||||
const invVals = [1, 0, 0, 1, 0, 0]
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const ti = Number(transformMatch[i + 1])
|
||||
if (ti == 0) {
|
||||
vals[i] = 0
|
||||
} else {
|
||||
invVals[i] = 1 / ti
|
||||
vals[i] = ti
|
||||
}
|
||||
}
|
||||
return SvgToPdfInternals.dummyDoc.Matrix(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public setTransform(element: Element): boolean {
|
||||
|
||||
const m = SvgToPdfInternals.extractMatrix(element)
|
||||
if (m === null) {
|
||||
return false;
|
||||
}
|
||||
this.addMatrix(m)
|
||||
return true;
|
||||
}
|
||||
|
||||
public undoTransform(): void {
|
||||
this.matrices.pop()
|
||||
const i = this.matricesInverted.pop()
|
||||
this.doc.setCurrentTransformationMatrix(i)
|
||||
this.applyMatrices()
|
||||
|
||||
}
|
||||
|
||||
public static parseCss(styleContent: string, separator: string = ";"): Record<string, string> {
|
||||
if (styleContent === undefined || styleContent === null) {
|
||||
return {}
|
||||
}
|
||||
const r: Record<string, string> = {}
|
||||
|
||||
for (const rule of styleContent.split(separator)) {
|
||||
const [k, v] = rule.split(":").map(x => x.trim())
|
||||
r[k] = v
|
||||
}
|
||||
|
||||
return r
|
||||
};
|
||||
|
||||
private drawRect(element: SVGRectElement) {
|
||||
const x = Number(element.getAttribute("x"))
|
||||
const y = Number(element.getAttribute("y"))
|
||||
const width = Number(element.getAttribute("width"))
|
||||
const height = Number(element.getAttribute("height"))
|
||||
const ry = SvgToPdfInternals.attrNumber(element, "ry", false) ?? 0
|
||||
const rx = SvgToPdfInternals.attrNumber(element, "rx", false) ?? 0
|
||||
const css = SvgToPdfInternals.css(element)
|
||||
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
|
||||
this.doc.setFillColor(css["fill"] ?? "black")
|
||||
this.doc.roundedRect(x, y, width, height, rx, ry, "F")
|
||||
}
|
||||
if (css["stroke"] && css["stroke"] !== "none") {
|
||||
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
|
||||
this.doc.setDrawColor(css["stroke"] ?? "black")
|
||||
this.doc.roundedRect(x, y, width, height, rx, ry, "S")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private drawCircle(element: SVGCircleElement) {
|
||||
const x = Number(element.getAttribute("cx"))
|
||||
const y = Number(element.getAttribute("cy"))
|
||||
const r = Number(element.getAttribute("r"))
|
||||
const css = SvgToPdfInternals.css(element)
|
||||
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
|
||||
this.doc.setFillColor(css["fill"] ?? "black")
|
||||
this.doc.circle(x, y, r, "F")
|
||||
|
||||
}
|
||||
if (css["stroke"] && css["stroke"] !== "none") {
|
||||
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
|
||||
this.doc.setDrawColor(css["stroke"] ?? "black")
|
||||
this.doc.circle(x, y, r, "S")
|
||||
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
private static attr(element: Element, name: string, recurseup: boolean = true): string | undefined {
|
||||
if (element === null || element === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const a = element.getAttribute(name)
|
||||
if (a !== null && a !== undefined) {
|
||||
return a
|
||||
}
|
||||
if (recurseup && element.parentElement !== undefined && element.parentElement !== element) {
|
||||
return SvgToPdfInternals.attr(element.parentElement, name, recurseup)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the 'style'-element recursively
|
||||
* @param element
|
||||
* @private
|
||||
*/
|
||||
private static css(element: Element): Record<string, string> {
|
||||
|
||||
if (element.parentElement == undefined || element.parentElement == element) {
|
||||
return SvgToPdfInternals.parseCss(element.getAttribute("style"))
|
||||
}
|
||||
|
||||
const css = SvgToPdfInternals.css(element.parentElement);
|
||||
const style = element.getAttribute("style")
|
||||
if (style === undefined || style == null) {
|
||||
return css
|
||||
}
|
||||
for (const rule of style.split(";")) {
|
||||
const [k, v] = rule.split(":").map(x => x.trim())
|
||||
css[k] = v
|
||||
}
|
||||
return css
|
||||
|
||||
}
|
||||
|
||||
static attrNumber(element: Element, name: string, recurseup: boolean = true): number {
|
||||
const a = SvgToPdfInternals.attr(element, name, recurseup)
|
||||
const n = parseFloat(a)
|
||||
if (!isNaN(n)) {
|
||||
return n
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private drawTspan(tspan: Element) {
|
||||
if (tspan.textContent == "") {
|
||||
return
|
||||
}
|
||||
const x = SvgToPdfInternals.attrNumber(tspan, "x")
|
||||
const y = SvgToPdfInternals.attrNumber(tspan, "y")
|
||||
|
||||
const css = SvgToPdfInternals.css(tspan)
|
||||
let maxWidth: number = undefined
|
||||
if (css["shape-inside"]) {
|
||||
const matched = css["shape-inside"].match(/url\(#([a-zA-Z0-9-]+)\)/)
|
||||
if (matched !== null) {
|
||||
const rectId = matched[1]
|
||||
const rect = this._rects[rectId]
|
||||
maxWidth = SvgToPdfInternals.attrNumber(rect, "width", false)
|
||||
}
|
||||
}
|
||||
|
||||
let fontFamily = css["font-family"] ?? "Ubuntu";
|
||||
if (fontFamily === "sans-serif") {
|
||||
fontFamily = "Ubuntu"
|
||||
}
|
||||
|
||||
let fontWeight = css["font-weight"] ?? "normal";
|
||||
this.doc.setFont(fontFamily, fontWeight)
|
||||
|
||||
|
||||
const fontColor = css["fill"]
|
||||
if (fontColor) {
|
||||
this.doc.setTextColor(fontColor)
|
||||
} else {
|
||||
this.doc.setTextColor("black")
|
||||
}
|
||||
let fontsize = parseFloat(css["font-size"])
|
||||
|
||||
this.doc.setFontSize(fontsize * 2.5)
|
||||
|
||||
let textTemplate = tspan.textContent.split(" ")
|
||||
let result: string = ""
|
||||
let addSpace = false
|
||||
for (let text of textTemplate) {
|
||||
if (text === "\\n") {
|
||||
result += "\n"
|
||||
addSpace = false
|
||||
continue
|
||||
}
|
||||
if (text === "\\n\\n") {
|
||||
result += "\n\n"
|
||||
addSpace = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!text.startsWith("$")) {
|
||||
if (addSpace) {
|
||||
result += " "
|
||||
}
|
||||
result += text
|
||||
addSpace = true
|
||||
continue
|
||||
}
|
||||
const list = text.match(/\$list\(([a-zA-Z0-9_.-]+)\)/)
|
||||
if (list) {
|
||||
const key = list[1]
|
||||
console.log("Generating a list with key" + key)
|
||||
let r = this.extractTranslation("$" + key + "0");
|
||||
let i = 0
|
||||
result += "\n"
|
||||
while (r !== undefined && i < 100) {
|
||||
result += "• " + r + "\n"
|
||||
i++
|
||||
r = this.extractTranslation("$" + key + i);
|
||||
}
|
||||
result += "\n"
|
||||
addSpace = false
|
||||
} else {
|
||||
const found = this.extractTranslation(text) ?? text
|
||||
if (addSpace) {
|
||||
result += " "
|
||||
}
|
||||
result += found
|
||||
addSpace = true
|
||||
}
|
||||
}
|
||||
this.doc.text(result, x, y, {
|
||||
maxWidth,
|
||||
}, this.currentMatrix)
|
||||
|
||||
}
|
||||
|
||||
private drawSvgViaCanvas(element: Element): void {
|
||||
const x = SvgToPdfInternals.attrNumber(element, "x")
|
||||
const y = SvgToPdfInternals.attrNumber(element, "y")
|
||||
const width = SvgToPdfInternals.attrNumber(element, "width")
|
||||
const height = SvgToPdfInternals.attrNumber(element, "height")
|
||||
const base64src = SvgToPdfInternals.attr(element, "xlink:href")
|
||||
const svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length));
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(svgXml, "text/xml");
|
||||
const svgRoot = xmlDoc.getElementsByTagName("svg")[0];
|
||||
const svgWidth = SvgToPdfInternals.attrNumber(svgRoot, "width")
|
||||
const svgHeight = SvgToPdfInternals.attrNumber(svgRoot, "height")
|
||||
|
||||
|
||||
let img = this._images[base64src]
|
||||
// This is an svg image, we use the canvas to convert it to a png
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
|
||||
canvas.width = svgWidth
|
||||
canvas.height = svgHeight
|
||||
img.style.width = `${(svgWidth)}px`
|
||||
img.style.height = `${(svgHeight)}px`
|
||||
|
||||
ctx.drawImage(img, 0, 0, svgWidth, svgHeight)
|
||||
const base64img = canvas.toDataURL("image/png")
|
||||
|
||||
this.addMatrix(this.doc.Matrix(width / svgWidth, 0, 0, height / svgHeight, 0, 0))
|
||||
const p = this.currentMatrixInverted.applyToPoint({x, y})
|
||||
this.doc.addImage(base64img, "png", p.x * svgWidth / width, p.y * svgHeight / height, svgWidth, svgHeight)
|
||||
this.undoTransform()
|
||||
}
|
||||
|
||||
private drawImage(element: Element): void {
|
||||
const href = SvgToPdfInternals.attr(element, "xlink:href")
|
||||
if (href.endsWith('svg') || href.startsWith("data:image/svg")) {
|
||||
this.drawSvgViaCanvas(element);
|
||||
} else {
|
||||
const x = SvgToPdfInternals.attrNumber(element, "x")
|
||||
const y = SvgToPdfInternals.attrNumber(element, "y")
|
||||
const width = SvgToPdfInternals.attrNumber(element, "width")
|
||||
const height = SvgToPdfInternals.attrNumber(element, "height")
|
||||
const base64src = SvgToPdfInternals.attr(element, "xlink:href")
|
||||
|
||||
this.doc.addImage(base64src, x, y, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (c.code === "H") {
|
||||
const command = {op: "l", c: [c.x, c.y]}
|
||||
this.doc.path([command])
|
||||
continue
|
||||
}
|
||||
|
||||
if (c.code === "V") {
|
||||
const command = {op: "l", c: [c.x, c.y]}
|
||||
this.doc.path([command])
|
||||
continue
|
||||
}
|
||||
|
||||
this.doc.path([{op: c.code.toLowerCase(), c: [c.x, c.y]}])
|
||||
}
|
||||
//"fill:#ffffff;stroke:#000000;stroke-width:0.8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:20"
|
||||
|
||||
const css = SvgToPdfInternals.css(element)
|
||||
if (css["color"] && css["color"].toLowerCase() !== "none") {
|
||||
this.doc.setDrawColor(css["color"])
|
||||
}
|
||||
if (css["stroke-width"]) {
|
||||
this.doc.setLineWidth(parseFloat(css["stroke-width"]))
|
||||
}
|
||||
if (css["stroke-linejoin"] !== undefined) {
|
||||
this.doc.setLineJoin(css["stroke-linejoin"])
|
||||
}
|
||||
let doFill = false
|
||||
if (css["fill-rule"] === "evenodd") {
|
||||
this.doc.fillEvenOdd()
|
||||
} else if (css["fill"] && css["fill"] !== "none") {
|
||||
this.doc.setFillColor(css["fill"])
|
||||
doFill = true
|
||||
}
|
||||
|
||||
if (css["stroke"] && css["stroke"] !== "none") {
|
||||
this.doc.setDrawColor(css["stroke"])
|
||||
if (doFill) {
|
||||
this.doc.fillStroke()
|
||||
} else {
|
||||
this.doc.stroke()
|
||||
}
|
||||
} else if (doFill) {
|
||||
this.doc.fill()
|
||||
}
|
||||
}
|
||||
|
||||
public handleElement(element: SVGSVGElement | Element): void {
|
||||
const isTransformed = this.setTransform(element)
|
||||
try {
|
||||
|
||||
if (element.tagName === "tspan") {
|
||||
if (element.childElementCount == 0) {
|
||||
this.drawTspan(element)
|
||||
} else {
|
||||
for (let child of Array.from(element.children)) {
|
||||
this.handleElement(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element.tagName === "image") {
|
||||
this.drawImage(element)
|
||||
}
|
||||
|
||||
if (element.tagName === "path") {
|
||||
this.drawPath(<any>element)
|
||||
}
|
||||
|
||||
if (element.tagName === "g" || element.tagName === "text") {
|
||||
|
||||
for (let child of Array.from(element.children)) {
|
||||
this.handleElement(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (element.tagName === "rect") {
|
||||
this.drawRect(<any>element)
|
||||
}
|
||||
|
||||
if (element.tagName === "circle") {
|
||||
this.drawCircle(<any>element)
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Could not handle element", element, "due to", e)
|
||||
}
|
||||
if (isTransformed) {
|
||||
this.undoTransform()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to calculate where the given point will end up.
|
||||
* ALl the transforms of the parent elements are taking into account
|
||||
* @param mapSpec
|
||||
* @constructor
|
||||
*/
|
||||
static GetActualXY(mapSpec: SVGTSpanElement): { x: number, y: number } {
|
||||
let runningM = SvgToPdfInternals.dummyDoc.unitMatrix
|
||||
|
||||
let e: Element = mapSpec
|
||||
do {
|
||||
const m = SvgToPdfInternals.extractMatrix(e)
|
||||
if (m !== null) {
|
||||
runningM = SvgToPdfInternals.dummyDoc.matrixMult(runningM, m)
|
||||
}
|
||||
e = e.parentElement
|
||||
} while (e !== null && e.parentElement != e)
|
||||
|
||||
const x = SvgToPdfInternals.attrNumber(mapSpec, "x")
|
||||
const y = SvgToPdfInternals.attrNumber(mapSpec, "y")
|
||||
return runningM.applyToPoint({x, y})
|
||||
}
|
||||
}
|
||||
|
||||
export interface SvgToPdfOptions {
|
||||
getFreeDiv: () => string,
|
||||
disableMaps?: false | true
|
||||
textSubstitutions?: Record<string, string>,
|
||||
beforePage?: (i: number) => void,
|
||||
overrideLocation?: { lat: number, lon: number }
|
||||
}
|
||||
|
||||
|
||||
export class SvgToPdfPage {
|
||||
|
||||
private images: Record<string, HTMLImageElement> = {}
|
||||
private rects: Record<string, SVGRectElement> = {}
|
||||
public readonly _svgRoot: SVGSVGElement;
|
||||
public readonly currentState: Store<string>
|
||||
private readonly importedTranslations: Record<string, string> = {}
|
||||
private readonly layerTranslations: Record<string, Record<string, any>> = {}
|
||||
private readonly options: SvgToPdfOptions
|
||||
|
||||
constructor(page: string, options?: SvgToPdfOptions) {
|
||||
this.options = options ?? (<SvgToPdfOptions>{})
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(page, "image/svg+xml");
|
||||
this._svgRoot = xmlDoc.getElementsByTagName("svg")[0];
|
||||
|
||||
}
|
||||
|
||||
private loadImage(element: Element): Promise<void> {
|
||||
const xlink = element.getAttribute("xlink:href")
|
||||
let img = document.createElement("img")
|
||||
|
||||
if (xlink.startsWith("data:image/svg+xml;")) {
|
||||
const base64src = xlink;
|
||||
let svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length));
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(svgXml, "text/xml");
|
||||
const svgRoot = xmlDoc.getElementsByTagName("svg")[0];
|
||||
const svgWidthStr = svgRoot.getAttribute("width")
|
||||
const svgHeightStr = svgRoot.getAttribute("height")
|
||||
const svgWidth = parseFloat(svgWidthStr)
|
||||
const svgHeight = parseFloat(svgHeightStr)
|
||||
if (!svgWidthStr.endsWith("px")) {
|
||||
svgRoot.setAttribute("width", svgWidth + "px")
|
||||
}
|
||||
if (!svgHeightStr.endsWith("px")) {
|
||||
svgRoot.setAttribute("height", svgHeight + "px")
|
||||
}
|
||||
img.src = "data:image/svg+xml;base64," + btoa(svgRoot.outerHTML)
|
||||
} else {
|
||||
img.src = xlink
|
||||
}
|
||||
|
||||
this.images[xlink] = img
|
||||
return new Promise((resolve) => {
|
||||
img.onload = _ => {
|
||||
resolve()
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
public extractTranslations(): Set<string> {
|
||||
const textContents: string[] = Array.from(this._svgRoot.getElementsByTagName("tspan"))
|
||||
.map(t => t.textContent)
|
||||
const translations = new Set<string>()
|
||||
console.log("Extracting translations, contents are", textContents)
|
||||
for (const tc of textContents) {
|
||||
const parts = tc.split(" ").filter(p => p.startsWith("$") && p.indexOf("(") < 0)
|
||||
for (let part of parts) {
|
||||
part = part.substring(1) // Drop the $
|
||||
let path = part.split(".")
|
||||
const importPath = this.importedTranslations[path[0]]
|
||||
if (importPath) {
|
||||
translations.add(importPath + "." + path.slice(1).join("."))
|
||||
} else {
|
||||
translations.add(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("Translations keys are", translations)
|
||||
return translations
|
||||
}
|
||||
|
||||
public async prepareElement(element: SVGSVGElement | Element, mapTextSpecs: SVGTSpanElement[]): Promise<void> {
|
||||
if (element.tagName === "rect") {
|
||||
this.rects[element.id] = <SVGRectElement>element;
|
||||
}
|
||||
|
||||
if (element.tagName === "image") {
|
||||
await this.loadImage(element)
|
||||
}
|
||||
|
||||
if (element.tagName === "tspan" && element.childElementCount == 0) {
|
||||
const specialValues = element.textContent.split(" ").filter(t => t.startsWith("$"))
|
||||
for (let specialValue of specialValues) {
|
||||
const importMatch = element.textContent.match(/\$import ([a-zA-Z-_0-9.? ]+) as ([a-zA-Z0-9]+)/)
|
||||
if (importMatch !== null) {
|
||||
const [, pathRaw, as] = importMatch
|
||||
this.importedTranslations[as] = pathRaw
|
||||
}
|
||||
const setPropertyMatch = element.textContent.match(/\$set\(([a-zA-Z-_0-9.?:]+),(.+)\)/)
|
||||
if (setPropertyMatch) {
|
||||
this.options.textSubstitutions[setPropertyMatch[1].trim()] = setPropertyMatch[2].trim()
|
||||
console.log("Setting a property:", setPropertyMatch, this.options.textSubstitutions)
|
||||
}
|
||||
if (element.textContent.startsWith("$map(")) {
|
||||
mapTextSpecs.push(<any>element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element.tagName === "g" || element.tagName === "text" || element.tagName === "tspan" || element.tagName === "defs") {
|
||||
|
||||
for (let child of Array.from(element.children)) {
|
||||
await this.prepareElement(child, mapTextSpecs)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private _isPrepared = false;
|
||||
|
||||
private async prepareMap(mapSpec: SVGTSpanElement,): Promise<void> {
|
||||
// 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], ",")
|
||||
|
||||
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 = 600
|
||||
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
|
||||
for (const paramsKey in params) {
|
||||
if (paramsKey.startsWith("layer-")) {
|
||||
const layerName = paramsKey.substring("layer-".length)
|
||||
const key = params[paramsKey].toLowerCase().trim()
|
||||
const layer = layout.layers.find(l => l.id === layerName)
|
||||
if (layer === undefined) {
|
||||
throw "No layer found for " + paramsKey
|
||||
}
|
||||
if (key === "force") {
|
||||
layer.minzoom = 0
|
||||
layer.minzoomVisible = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
const zoom = Number(params["zoom"] ?? params["z"] ?? 14);
|
||||
|
||||
Hash.hash.setData(undefined)
|
||||
// QueryParameters.ClearAll()
|
||||
|
||||
const state = new FeaturePipelineState(layout)
|
||||
state.locationControl.setData({
|
||||
zoom,
|
||||
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
|
||||
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842)
|
||||
})
|
||||
|
||||
console.log("Params are", params, params["layers"] === "none")
|
||||
|
||||
const fl = state.filteredLayers.data
|
||||
for (const filteredLayer of fl) {
|
||||
if (params["layer-" + filteredLayer.layerDef.id] !== undefined) {
|
||||
filteredLayer.isDisplayed.setData(params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !== "false")
|
||||
} else 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)
|
||||
console.log("Setting ", layer?.layerDef?.id, " to visibility", isDisplayed, "(minzoom:", layer?.layerDef?.minzoomVisible, layer?.layerDef?.minzoom, ")")
|
||||
layer.isDisplayed.setData(
|
||||
isDisplayed
|
||||
)
|
||||
if (key === "force") {
|
||||
layer.layerDef.minzoom = 0
|
||||
layer.layerDef.minzoomVisible = 0
|
||||
layer.isDisplayed.addCallback(isDisplayed => {
|
||||
if (!isDisplayed) {
|
||||
console.warn("Forcing layer " + paramsKey + " as true")
|
||||
layer.isDisplayed.setData(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pngCreator = new PngMapCreator(
|
||||
state,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
scaling: Number(params["scaling"] ?? 1.5),
|
||||
divId: this.options.getFreeDiv(),
|
||||
dummyMode: this.options.disableMaps
|
||||
}
|
||||
)
|
||||
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 PrepareLanguage(language: string) {
|
||||
// Always fetch the remote data - it's cached anyway
|
||||
this.layerTranslations[language] = await Utils.downloadJsonCached("https://raw.githubusercontent.com/pietervdvn/MapComplete/develop/langs/layers/" + language + ".json", 24 * 60 * 60 * 1000)
|
||||
const shared_questions = await Utils.downloadJsonCached("https://raw.githubusercontent.com/pietervdvn/MapComplete/develop/langs/shared-questions/" + language + ".json", 24 * 60 * 60 * 1000)
|
||||
this.layerTranslations[language]["shared-questions"] = shared_questions["shared_questions"]
|
||||
}
|
||||
|
||||
public async Prepare() {
|
||||
|
||||
if (this._isPrepared) {
|
||||
return
|
||||
}
|
||||
this._isPrepared = true;
|
||||
const mapSpecs: SVGTSpanElement[] = []
|
||||
for (let child of Array.from(this._svgRoot.children)) {
|
||||
await this.prepareElement(<any>child, mapSpecs)
|
||||
}
|
||||
|
||||
for (const mapSpec of mapSpecs) {
|
||||
await this.prepareMap(mapSpec)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public drawPage(advancedApi: jsPDF, i: number, language): void {
|
||||
if (!this._isPrepared) {
|
||||
throw "Run 'Prepare()' first!"
|
||||
}
|
||||
|
||||
if (this.options.beforePage) {
|
||||
this.options.beforePage(i)
|
||||
}
|
||||
const self = this
|
||||
const internal = new SvgToPdfInternals(advancedApi, this.images, this.rects, key => self.extractTranslation(key, language));
|
||||
for (let child of Array.from(this._svgRoot.children)) {
|
||||
internal.handleElement(<any>child)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extractTranslation(text: string, language: string, strict: boolean = false) {
|
||||
if (text === "$version") {
|
||||
return new Date().toISOString().substring(0, "2022-01-02THH:MM".length) + " - v" + Constants.vNumber
|
||||
}
|
||||
const pathPart = text.match(/\$(([_a-zA-Z0-9? ]+\.)+[_a-zA-Z0-9? ]+)(.*)/)
|
||||
if (pathPart === null) {
|
||||
return text
|
||||
}
|
||||
let t: any = Translations.t
|
||||
const path = pathPart[1].split(".")
|
||||
if (this.importedTranslations[path[0]]) {
|
||||
path.splice(0, 1, ...this.importedTranslations[path[0]].split("."))
|
||||
}
|
||||
const rest = pathPart[3] ?? ""
|
||||
if (path[0] === "layer") {
|
||||
t = this.layerTranslations[language]
|
||||
if (t === undefined) {
|
||||
console.error("No layerTranslation available for language " + language)
|
||||
return text
|
||||
}
|
||||
path.splice(0, 1)
|
||||
}
|
||||
for (const crumb of path) {
|
||||
t = t[crumb]
|
||||
if (t === undefined) {
|
||||
console.error("No value found to substitute " + text, "the path is", path)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof t === "string") {
|
||||
t = new TypedTranslation({"*": t})
|
||||
}
|
||||
if (t instanceof TypedTranslation) {
|
||||
if (strict && (t.translations[language] ?? t.translations["*"]) === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return t.Subs(this.options.textSubstitutions).textFor(language) + rest
|
||||
} else if (t instanceof Translation) {
|
||||
if (strict && (t.translations[language] ?? t.translations["*"]) === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return (<Translation>t).textFor(language) + rest
|
||||
} else {
|
||||
console.error("Could not get textFor from ", t, "for path", text)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class SvgToPdf {
|
||||
|
||||
public static readonly templates: Record<string, { pages: string[], description: string | Translation }> = {
|
||||
flyer_a4: {
|
||||
pages: ["/assets/templates/MapComplete-flyer.svg", "/assets/templates/MapComplete-flyer.back.svg"],
|
||||
description: Translations.t.flyer.description
|
||||
},
|
||||
poster_a3: {
|
||||
pages: ["/assets/templates/MapComplete-poster-a3.svg"],
|
||||
description: "A basic A3 poster (similar to the flyer)"
|
||||
},
|
||||
poster_a2: {
|
||||
pages: ["/assets/templates/MapComplete-poster-a2.svg"],
|
||||
description: "A basic A2 poster (similar to the flyer); scaled up from the A3 poster"
|
||||
}
|
||||
}
|
||||
private readonly _title: string;
|
||||
|
||||
private readonly _pages: SvgToPdfPage[]
|
||||
|
||||
constructor(title: string, pages: string[], options?: SvgToPdfOptions) {
|
||||
this._title = title;
|
||||
options = options ?? <SvgToPdfOptions>{}
|
||||
options.textSubstitutions = options.textSubstitutions ?? {}
|
||||
const mapCount = "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length;
|
||||
options.textSubstitutions["mapCount"] = mapCount
|
||||
|
||||
this._pages = pages.map(page => new SvgToPdfPage(page, options))
|
||||
}
|
||||
|
||||
|
||||
public async ConvertSvg(language: string): Promise<void> {
|
||||
const firstPage = this._pages[0]._svgRoot
|
||||
const width = SvgToPdfInternals.attrNumber(firstPage, "width")
|
||||
const height = SvgToPdfInternals.attrNumber(firstPage, "height")
|
||||
const mode = width > height ? "landscape" : "portrait"
|
||||
|
||||
await this.Prepare()
|
||||
for (const page of this._pages) {
|
||||
await page.Prepare()
|
||||
await page.PrepareLanguage(language)
|
||||
}
|
||||
|
||||
const doc = new jsPDF(mode, undefined, [width, height])
|
||||
doc.advancedAPI(advancedApi => {
|
||||
for (let i = 0; i < this._pages.length; i++) {
|
||||
if (i > 0) {
|
||||
const page = this._pages[i]._svgRoot
|
||||
const width = SvgToPdfInternals.attrNumber(page, "width")
|
||||
const height = SvgToPdfInternals.attrNumber(page, "height")
|
||||
|
||||
advancedApi.addPage([width, height])
|
||||
const mediabox: { bottomLeftX: number, bottomLeftY: number, topRightX: number, topRightY: number } = advancedApi.getCurrentPageInfo().pageContext.mediaBox
|
||||
const targetWidth = 297
|
||||
const targetHeight = 210
|
||||
const sx = mediabox.topRightX / targetWidth
|
||||
const sy = mediabox.topRightY / targetHeight
|
||||
advancedApi.setCurrentTransformationMatrix(advancedApi.Matrix(sx, 0, 0, -sy, 0, mediabox.topRightY))
|
||||
}
|
||||
this._pages[i].drawPage(advancedApi, i, language)
|
||||
}
|
||||
})
|
||||
await doc.save(this._title + "." + language + ".pdf");
|
||||
}
|
||||
|
||||
public translationKeys(): Set<string> {
|
||||
const allTranslations = this._pages[0].extractTranslations()
|
||||
for (let i = 1; i < this._pages.length; i++) {
|
||||
const translations = this._pages[i].extractTranslations()
|
||||
translations.forEach(t => allTranslations.add(t))
|
||||
}
|
||||
allTranslations.delete("import")
|
||||
allTranslations.delete("version")
|
||||
return allTranslations
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares all the minimaps
|
||||
* @constructor
|
||||
*/
|
||||
public async Prepare(): Promise<SvgToPdf> {
|
||||
for (const page of this._pages) {
|
||||
await page.Prepare()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
public async PrepareLanguages(languages: string[]): Promise<boolean> {
|
||||
for (const page of this._pages) {
|
||||
// Load all languages at once.
|
||||
// We don't parallelize the pages, as they'll probably reload the same languages anyway (and they are cached)
|
||||
await Promise.all(languages.map(async language => await page.PrepareLanguage(language)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
getTranslation(translationKey: string, language: string, strict: boolean = false) {
|
||||
for (const page of this._pages) {
|
||||
const tr = page.extractTranslation(translationKey, language, strict)
|
||||
if(tr === undefined){
|
||||
continue
|
||||
}
|
||||
if(tr === translationKey){
|
||||
continue
|
||||
}
|
||||
return tr
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
import MinimapImplementation from "./UI/Base/MinimapImplementation";
|
||||
|
||||
import { Utils } from "./Utils"
|
||||
import AllThemesGui from "./UI/AllThemesGui"
|
||||
import { QueryParameters } from "./Logic/Web/QueryParameters"
|
||||
import StatisticsGUI from "./UI/StatisticsGUI"
|
||||
import { FixedUiElement } from "./UI/Base/FixedUiElement"
|
||||
import {PdfExportGui} from "./UI/BigComponents/PdfExportGui";
|
||||
|
||||
const layout = QueryParameters.GetQueryParameter("layout", undefined).data ?? ""
|
||||
const customLayout = QueryParameters.GetQueryParameter("userlayout", undefined).data ?? ""
|
||||
|
@ -42,6 +45,13 @@ if (mode.data === "statistics") {
|
|||
console.log("Statistics mode!")
|
||||
new FixedUiElement("").AttachTo("centermessage")
|
||||
new StatisticsGUI().SetClass("w-full h-full pointer-events-auto").AttachTo("topleft-tools")
|
||||
} else if(mode.data === "pdf"){
|
||||
MinimapImplementation.initialize()
|
||||
new FixedUiElement("").AttachTo("centermessage")
|
||||
const div = document.createElement("div")
|
||||
div.id = "extra_div_for_maps"
|
||||
new PdfExportGui(div.id).SetClass("pointer-events-auto").AttachTo("topleft-tools")
|
||||
document.getElementById("topleft-tools").appendChild(div)
|
||||
} else {
|
||||
new AllThemesGui().setup()
|
||||
}
|
||||
|
|
|
@ -407,10 +407,38 @@
|
|||
"es": "Cerámica",
|
||||
"da": "flisebeklædning"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "artwork_type=woodcarving",
|
||||
"then": {
|
||||
"nl": "Houtsculptuur",
|
||||
"en": "Woodcarving"
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "artwork-artwork_type"
|
||||
},
|
||||
{
|
||||
"id": "artwork-artist-wikidata",
|
||||
"render": {
|
||||
"en": "This artwork was made by {wikidata_label(artist:wikidata):font-weight:bold}<br/>{wikipedia(artist:wikidata)}"
|
||||
},
|
||||
"question": {
|
||||
"en": "Who made this artwork?"
|
||||
},
|
||||
"freeform": {
|
||||
"key": "artist:wikidata",
|
||||
"type": "wikidata",
|
||||
"helperArgs": [
|
||||
{
|
||||
"key": "artist_name",
|
||||
"instanceOf": [
|
||||
"Q5"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"question": {
|
||||
"en": "Which artist created this?",
|
||||
|
@ -449,6 +477,7 @@
|
|||
"freeform": {
|
||||
"key": "artist_name"
|
||||
},
|
||||
"condition": "artist:wikidata=",
|
||||
"id": "artwork-artist_name"
|
||||
},
|
||||
{
|
||||
|
@ -492,44 +521,20 @@
|
|||
},
|
||||
"id": "artwork-website"
|
||||
},
|
||||
"wikipedia",
|
||||
{
|
||||
"id": "artwork_subject",
|
||||
"condition": "subject:wikidata~*",
|
||||
"question": {
|
||||
"en": "Which Wikidata-entry corresponds with <b>this artwork</b>?",
|
||||
"nl": "Welk Wikidata-item beschrijft <b>dit kunstwerk</b>?",
|
||||
"fr": "Quelle entrée Wikidata correspond à <b>cette œuvre d'art</b> ?",
|
||||
"de": "Gibt es ein Wikidata Element für <b>dieses Kunstwerk</b>?",
|
||||
"it": "Quale elemento Wikidata corrisponde a <b>quest’opera d’arte</b>?",
|
||||
"ru": "Какая запись в Wikidata соответсвует <b>этой работе</b>?",
|
||||
"ja": "<b>このアートワーク</b>に関するWikidataのエントリーはどれですか?",
|
||||
"zh_Hant": "<b>這個藝術品</b>有那個對應的 Wikidata 項目?",
|
||||
"nb_NO": "Hvilken Wikipedia-oppføring samsvarer med <b>dette kunstverket</b>?",
|
||||
"id": "Entri Wikidata mana yang sesuai dengan <b>karya seni ini</b>?",
|
||||
"pt": "Que entrada no Wikidata corresponde a <b>esta obra de arte</b>?",
|
||||
"hu": "Melyik Wikidata-bejegyzés felel meg <b>ennek a műalkotásnak</b>?",
|
||||
"es": "¿Qué entrada de Wikidata se corresponde con <b>esta obra de arte</b>?",
|
||||
"da": "Hvilken Wikidata-indgang svarer til <b>dette kunstværk</b>?"
|
||||
},
|
||||
"render": {
|
||||
"en": "Corresponds with <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"nl": "Komt overeen met <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"fr": "Correspond à <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"de": "Entspricht <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"it": "Corrisponde a <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"ru": "Запись об этой работе в wikidata: <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"ja": "<a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>に関連する",
|
||||
"zh_Hant": "與 <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>對應",
|
||||
"nb_NO": "Samsvarer med <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"id": "Sesuai dengan <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"pt": "Corresponde a <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"hu": "Ez a megfelelő: <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"es": "Se corresponde con <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>",
|
||||
"da": "Svarer til <a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'>{wikidata}</a>"
|
||||
"en": "What does this artwork depict?"
|
||||
},
|
||||
"freeform": {
|
||||
"key": "wikidata",
|
||||
"key": "subject:wikidata",
|
||||
"type": "wikidata"
|
||||
},
|
||||
"id": "artwork-wikidata"
|
||||
"render": {
|
||||
"en": "This artwork depicts {wikidata_label(subject:wikidata)}{wikipedia(subject:wikidata)}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"deletion": {
|
||||
|
@ -563,5 +568,8 @@
|
|||
"render": "10"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filter": [
|
||||
"has_image"
|
||||
]
|
||||
}
|
|
@ -281,24 +281,9 @@
|
|||
"reviews"
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "opened-now",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Opened now",
|
||||
"nl": "Nu geopend",
|
||||
"de": "Derzeit geöffnet",
|
||||
"fr": "Ouvert maintenant",
|
||||
"hu": "Most nyitva van",
|
||||
"ca": "Obert ara",
|
||||
"es": "Abiert oahora",
|
||||
"da": "Åbent nu"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
"open_now",
|
||||
"accepts_cash",
|
||||
"accepts_cards"
|
||||
],
|
||||
"deletion": {
|
||||
"softDeletionTags": {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"nl": "Een dummy-laag die tagrenderings bevat, gedeeld over de verschillende klimsport lagen",
|
||||
"de": "Eine Dummy-Ebene, die Tagrenderings enthält, die von den Kletterebenen gemeinsam genutzt werden"
|
||||
},
|
||||
"minzoom": 25,
|
||||
"minzoom": 19,
|
||||
"source": {
|
||||
"osmTags": "sport=climbing"
|
||||
},
|
||||
|
|
|
@ -162,20 +162,7 @@
|
|||
}
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "opened-now",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Opened now",
|
||||
"de": "Jetzt geöffnet",
|
||||
"nl": "Nu geopend",
|
||||
"fr": "Ouvert maintenant"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
"open_now"
|
||||
],
|
||||
"mapRendering": [
|
||||
{
|
||||
|
|
97
assets/layers/filters/filters.json
Normal file
97
assets/layers/filters/filters.json
Normal file
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"id": "filters",
|
||||
"description": "This layer acts as library for common filters",
|
||||
"mapRendering": null,
|
||||
"source": {
|
||||
"osmTags": "id~*"
|
||||
},
|
||||
"filter": [
|
||||
{
|
||||
"id": "open_now",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Opened now",
|
||||
"nl": "Nu geopened",
|
||||
"de": "Aktuell geöffnet",
|
||||
"ca": "Obert ara",
|
||||
"es": "Abierta ahora",
|
||||
"fr": "Ouvert maintenant",
|
||||
"hu": "Most nyitva van",
|
||||
"da": "Åbent nu",
|
||||
"zh_Hant": "目前開放",
|
||||
"id": "Saat ini buka",
|
||||
"it": "Aperto ora"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts_cash",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cash=yes",
|
||||
"question": {
|
||||
"en": "Accepts cash",
|
||||
"de": "Akzeptiert Bargeld",
|
||||
"nl": "Accepteert cash",
|
||||
"es": "Acepta efectivo",
|
||||
"fr": "Accepte les espèces"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts_cards",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts payment cards",
|
||||
"de": "Akzeptiert Kartenzahlung",
|
||||
"nl": "Accepteert betaalkaarten",
|
||||
"es": "Acepta el pago por tarjeta",
|
||||
"fr": "Accepte les cartes de paiement"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "has_image",
|
||||
"options": [
|
||||
{
|
||||
"question": "With and without images"
|
||||
},
|
||||
{
|
||||
"question": {
|
||||
"en": "Has at least one image"
|
||||
},
|
||||
"osmTags": {
|
||||
"or": [
|
||||
"image~*",
|
||||
"image:0~*",
|
||||
"image:1~*",
|
||||
"image:2~*",
|
||||
"image:3~*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"question": {
|
||||
"en": "Probably does not have an image"
|
||||
},
|
||||
"osmTags": {
|
||||
"and": [
|
||||
"image=",
|
||||
"image:0=",
|
||||
"image:1=",
|
||||
"image:2=",
|
||||
"image:3="
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -770,22 +770,7 @@
|
|||
"reviews"
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "opened-now",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Opened now",
|
||||
"nl": "Nu geopened",
|
||||
"de": "Aktuell geöffnet",
|
||||
"ca": "Obert ara",
|
||||
"es": "Abierta ahora",
|
||||
"fr": "Ouvert maintenant"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"open_now",
|
||||
{
|
||||
"id": "vegetarian",
|
||||
"options": [
|
||||
|
@ -849,36 +834,8 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts-cash",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cash=yes",
|
||||
"question": {
|
||||
"en": "Accepts cash",
|
||||
"de": "Akzeptiert Bargeld",
|
||||
"es": "Acepta efectivo",
|
||||
"nl": "Accepteert cash",
|
||||
"fr": "Accepte les paiements en espèces"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts-cards",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts payment cards",
|
||||
"de": "Akzeptiert Kartenzahlung",
|
||||
"es": "Acepta tarjetas de pago",
|
||||
"nl": "Accepteert betaalkaarten",
|
||||
"fr": "Accepte les cartes de paiement"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"accepts_cash",
|
||||
"accepts_cards"
|
||||
],
|
||||
"deletion": {
|
||||
"nonDeleteMappings": [
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[
|
||||
{
|
||||
"path": "hotel.svg",
|
||||
"license": "",
|
||||
"license": "CC0",
|
||||
"authors": [
|
||||
"Andy Allan",
|
||||
"Michael Glanznig",
|
||||
|
|
|
@ -58,6 +58,7 @@
|
|||
}
|
||||
],
|
||||
"tagRenderings": [
|
||||
"images",
|
||||
{
|
||||
"id": "kerb-type",
|
||||
"question": {
|
||||
|
@ -373,4 +374,4 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -212,25 +212,7 @@
|
|||
}
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "is_open",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Currently open",
|
||||
"de": "Aktuell geöffnet",
|
||||
"zh_Hant": "目前開放",
|
||||
"id": "Saat ini buka",
|
||||
"hu": "Most nyitva",
|
||||
"nl": "Momenteel geopend",
|
||||
"ca": "Actualment obert",
|
||||
"es": "Actualmente abierta",
|
||||
"fr": "Ouvert actuellement"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
"open_now"
|
||||
],
|
||||
"allowMove": {
|
||||
"enableImproveAccuracy": true
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"en": "Layer showing individual parking spaces.",
|
||||
"de": "Ebene mit den einzelnen PKW Stellplätzen."
|
||||
},
|
||||
"minzoom": 20,
|
||||
"minzoom": 19,
|
||||
"source": {
|
||||
"osmTags": "amenity=parking_space"
|
||||
},
|
||||
|
|
|
@ -151,6 +151,7 @@
|
|||
"osmTags": "dispensing=yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"open_now"
|
||||
]
|
||||
}
|
|
@ -121,25 +121,7 @@
|
|||
}
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "is_open",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Currently open",
|
||||
"de": "Aktuell geöffnet",
|
||||
"zh_Hant": "目前開放",
|
||||
"id": "Saat ini buka",
|
||||
"hu": "Most nyitva",
|
||||
"nl": "Momenteel geopend",
|
||||
"ca": "Actualment obert",
|
||||
"es": "Actualmente abierta",
|
||||
"fr": "Ouvert actuellement"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
"open_now"
|
||||
],
|
||||
"mapRendering": [
|
||||
{
|
||||
|
|
|
@ -990,21 +990,7 @@
|
|||
}
|
||||
],
|
||||
"filter": [
|
||||
{
|
||||
"id": "isOpen",
|
||||
"options": [
|
||||
{
|
||||
"question": {
|
||||
"en": "Currently open",
|
||||
"nl": "Op dit moment open",
|
||||
"de": "Derzeit geöffnet",
|
||||
"es": "Actualmente abierto",
|
||||
"it": "Aperto ora"
|
||||
},
|
||||
"osmTags": "_isOpen=yes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"open_now",
|
||||
{
|
||||
"id": "recyclingType",
|
||||
"options": [
|
||||
|
|
|
@ -295,6 +295,7 @@
|
|||
}
|
||||
],
|
||||
"filter": [
|
||||
"open_now",
|
||||
{
|
||||
"id": "shop-type",
|
||||
"options": [
|
||||
|
@ -337,35 +338,7 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts-cash",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cash=yes",
|
||||
"question": {
|
||||
"en": "Accepts cash",
|
||||
"de": "Akzeptiert Bargeld",
|
||||
"nl": "Accepteert cash",
|
||||
"es": "Acepta efectivo",
|
||||
"fr": "Accepte les espèces"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accepts-cards",
|
||||
"options": [
|
||||
{
|
||||
"osmTags": "payment:cards=yes",
|
||||
"question": {
|
||||
"en": "Accepts payment cards",
|
||||
"de": "Akzeptiert Kartenzahlung",
|
||||
"nl": "Accepteert betaalkaarten",
|
||||
"es": "Acepta el pago por tarjeta",
|
||||
"fr": "Accepte les cartes de paiement"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"accepts_cash",
|
||||
"accepts_cards"
|
||||
]
|
||||
}
|
|
@ -170,6 +170,7 @@
|
|||
],
|
||||
"filter": [
|
||||
{
|
||||
"#": "ignore-possible-duplicate",
|
||||
"id": "public-access",
|
||||
"options": [
|
||||
{
|
||||
|
|
|
@ -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?",
|
||||
|
|
908
assets/templates/MapComplete-flyer.back.svg
Normal file
908
assets/templates/MapComplete-flyer.back.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 56 KiB |
1492
assets/templates/MapComplete-flyer.svg
Normal file
1492
assets/templates/MapComplete-flyer.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 1.6 MiB |
2353
assets/templates/MapComplete-poster-a2.svg
Normal file
2353
assets/templates/MapComplete-poster-a2.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 1.5 MiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue