Merge branch 'develop'

This commit is contained in:
Pieter Vander Vennet 2022-09-28 22:49:38 +02:00
commit 4876b8f426
137 changed files with 30516 additions and 1815 deletions

View file

@ -15,7 +15,7 @@ Requirements
Before you start, you should have the following qualifications: 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 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) - 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 - You are in contact with your local OpenStreetMap community and do know some other members to discuss tagging and to
help testing 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 #### 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>. **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`. 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=...`;... 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 3. A contributor visits the themes and will notice that _Fancy Food_ is missing
4. The contributor will add _Fancy Food_ 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. 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. 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 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 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>` up `https://mapcomplete.osm.be?userlayout=<url-to-the-raw.json>`
- Ask to have your theme included into the official MapComplete - requirements below - 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 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: 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 - 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` especially the `icon` and a way to dynamically render tags and ask questions. A lot of those fields (`icon`
, `title`, ...) are actually a `TagRendering`. , `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 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. question is defined), the question will be shown.
Every field is documented in the source code itself - you can find them here: 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) - [The top level `LayoutConfig`](/Models/ThemeConfig/Json/LayoutConfigJson.ts)
- [A layer object `LayerConfig`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/LayerConfigJson.ts) - [A layer object `LayerConfig`](/Models/ThemeConfig/Json/LayerConfigJson.ts)
- [The `TagRendering`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/TagRenderingConfigJson.ts) - [The `TagRendering`](/Models/ThemeConfig/Json/TagRenderingConfigJson.ts)
- At last, the exact semantics of tags are documented [here](Tags_format.md) - 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. 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 ### 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. 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 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
Not publishing because 'it is not good enough'. _Share your theme, even if it is still not great, let the community help 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 ### 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. 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 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 ### 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 4. Make sure the icons (on the map and in the questions) are big enough, clear enough and contrast enough with the
background map 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, One layer should portray one kind of physical object, e.g. "benches" or "restaurants". It should contain all of them,
disregarding other properties. 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. 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. 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View file

@ -55,12 +55,11 @@ class StatsDownloader {
if (existsSync(path)) { if (existsSync(path)) {
let features = JSON.parse(readFileSync(path, "UTF-8")) let features = JSON.parse(readFileSync(path, "UTF-8"))
features = features?.features ?? features 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 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( console.log(
"Loaded ", "Loaded ",
path, path,
"from disk, got", "from disk, has",
features.length, features.length,
"features now" "features now"
) )

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -137,10 +137,13 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
l("Stamen.TonerBackground", "Toner Background - no labels (by Stamen)"), l("Stamen.TonerBackground", "Toner Background - no labels (by Stamen)"),
l("Stamen.Watercolor", "Watercolor (by Stamen)"), l("Stamen.Watercolor", "Watercolor (by Stamen)"),
l("Stadia.OSMBright", "Osm Bright (by Stadia)"), l("Stadia.OSMBright", "Osm Bright (by Stadia)"),
l("Stadia.AlidadeSmoothDark", "Alidade Smooth Dark (by Stadia)"),
l("CartoDB.Positron", "Positron (by CartoDB)"), l("CartoDB.Positron", "Positron (by CartoDB)"),
l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"), l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"),
l("CartoDB.Voyager", "Voyager (by CartoDB)"), l("CartoDB.Voyager", "Voyager (by CartoDB)"),
l("CartoDB.VoyagerNoLabels", "Voyager - no labels (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) return Utils.NoNull(layers)
} }

View file

@ -191,6 +191,9 @@ export default class OverpassFeatureSource implements FeatureSource {
const self = this const self = this
const overpassUrls = self.state.overpassUrl.data const overpassUrls = self.state.overpassUrl.data
if(overpassUrls === undefined || overpassUrls.length === 0){
throw "Panic: overpassFeatureSource didn't receive any overpassUrls"
}
let bounds: BBox let bounds: BBox
do { do {
try { try {

View file

@ -46,7 +46,11 @@ export default class TitleHandler {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return return
} }
try{
document.title = title document.title = title
}catch (e) {
console.error(e)
}
}) })
} }
} }

View file

@ -18,6 +18,7 @@ import * as licenses from "../assets/generated/license_info.json"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig" 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 Svg from "../Svg"
import {DoesImageExist, PrevalidateTheme, ValidateThemeAndLayers} from "../Models/ThemeConfig/Conversion/Validation";
export default class DetermineLayout { export default class DetermineLayout {
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path)) private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
@ -179,6 +180,23 @@ export default class DetermineLayout {
json.id = forceId ?? json.id 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, { return new LayoutConfig(json, false, {
definitionRaw: JSON.stringify(raw, null, " "), definitionRaw: JSON.stringify(raw, null, " "),
definedAtUrl: sourceUrl, definedAtUrl: sourceUrl,

View file

@ -84,8 +84,8 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
} }
try { try {
const tags: OsmTags = { const tags: OsmTags & {id: OsmId & string} = {
id: <OsmId>(change.type + "/" + change.id), id: <OsmId & string>(change.type + "/" + change.id),
} }
for (const kv of change.tags) { for (const kv of change.tags) {
tags[kv.k] = kv.v tags[kv.k] = kv.v

View file

@ -1,9 +1,8 @@
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource" import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource" import {ImmutableStore, Store} from "../../UIEventSource"
import { stat } from "fs"
import FilteredLayer from "../../../Models/FilteredLayer" import FilteredLayer from "../../../Models/FilteredLayer"
import {BBox} from "../../BBox" import {BBox} from "../../BBox"
import { Feature } from "@turf/turf" import {Feature} from "geojson";
/** /**
* A simple, read only feature store. * A simple, read only feature store.

View file

@ -8,11 +8,12 @@ import {
booleanWithin, booleanWithin,
Coord, Coord,
Feature, Feature,
Geometry, Geometry, Lines,
MultiPolygon, MultiPolygon,
Polygon, Polygon,
Properties, Properties,
} from "@turf/turf" } from "@turf/turf"
import {GeoJSON, LineString, Point} from "geojson";
export class GeoOperations { export class GeoOperations {
private static readonly _earthRadius = 6378137 private static readonly _earthRadius = 6378137
@ -26,8 +27,8 @@ export class GeoOperations {
* Converts a GeoJson feature to a point GeoJson feature * Converts a GeoJson feature to a point GeoJson feature
* @param feature * @param feature
*/ */
static centerpoint(feature: any) { static centerpoint(feature: any): Feature<Point> {
const newFeature = turf.center(feature) const newFeature : Feature<Point> = turf.center(feature)
newFeature.properties = feature.properties newFeature.properties = feature.properties
newFeature.id = feature.id newFeature.id = feature.id
return newFeature 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: * The properties object will contain three values:
// - `index`: closest point was found on nth line part, // - `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 way The road on which you want to find a point
* @param point Point defined as [lon, lat] * @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") { if (way.geometry.type === "Polygon") {
way = { ...way } way = { ...way }
way.geometry = { ...way.geometry } way.geometry = { ...way.geometry }
way.geometry.type = "LineString" 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 { public static toCSV(features: any[]): string {

View file

@ -19,7 +19,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
private readonly _lon: number private readonly _lon: number
private readonly _snapOnto: OsmWay private readonly _snapOnto: OsmWay
private readonly _reusePointDistance: number 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 private readonly _reusePreviouslyCreatedPoint: boolean
constructor( constructor(

View file

@ -6,6 +6,7 @@ import ChangeTagAction from "./ChangeTagAction"
import {TagsFilter} from "../../Tags/TagsFilter" import {TagsFilter} from "../../Tags/TagsFilter"
import {And} from "../../Tags/And" import {And} from "../../Tags/And"
import {Tag} from "../../Tags/Tag" import {Tag} from "../../Tags/Tag"
import {OsmId} from "../../../Models/OsmFeature";
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
export default class DeleteAction extends OsmChangeAction { export default class DeleteAction extends OsmChangeAction {
@ -15,12 +16,13 @@ export default class DeleteAction extends OsmChangeAction {
specialMotivation: string specialMotivation: string
changeType: "deletion" changeType: "deletion"
} }
private readonly _id: string private readonly _id: OsmId
private _hardDelete: boolean private readonly _hardDelete: boolean
constructor( constructor(
id: string, id: OsmId,
softDeletionTags: TagsFilter, softDeletionTags: TagsFilter | undefined,
meta: { meta: {
theme: string theme: string
specialMotivation: string specialMotivation: string
@ -40,14 +42,29 @@ export default class DeleteAction extends OsmChangeAction {
new Tag( new Tag(
"fixme", "fixme",
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})` `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) { if (this._hardDelete) {
return [ return [

View file

@ -13,6 +13,7 @@ import { Utils } from "../../../Utils"
import { OsmConnection } from "../OsmConnection" import { OsmConnection } from "../OsmConnection"
import { Feature } from "@turf/turf" import { Feature } from "@turf/turf"
import FeaturePipeline from "../../FeatureSource/FeaturePipeline" import FeaturePipeline from "../../FeatureSource/FeaturePipeline"
import {Geometry, LineString, Point, Polygon} from "geojson";
export default class ReplaceGeometryAction extends OsmChangeAction { export default class ReplaceGeometryAction extends OsmChangeAction {
/** /**
@ -84,7 +85,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
public async getPreview(): Promise<FeatureSource> { public async getPreview(): Promise<FeatureSource> {
const { closestIds, allNodesById, detachedNodes, reprojectedNodes } = const { closestIds, allNodesById, detachedNodes, reprojectedNodes } =
await this.GetClosestIds() await this.GetClosestIds()
const preview: Feature[] = closestIds.map((newId, i) => { const preview: Feature<Geometry> [] = closestIds.map((newId, i) => {
if (this.identicalTo[i] !== undefined) { if (this.identicalTo[i] !== undefined) {
return undefined return undefined
} }
@ -121,7 +122,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
reprojectedNodes.forEach(({ newLat, newLon, nodeId }) => { reprojectedNodes.forEach(({ newLat, newLon, nodeId }) => {
const origNode = allNodesById.get(nodeId) const origNode = allNodesById.get(nodeId)
const feature: Feature = { const feature: Feature<LineString> = {
type: "Feature", type: "Feature",
properties: { properties: {
move: "yes", move: "yes",
@ -143,7 +144,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
detachedNodes.forEach(({ reason }, id) => { detachedNodes.forEach(({ reason }, id) => {
const origNode = allNodesById.get(id) const origNode = allNodesById.get(id)
const feature: Feature = { const feature: Feature<Point> = {
type: "Feature", type: "Feature",
properties: { properties: {
detach: "yes", detach: "yes",
@ -389,7 +390,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
const node = allNodesById.get(id) const node = allNodesById.get(id)
// Project the node onto the target way to calculate the new coordinates // Project the node onto the target way to calculate the new coordinates
const way = { const way = <Feature<LineString>> {
type: "Feature", type: "Feature",
properties: {}, properties: {},
geometry: { geometry: {

View file

@ -4,6 +4,7 @@ import { Store, UIEventSource } from "../UIEventSource"
import {BBox} from "../BBox" import {BBox} from "../BBox"
import * as OsmToGeoJson from "osmtogeojson" 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 { export abstract class OsmObject {
private static defaultBackend = "https://www.openstreetmap.org/" private static defaultBackend = "https://www.openstreetmap.org/"
@ -16,7 +17,7 @@ export abstract class OsmObject {
/** /**
* The OSM tags as simple object * The OSM tags as simple object
*/ */
tags: OsmTags tags: OsmTags & {id: OsmId}
version: number version: number
public changed: boolean = false public changed: boolean = false
timestamp: Date timestamp: Date
@ -40,6 +41,9 @@ export abstract class OsmObject {
this.backendURL = url 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> { public static DownloadObject(id: string, forceRefresh: boolean = false): Store<OsmObject> {
let src: UIEventSource<OsmObject> let src: UIEventSource<OsmObject>
if (OsmObject.objectCache.has(id)) { if (OsmObject.objectCache.has(id)) {
@ -69,12 +73,12 @@ export abstract class OsmObject {
return rawData.elements[0].tags return rawData.elements[0].tags
} }
static async DownloadObjectAsync(id: NodeId): Promise<OsmNode | undefined> static async DownloadObjectAsync(id: NodeId, maxCacheAgeInSecs?: number): Promise<OsmNode | undefined>
static async DownloadObjectAsync(id: WayId): Promise<OsmWay | undefined> static async DownloadObjectAsync(id: WayId, maxCacheAgeInSecs?: number): Promise<OsmWay | undefined>
static async DownloadObjectAsync(id: RelationId): Promise<OsmRelation | undefined> static async DownloadObjectAsync(id: RelationId, maxCacheAgeInSecs?: number): Promise<OsmRelation | undefined>
static async DownloadObjectAsync(id: OsmId): Promise<OsmObject | undefined> static async DownloadObjectAsync(id: OsmId, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined>
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined> static async DownloadObjectAsync(id: string, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined>
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined> { static async DownloadObjectAsync(id: string, maxCacheAgeInSecs?: number): Promise<OsmObject | undefined> {
const splitted = id.split("/") const splitted = id.split("/")
const type = splitted[0] const type = splitted[0]
const idN = Number(splitted[1]) const idN = Number(splitted[1])
@ -84,7 +88,7 @@ export abstract class OsmObject {
const full = !id.startsWith("node") ? "/full" : "" const full = !id.startsWith("node") ? "/full" : ""
const url = `${OsmObject.backendURL}api/0.6/${id}${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) { if (rawData === undefined) {
return undefined return undefined
} }
@ -260,10 +264,8 @@ export abstract class OsmObject {
return false return false
} }
private static constructPolygonFeatures(): Map< private static constructPolygonFeatures(): Map<string,
string, { values: Set<string>; blacklist: boolean }> {
{ values: Set<string>; blacklist: boolean }
> {
const result = new 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) { for (const polygonFeature of polygon_features["default"] ?? polygon_features) {
const key = polygonFeature.key const key = polygonFeature.key
@ -458,20 +460,27 @@ export class OsmWay extends OsmObject {
this.nodes = element.nodes this.nodes = element.nodes
} }
public asGeoJson() { public asGeoJson(): Feature<Polygon | LineString> & { properties: { id: WayId } } {
let coordinates: [number, number][] | [number, number][][] = this.coordinates.map( let coordinates: [number, number][] | [number, number][][] = this.coordinates.map(
([lat, lon]) => [lon, lat] ([lat, lon]) => [lon, lat]
) )
let geometry: LineString | Polygon
if (this.isPolygon()) { if (this.isPolygon()) {
coordinates = [coordinates] geometry = {
type: "Polygon",
coordinates: [coordinates],
}
} else {
geometry = {
type: "LineString",
coordinates: coordinates,
}
} }
return { return {
type: "Feature", type: "Feature",
properties: this.tags, properties: <any>this.tags,
geometry: { geometry,
type: this.isPolygon() ? "Polygon" : "LineString",
coordinates: coordinates,
},
} }
} }

View file

@ -14,7 +14,7 @@ export class Overpass {
private readonly _interpreterUrl: string private readonly _interpreterUrl: string
private readonly _timeout: Store<number> private readonly _timeout: Store<number>
private readonly _extraScripts: string[] private readonly _extraScripts: string[]
private _includeMeta: boolean private readonly _includeMeta: boolean
private _relationTracker: RelationsTracker private _relationTracker: RelationsTracker
constructor( constructor(

View file

@ -9,6 +9,7 @@ import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country" import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants" import Constants from "../Models/Constants"
import { TagUtils } from "./Tags/TagUtils" import { TagUtils } from "./Tags/TagUtils"
import {Feature, LineString} from "geojson";
export class SimpleMetaTagger { export class SimpleMetaTagger {
public readonly keys: string[] public readonly keys: string[]
@ -420,6 +421,38 @@ export default class SimpleMetaTaggers {
return true 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( private static currentTime = new SimpleMetaTagger(
{ {
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"], keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
@ -457,6 +490,7 @@ export default class SimpleMetaTaggers {
SimpleMetaTaggers.country, SimpleMetaTaggers.country,
SimpleMetaTaggers.isOpen, SimpleMetaTaggers.isOpen,
SimpleMetaTaggers.directionSimplified, SimpleMetaTaggers.directionSimplified,
SimpleMetaTaggers.directionCenterpoint,
SimpleMetaTaggers.currentTime, SimpleMetaTaggers.currentTime,
SimpleMetaTaggers.objectMetaInfo, SimpleMetaTaggers.objectMetaInfo,
SimpleMetaTaggers.noBothButLeftRight, SimpleMetaTaggers.noBothButLeftRight,

View file

@ -166,9 +166,9 @@ export default class FeatureSwitchState {
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","), (layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter" "Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
).sync( ).sync(
(param) => param.split(","), (param) => param?.split(","),
[], [],
(urls) => urls.join(",") (urls) => urls?.join(",")
) )
this.overpassTimeout = UIEventSource.asFloat( this.overpassTimeout = UIEventSource.asFloat(

View file

@ -354,11 +354,11 @@ export class TagUtils {
value: string value: string
modifier: "i" | "" modifier: "i" | ""
} | null { } | null {
const match = tag.match(/^([_a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/) const match = tag.match(/^([_|a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/)
if (match == null) { if (match == null) {
return null return null
} }
const [, key, invert, modifier, value] = match const [_, key, invert, modifier, value] = match
return { key, value, invert: invert == "!", modifier: modifier == "i~" ? "i" : "" } return { key, value, invert: invert == "!", modifier: modifier == "i~" ? "i" : "" }
} }

View file

@ -678,6 +678,18 @@ export class UIEventSource<T> extends Store<T> {
public map<J>(f: (t: T) => J, extraSources: Store<any>[] = []): Store<J> { public map<J>(f: (t: T) => J, extraSources: Store<any>[] = []): Store<J> {
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data)) 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 * Two way sync with functions in both directions

View file

@ -6,11 +6,11 @@ import Hash from "./Hash"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
export class QueryParameters { export class QueryParameters {
static defaults = {} static defaults : Record<string, string> = {}
static documentation: Map<string, string> = new Map<string, string>() static documentation: Map<string, string> = new Map<string, string>()
private static order: string[] = ["layout", "test", "z", "lat", "lon"] private static order: string[] = ["layout", "test", "z", "lat", "lon"]
private static _wasInitialized: Set<string> = new Set() protected static readonly _wasInitialized: Set<string> = new Set()
private static knownSources = {} protected static readonly knownSources: Record<string, UIEventSource<string>> = {}
private static initialized = false private static initialized = false
public static GetQueryParameter( public static GetQueryParameter(
@ -105,7 +105,19 @@ export class QueryParameters {
} }
if (!Utils.runningFromConsole) { if (!Utils.runningFromConsole) {
// Don't pollute the history every time a parameter changes // Don't pollute the history every time a parameter changes
try{
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current()) 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 = []
}
}

View file

@ -1,14 +1,4 @@
import { import {Concat, Conversion, DesugaringContext, DesugaringStep, Each, FirstOf, Fuse, On, SetDefault,} from "./Conversion"
Concat,
Conversion,
DesugaringContext,
DesugaringStep,
Each,
FirstOf,
Fuse,
On,
SetDefault,
} from "./Conversion"
import {LayerConfigJson} from "../Json/LayerConfigJson" import {LayerConfigJson} from "../Json/LayerConfigJson"
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson" import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"
import {Utils} from "../../../Utils" import {Utils} from "../../../Utils"
@ -18,6 +8,58 @@ 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 * 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< class ExpandTagRendering extends Conversion<
string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, string | TagRenderingConfigJson | { builtin: string | string[]; override: any },
@ -178,7 +220,7 @@ class ExpandTagRendering extends Conversion<
if (lookup === undefined) { if (lookup === undefined) {
let candidates = Array.from(state.tagRenderings.keys()) let candidates = Array.from(state.tagRenderings.keys())
if (name.indexOf(".") > 0) { if (name.indexOf(".") > 0) {
const [layerName, search] = name.split(".") const [layerName] = name.split(".")
let layer = state.sharedLayers.get(layerName) let layer = state.sharedLayers.get(layerName)
if (layerName === this._self.id) { if (layerName === this._self.id) {
layer = this._self layer = this._self
@ -699,7 +741,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
) )
), ),
new SetDefault("titleIcons", ["defaults"]), 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()
) )
} }
} }

View file

@ -14,6 +14,10 @@ import { And } from "../../../Logic/Tags/And"
import Translations from "../../../UI/i18n/Translations" import Translations from "../../../UI/i18n/Translations"
import Svg from "../../../Svg" 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> { class ValidateLanguageCompleteness extends DesugaringStep<any> {
private readonly _languages: string[] private readonly _languages: string[]
@ -147,7 +151,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
const warnings = [] const warnings = []
const information = [] const information = []
const theme = new LayoutConfig(json, true) const theme = new LayoutConfig(json, this._isBuiltin)
{ {
// Legacy format checks // Legacy format checks
@ -168,7 +172,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
} }
} }
} }
{ if (this._isBuiltin) {
// Check images: are they local, are the licenses there, is the theme icon square, ... // Check images: are they local, are the licenses there, is the theme icon square, ...
const images = new ExtractImages( const images = new ExtractImages(
this._isBuiltin, this._isBuiltin,
@ -224,6 +228,8 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
} }
try { try {
if (this._isBuiltin) {
if (theme.id !== theme.id.toLowerCase()) { if (theme.id !== theme.id.toLowerCase()) {
errors.push("Theme ids should be in lowercase, but it is " + theme.id) errors.push("Theme ids should be in lowercase, but it is " + theme.id)
} }
@ -250,6 +256,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
warnings, warnings,
information information
) )
}
const dups = Utils.Dupiclates(json.layers.map((layer) => layer["id"])) const dups = Utils.Dupiclates(json.layers.map((layer) => layer["id"]))
if (dups.length > 0) { if (dups.length > 0) {
errors.push( errors.push(
@ -298,7 +305,7 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
super( super(
"Validates a theme and the contained layers", "Validates a theme and the contained layers",
new ValidateTheme(doesImageExist, path, isBuiltin, sharedTagRenderings), 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)))
) )
} }
} }
@ -699,8 +706,14 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
} }
} }
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 { try {
{ if (this._isBuiltin) {
// Some checks for legacy elements // Some checks for legacy elements
if (json["overpassTags"] !== undefined) { if (json["overpassTags"] !== undefined) {
@ -747,7 +760,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
warnings.push(context + " has a tagRendering as `isShown`") warnings.push(context + " has a tagRendering as `isShown`")
} }
} }
{ if (this._isBuiltin) {
// Check location of layer file // Check location of layer file
const expected: string = `assets/layers/${json.id}/${json.id}.json` const expected: string = `assets/layers/${json.id}/${json.id}.json`
if (this._path != undefined && this._path.indexOf(expected) < 0) { if (this._path != undefined && this._path.indexOf(expected) < 0) {
@ -795,6 +808,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
} }
} }
} }
if (json.tagRenderings !== undefined) { if (json.tagRenderings !== undefined) {
const r = new On( const r = new On(
"tagRenderings", "tagRenderings",
@ -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
};
}
}

View file

@ -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. * This block defines under what circumstances the delete dialog is shown for objects of this layer.

View file

@ -371,7 +371,7 @@ export default class LayerConfig extends WithContextLoader {
throw "Error in " + context + ": use 'filter' instead of 'filters'" 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, readOnlyMode: true,
}) })

View file

@ -21,8 +21,8 @@ export default class LayoutConfig {
public readonly startZoom: number public readonly startZoom: number
public readonly startLat: number public readonly startLat: number
public readonly startLon: number public readonly startLon: number
public readonly widenFactor: number public widenFactor: number
public readonly defaultBackgroundId?: string public defaultBackgroundId?: string
public layers: LayerConfig[] public layers: LayerConfig[]
public tileLayerSources: TilesourceConfig[] public tileLayerSources: TilesourceConfig[]
public readonly clustering?: { public readonly clustering?: {
@ -46,7 +46,7 @@ export default class LayoutConfig {
public readonly customCss?: string public readonly customCss?: string
public readonly overpassUrl: string[] public readonly overpassUrl: string[]
public readonly overpassTimeout: number public overpassTimeout: number
public readonly overpassMaxZoom: number public readonly overpassMaxZoom: number
public readonly osmApiTileSize: number public readonly osmApiTileSize: number
public readonly official: boolean public readonly official: boolean

View file

@ -2,7 +2,7 @@ import { TagsFilter } from "../../Logic/Tags/TagsFilter"
import { RegexTag } from "../../Logic/Tags/RegexTag" import { RegexTag } from "../../Logic/Tags/RegexTag"
export default class SourceConfig { export default class SourceConfig {
public readonly osmTags?: TagsFilter public osmTags?: TagsFilter
public readonly overpassScript?: string public readonly overpassScript?: string
public geojsonSource?: string public geojsonSource?: string
public geojsonZoomLevel?: number public geojsonZoomLevel?: number

View file

@ -267,6 +267,9 @@ export default class TagRenderingConfig {
if (this.freeform.key === "wikidata" && txt.indexOf("{wikipedia()") >= 0) { if (this.freeform.key === "wikidata" && txt.indexOf("{wikipedia()") >= 0) {
continue 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} ` 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} `
} }
} }

View file

@ -3,8 +3,11 @@ import Locale from "../i18n/Locale"
import Link from "./Link" import Link from "./Link"
import Svg from "../../Svg" import Svg from "../../Svg"
/**
* The little 'translate'-icon next to every icon + some static helper functions
*/
export default class LinkToWeblate extends VariableUiElement { export default class LinkToWeblate extends VariableUiElement {
private static URI: any
constructor(context: string, availableTranslations: object) { constructor(context: string, availableTranslations: object) {
super( super(
Locale.language.map( Locale.language.map(

View file

@ -3,6 +3,7 @@ import Loc from "../../Models/Loc"
import BaseLayer from "../../Models/BaseLayer" import BaseLayer from "../../Models/BaseLayer"
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox" import { BBox } from "../../Logic/BBox"
import {deprecate} from "util";
export interface MinimapOptions { export interface MinimapOptions {
background?: UIEventSource<BaseLayer> background?: UIEventSource<BaseLayer>
@ -24,7 +25,10 @@ export interface MinimapObj {
installBounds(factor: number | BBox, showRange?: boolean): void 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 { export default class Minimap {

View file

@ -109,10 +109,27 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
mp.remove() 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() const screenshotter = new SimpleMapScreenshoter()
screenshotter.addTo(this.leafletMap.data) 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 { protected InnerConstructElement(): HTMLElement {

View file

@ -148,7 +148,7 @@ export default abstract class BaseUIElement {
} catch (e) { } catch (e) {
const domExc = e as DOMException const domExc = e as DOMException
if (domExc) { 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) console.error(e)
} }

View 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,
"&nbsp;",
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)
}
}

View file

@ -10,7 +10,7 @@ import Combine from "../Base/Combine"
import Locale from "../i18n/Locale" import Locale from "../i18n/Locale"
export default class SearchAndGo extends Combine { 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 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) 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[0], bb[2]],
[bb[1], bb[3]], [bb[1], bb[3]],
] ]
state.selectedElement.setData(undefined) state.selectedElement?.setData(undefined)
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id) Hash.hash.setData(poi.osm_type + "/" + poi.osm_id)
state.leafletMap.data.fitBounds(bounds) state.leafletMap.data.fitBounds(bounds)
placeholder.setData(Translations.t.general.search.search) placeholder.setData(Translations.t.general.search.search)

View file

@ -26,6 +26,7 @@ import BaseLayer from "../../Models/BaseLayer"
import Loading from "../Base/Loading" import Loading from "../Base/Loading"
import Hash from "../../Logic/Web/Hash" import Hash from "../../Logic/Web/Hash"
import { GlobalFilter } from "../../Logic/State/MapState" import { GlobalFilter } from "../../Logic/State/MapState"
import {WayId} from "../../Models/OsmFeature";
/* /*
* The SimpleAddUI is a single panel, which can have multiple states: * The SimpleAddUI is a single panel, which can have multiple states:
@ -123,13 +124,13 @@ export default class SimpleAddUI extends Toggle {
function confirm( function confirm(
tags: any[], tags: any[],
location: { lat: number; lon: number }, location: { lat: number; lon: number },
snapOntoWayId?: string snapOntoWayId?: WayId
) { ) {
if (snapOntoWayId === undefined) { if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined) createNewPoint(tags, location, undefined)
} else { } else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD((way) => { OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD((way) => {
createNewPoint(tags, location, <OsmWay>way) createNewPoint(tags, location, way)
return true return true
}) })
} }

View file

@ -11,7 +11,7 @@ import Link from "../Base/Link"
import LinkToWeblate from "../Base/LinkToWeblate" import LinkToWeblate from "../Base/LinkToWeblate"
import Toggleable from "../Base/Toggleable" import Toggleable from "../Base/Toggleable"
import Title from "../Base/Title" import Title from "../Base/Title"
import { Store, UIEventSource } from "../../Logic/UIEventSource" import { Store } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton" import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg" import Svg from "../../Svg"
import * as native_languages from "../../assets/language_native.json" 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}", // "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
const translated = seed.Subs({ const translated = seed.Subs({
total, total,

View file

@ -70,7 +70,7 @@ export default class ExportPDF {
console.error(e) console.error(e)
self.cleanup() self.cleanup()
} }
}, 500), }, 500)
}) })
minimap.SetStyle( minimap.SetStyle(
@ -166,7 +166,7 @@ export default class ExportPDF {
// Add the logo of the layout // Add the logo of the layout
let img = document.createElement("img") let img = document.createElement("img")
const imgSource = layout.icon const imgSource = layout.icon
const imgType = imgSource.substr(imgSource.lastIndexOf(".") + 1) const imgType = imgSource.substring(imgSource.lastIndexOf(".") + 1)
img.src = imgSource img.src = imgSource
if (imgType.toLowerCase() === "svg") { if (imgType.toLowerCase() === "svg") {
new FixedUiElement("").AttachTo(this.freeDivId) new FixedUiElement("").AttachTo(this.freeDivId)

View file

@ -3,11 +3,12 @@ import { UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement" import BaseUIElement from "../BaseUIElement"
import InputElementMap from "./InputElementMap" import InputElementMap from "./InputElementMap"
import Translations from "../i18n/Translations";
export class CheckBox extends InputElementMap<number[], boolean> { export class CheckBox extends InputElementMap<number[], boolean> {
constructor(el: BaseUIElement, defaultValue?: boolean) { constructor(el: (BaseUIElement | string), defaultValue?: boolean) {
super( super(
new CheckBoxes([el]), new CheckBoxes([Translations.W(el)]),
(x0, x1) => x0 === x1, (x0, x1) => x0 === x1,
(t) => t.length > 0, (t) => t.length > 0,
(x) => (x ? [0] : []) (x) => (x ? [0] : [])

View file

@ -5,7 +5,6 @@ import Minimap, { MinimapObj } from "../Base/Minimap"
import BaseLayer from "../../Models/BaseLayer" import BaseLayer from "../../Models/BaseLayer"
import Combine from "../Base/Combine" import Combine from "../Base/Combine"
import Svg from "../../Svg" import Svg from "../../Svg"
import State from "../../State"
import {GeoOperations} from "../../Logic/GeoOperations" import {GeoOperations} from "../../Logic/GeoOperations"
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer" import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
@ -16,29 +15,41 @@ import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import BaseUIElement from "../BaseUIElement" import BaseUIElement from "../BaseUIElement"
import Toggle from "./Toggle" import Toggle from "./Toggle"
import * as matchpoint from "../../assets/layers/matchpoint/matchpoint.json" 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 export default class LocationInput
extends BaseUIElement extends BaseUIElement
implements ReadonlyInputElement<Loc>, MinimapObj implements ReadonlyInputElement<Loc>, MinimapObj {
{
private static readonly matchLayer = new LayerConfig( private static readonly matchLayer = new LayerConfig(
matchpoint, matchpoint,
"LocationInput.matchpoint", "LocationInput.matchpoint",
true 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 _matching_layer: LayerConfig
public readonly leafletMap: UIEventSource<any> public readonly leafletMap: UIEventSource<any>
public readonly bounds public readonly bounds
public readonly location public readonly location
private _centerLocation: UIEventSource<Loc> private readonly _centerLocation: UIEventSource<Loc>
private readonly mapBackground: UIEventSource<BaseLayer> private readonly mapBackground: UIEventSource<BaseLayer>
/** /**
* The features to which the input should be snapped * The features to which the input should be snapped
* @private * @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 _value: Store<Loc>
private readonly _snappedPoint: Store<any> private readonly _snappedPoint: Store<any>
private readonly _maxSnapDistance: number private readonly _maxSnapDistance: number
@ -47,33 +58,80 @@ export default class LocationInput
private readonly map: BaseUIElement & MinimapObj private readonly map: BaseUIElement & MinimapObj
private readonly clickLocation: UIEventSource<Loc> private readonly clickLocation: UIEventSource<Loc>
private readonly _minZoom: number 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 minZoom?: number
mapBackground?: UIEventSource<BaseLayer> mapBackground?: UIEventSource<BaseLayer>
snapTo?: UIEventSource<{ feature: any }[]> snapTo?: UIEventSource<{ feature: Feature }[]>
maxSnapDistance?: number maxSnapDistance?: number
snappedPointTags?: any snappedPointTags?: any
requiresSnapping?: boolean requiresSnapping?: boolean
centerLocation: UIEventSource<Loc> centerLocation?: UIEventSource<Loc>
bounds?: UIEventSource<BBox> bounds?: UIEventSource<BBox>
state?: {
readonly filteredLayers: Store<FilteredLayer[]>;
readonly backgroundLayer: UIEventSource<BaseLayer>;
readonly layoutToUse: LayoutConfig;
readonly selectedElement: UIEventSource<any>;
readonly allElements: ElementStorage
}
}) { }) {
super() super()
this._snapTo = options.snapTo?.map((features) => this._snapToRaw = options?.snapTo?.map(feats => feats.filter(f => f.feature.geometry.type !== "Point"))
features?.filter((feat) => feat.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._maxSnapDistance = options.maxSnapDistance this._centerLocation = options?.centerLocation ?? new UIEventSource<Loc>({
this._centerLocation = options.centerLocation lat: 0, lon: 0, zoom: 0
this._snappedPointTags = options.snappedPointTags })
this._bounds = options.bounds this._snappedPointTags = options?.snappedPointTags
this._minZoom = options.minZoom this._bounds = options?.bounds
this._minZoom = options?.minZoom
this._state = options?.state
if (this._snapTo === undefined) { if (this._snapTo === undefined) {
this._value = this._centerLocation this._value = this._centerLocation
} else { } else {
const self = this const self = this
if (self._snappedPointTags !== undefined) { if (self._snappedPointTags !== undefined) {
const layout = State.state.layoutToUse const layout = this._state.layoutToUse
let matchingLayer = LocationInput.matchLayer let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) { for (const layer of layout.layers) {
@ -86,36 +144,39 @@ export default class LocationInput
this._matching_layer = LocationInput.matchLayer 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) => { (loc) => {
if (loc === undefined) { if (loc === undefined) {
return undefined return undefined
} }
// We reproject the location onto every 'snap-to-feature' and select the closest // We reproject the location onto every 'snap-to-feature' and select the closest
let min = undefined let min = undefined
let matchedWay = undefined let matchedWay: Feature<LineString | Polygon> & {properties : {id : WayId}} = undefined
for (const feature of self._snapTo.data ?? []) { for (const feature of self._snapTo.data ?? []) {
try { try {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [ const nearestPointOnLine = GeoOperations.nearestPoint(feature, [
loc.lon, loc.lon,
loc.lat, loc.lat,
]) ])
if (min === undefined) { if (min === undefined) {
min = nearestPointOnLine min = nearestPointOnLine
matchedWay = feature.feature matchedWay = feature
continue continue
} }
if (min.properties.dist > nearestPointOnLine.properties.dist) { if (min.properties.dist > nearestPointOnLine.properties.dist) {
min = nearestPointOnLine min = nearestPointOnLine
matchedWay = feature.feature matchedWay = feature
} }
} catch (e) { } catch (e) {
console.log( console.log(
"Snapping to a nearest point failed for ", "Snapping to a nearest point failed for ",
feature.feature, feature,
"due to ", "due to ",
e e
) )
@ -123,18 +184,25 @@ export default class LocationInput
} }
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) { if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options.requiresSnapping) { if (options?.requiresSnapping) {
return undefined return undefined
} else { } else {
// No match found - the original coordinates are returned as is
return { return {
type: "Feature", type: "Feature",
properties: options.snappedPointTags ?? min.properties, properties: options?.snappedPointTags ?? min.properties,
geometry: {type: "Point", coordinates: [loc.lon, loc.lat]}, geometry: {type: "Point", coordinates: [loc.lon, loc.lat]},
} }
} }
} }
min.properties = options.snappedPointTags ?? min.properties min.properties = options?.snappedPointTags ?? min.properties
self.snappedOnto.setData(matchedWay) 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 return min
}, },
[this._snapTo] [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.SetClass("block h-full")
this.clickLocation = new UIEventSource<Loc>(undefined) this.clickLocation = new UIEventSource<Loc>(undefined)
this.map = Minimap.createMiniMap({ this.map = Minimap.createMiniMap({
location: this._centerLocation, location: this._centerLocation,
background: this.mapBackground, background: this.mapBackground,
attribution: this.mapBackground !== State.state?.backgroundLayer, attribution: this.mapBackground !== this._state?.backgroundLayer,
lastClickLocation: this.clickLocation, lastClickLocation: this.clickLocation,
bounds: this._bounds, bounds: this._bounds,
addLayerControl: true, addLayerControl: true,
@ -177,10 +245,6 @@ export default class LocationInput
this.map.installBounds(factor, showRange) this.map.installBounds(factor, showRange)
} }
TakeScreenshot(): Promise<any> {
return this.map.TakeScreenshot()
}
protected InnerConstructElement(): HTMLElement { protected InnerConstructElement(): HTMLElement {
try { try {
const self = this const self = this
@ -201,14 +265,14 @@ export default class LocationInput
this.clickLocation.addCallbackAndRunD((location) => this.clickLocation.addCallbackAndRunD((location) =>
this._centerLocation.setData(location) this._centerLocation.setData(location)
) )
if (this._snapTo !== undefined) { if (this._snapToRaw !== undefined) {
// Show the lines to snap to // 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({ new ShowDataMultiLayer({
features: StaticFeatureSource.fromDateless(this._snapTo), features: StaticFeatureSource.fromDateless(this._snapToRaw),
zoomToFeatures: false, zoomToFeatures: false,
leafletMap: this.map.leafletMap, leafletMap: this.map.leafletMap,
layers: State.state.filteredLayers, layers: this._state.filteredLayers,
}) })
// Show the central point // Show the central point
const matchPoint = this._snappedPoint.map((loc) => { const matchPoint = this._snappedPoint.map((loc) => {
@ -224,8 +288,8 @@ export default class LocationInput
zoomToFeatures: false, zoomToFeatures: false,
leafletMap: this.map.leafletMap, leafletMap: this.map.leafletMap,
layerToShow: this._matching_layer, layerToShow: this._matching_layer,
state: State.state, state: this._state,
selectedElement: State.state.selectedElement, selectedElement: this._state.selectedElement,
}) })
} }
this.mapBackground.map( this.mapBackground.map(
@ -270,4 +334,11 @@ export default class LocationInput
.ConstructElement() .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)
}
} }

View file

@ -10,16 +10,15 @@ import Toggle from "./Input/Toggle"
export default class LanguagePicker extends Toggle { export default class LanguagePicker extends Toggle {
constructor(languages: string[], label: string | BaseUIElement = "") { constructor(languages: string[], label: string | BaseUIElement = "") {
console.log("Constructing a language pîcker for languages", languages)
if (languages === undefined || languages.length <= 1) { if (languages === undefined || languages.length <= 1) {
super(undefined, undefined, undefined) super(undefined, undefined, undefined)
return undefined }else {
}
const allLanguages: string[] = used_languages.languages
const normalPicker = LanguagePicker.dropdownFor(languages, label) const normalPicker = LanguagePicker.dropdownFor(languages, label)
const fullPicker = new Lazy(() => LanguagePicker.dropdownFor(allLanguages, label)) const fullPicker = new Lazy(() => LanguagePicker.dropdownFor(allLanguages, label))
super(fullPicker, normalPicker, Locale.showLinkToWeblate) super(fullPicker, normalPicker, Locale.showLinkToWeblate)
const allLanguages: string[] = used_languages.languages
}
} }
private static dropdownFor(languages: string[], label: string | BaseUIElement): BaseUIElement { private static dropdownFor(languages: string[], label: string | BaseUIElement): BaseUIElement {

View file

@ -18,6 +18,7 @@ import Title from "../Base/Title"
import { GlobalFilter } from "../../Logic/State/MapState" import { GlobalFilter } from "../../Logic/State/MapState"
import { VariableUiElement } from "../Base/VariableUIElement" import { VariableUiElement } from "../Base/VariableUIElement"
import { Tag } from "../../Logic/Tags/Tag" import { Tag } from "../../Logic/Tags/Tag"
import {WayId} from "../../Models/OsmFeature";
export default class ConfirmLocationOfPoint extends Combine { export default class ConfirmLocationOfPoint extends Combine {
constructor( constructor(
@ -35,7 +36,7 @@ export default class ConfirmLocationOfPoint extends Combine {
confirm: ( confirm: (
tags: any[], tags: any[],
location: { lat: number; lon: number }, location: { lat: number; lon: number },
snapOntoWayId: string snapOntoWayId: WayId | undefined
) => void, ) => void,
cancel: () => void, cancel: () => void,
closePopup: () => void closePopup: () => void
@ -75,6 +76,7 @@ export default class ConfirmLocationOfPoint extends Combine {
snappedPointTags: tags, snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance, maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds, bounds: mapBounds,
state: <any> state
}) })
preciseInput.installBounds(preset.boundsFactor ?? 0.25, true) preciseInput.installBounds(preset.boundsFactor ?? 0.25, true)
preciseInput preciseInput

View file

@ -22,6 +22,7 @@ import Title from "../Base/Title"
import { SubstitutedTranslation } from "../SubstitutedTranslation" import { SubstitutedTranslation } from "../SubstitutedTranslation"
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState" import FeaturePipelineState from "../../Logic/State/FeaturePipelineState"
import TagRenderingQuestion from "./TagRenderingQuestion" import TagRenderingQuestion from "./TagRenderingQuestion"
import {OsmId} from "../../Models/OsmFeature";
export default class DeleteWizard extends Toggle { export default class DeleteWizard extends Toggle {
/** /**
@ -43,7 +44,7 @@ export default class DeleteWizard extends Toggle {
* @param state: the state of the application * @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 * @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 deleteAbility = new DeleteabilityChecker(id, state, options.neededChangesets)
const tagsSource = state.allElements.getEventSourceById(id) const tagsSource = state.allElements.getEventSourceById(id)

View file

@ -248,9 +248,8 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
) )
editElements.push( editElements.push(
new VariableUiElement( Toggle.If(state.featureSwitchIsDebugging,
state.featureSwitchIsDebugging.map((isDebugging) => { () => {
if (isDebugging) {
const config_all_tags: TagRenderingConfig = new TagRenderingConfig( const config_all_tags: TagRenderingConfig = new TagRenderingConfig(
{ render: "{all_tags()}" }, { render: "{all_tags()}" },
"" ""
@ -271,7 +270,6 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
"This is layer " + layerConfig.id, "This is layer " + layerConfig.id,
]) ])
} }
})
) )
) )

View file

@ -145,6 +145,7 @@ export default class MoveWizard extends Toggle {
minZoom: reason.minZoom, minZoom: reason.minZoom,
centerLocation: loc, centerLocation: loc,
mapBackground: new UIEventSource<BaseLayer>(preferredBackground), // We detach the layer mapBackground: new UIEventSource<BaseLayer>(preferredBackground), // We detach the layer
state: <any> state
}) })
if (reason.lockBounds) { if (reason.lockBounds) {

View file

@ -6,7 +6,6 @@ import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeature
import { GeoOperations } from "../../Logic/GeoOperations" import { GeoOperations } from "../../Logic/GeoOperations"
import { Tiles } from "../../Models/TileRange" import { Tiles } from "../../Models/TileRange"
import * as clusterstyle from "../../assets/layers/cluster_style/cluster_style.json" import * as clusterstyle from "../../assets/layers/cluster_style/cluster_style.json"
import State from "../../State"
export default class ShowTileInfo { export default class ShowTileInfo {
public static readonly styling = new LayerConfig(clusterstyle, "ShowTileInfo", true) public static readonly styling = new LayerConfig(clusterstyle, "ShowTileInfo", true)
@ -16,7 +15,7 @@ export default class ShowTileInfo {
leafletMap: UIEventSource<any> leafletMap: UIEventSource<any>
layer?: LayerConfig layer?: LayerConfig
doShowLayer?: UIEventSource<boolean> doShowLayer?: UIEventSource<boolean>
}) { }, state) {
const source = options.source const source = options.source
const metaFeature: Store<{ feature; freshness: Date }[]> = source.features.map( const metaFeature: Store<{ feature; freshness: Date }[]> = source.features.map(
(features) => { (features) => {
@ -56,7 +55,7 @@ export default class ShowTileInfo {
features: new StaticFeatureSource(metaFeature), features: new StaticFeatureSource(metaFeature),
leafletMap: options.leafletMap, leafletMap: options.leafletMap,
doShowLayer: options.doShowLayer, doShowLayer: options.doShowLayer,
state: State.state, state
}) })
} }
} }

View file

@ -13,9 +13,12 @@ import MapState from "../Logic/State/MapState"
import BaseUIElement from "./BaseUIElement" import BaseUIElement from "./BaseUIElement"
import Title from "./Base/Title" import Title from "./Base/Title"
import { FixedUiElement } from "./Base/FixedUiElement" import { FixedUiElement } from "./Base/FixedUiElement"
import List from "./Base/List";
class StatisticsForOverviewFile extends Combine { class StatisticsForOverviewFile extends Combine {
constructor(homeUrl: string, paths: string[]) { constructor(homeUrl: string, paths: string[]) {
paths = paths.filter(p => !p.endsWith("file-overview.json"))
const layer = AllKnownLayouts.allKnownLayouts.get("mapcomplete-changes").layers[0] const layer = AllKnownLayouts.allKnownLayouts.get("mapcomplete-changes").layers[0]
const filteredLayer = MapState.InitializeFilteredLayers( const filteredLayer = MapState.InitializeFilteredLayers(
{id: "statistics-view", layers: [layer]}, {id: "statistics-view", layers: [layer]},
@ -27,7 +30,16 @@ class StatisticsForOverviewFile extends Combine {
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([]) const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
for (const filepath of paths) { for (const filepath of paths) {
if(filepath.endsWith("file-overview.json")){
continue
}
Utils.downloadJson(homeUrl + filepath).then((data) => { Utils.downloadJson(homeUrl + filepath).then((data) => {
if (data === undefined) {
return
}
if (data.features === undefined) {
data.features = data
}
data?.features?.forEach((item) => { data?.features?.forEach((item) => {
item.properties = {...item.properties, ...item.properties.metadata} item.properties = {...item.properties, ...item.properties.metadata}
delete item.properties.metadata delete item.properties.metadata
@ -43,6 +55,7 @@ class StatisticsForOverviewFile extends Combine {
) )
) )
super([ super([
filterPanel, filterPanel,
new VariableUiElement( new VariableUiElement(
@ -83,7 +96,49 @@ class StatisticsForOverviewFile extends Combine {
const trs = layer.tagRenderings.filter( const trs = layer.tagRenderings.filter(
(tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined (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) { for (const tr of trs) {
let total = undefined let total = undefined
if (tr.freeform?.key !== undefined) { if (tr.freeform?.key !== undefined) {
@ -186,6 +241,20 @@ class ChangesetsOverview {
return new ChangesetsOverview(this._meta.filter(predicate)) 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 { private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
if (cs === undefined) { if (cs === undefined) {
return undefined return undefined
@ -211,7 +280,8 @@ class ChangesetsOverview {
} }
try { try {
cs.properties.host = new URL(cs.properties.host).host cs.properties.host = new URL(cs.properties.host).host
} catch (e) {} } catch (e) {
}
return cs return cs
} }
} }

View file

@ -41,7 +41,7 @@ export default class Translations {
* translation.textFor("nl") // => "Nederlands" * 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) { if (t === undefined || t === null) {
return undefined return undefined
} }
@ -51,7 +51,7 @@ export default class Translations {
if (typeof t === "string") { if (typeof t === "string") {
return new TypedTranslation<object>({ "*": t }, context) return new TypedTranslation<object>({ "*": t }, context)
} }
if (t.render !== undefined) { if (t["render"] !== undefined) {
const msg = const msg =
"Creating a translation, but this object contains a 'render'-field. Use the translation directly" "Creating a translation, but this object contains a 'render'-field. Use the translation directly"
console.error(msg, t) console.error(msg, t)

View file

@ -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) { } else if (xhr.status === 509 || xhr.status === 429) {
reject("rate limited") reject("rate limited")
} else { } else {
reject(xhr.statusText) reject("Could not download "+url+" due to "+xhr.statusText)
} }
} }
xhr.open("GET", url) xhr.open("GET", url)

113
Utils/pngMapCreator.ts Normal file
View 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
View 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(/&nbsp;/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
}
}

View file

@ -1,8 +1,11 @@
import MinimapImplementation from "./UI/Base/MinimapImplementation";
import { Utils } from "./Utils" import { Utils } from "./Utils"
import AllThemesGui from "./UI/AllThemesGui" import AllThemesGui from "./UI/AllThemesGui"
import { QueryParameters } from "./Logic/Web/QueryParameters" import { QueryParameters } from "./Logic/Web/QueryParameters"
import StatisticsGUI from "./UI/StatisticsGUI" import StatisticsGUI from "./UI/StatisticsGUI"
import { FixedUiElement } from "./UI/Base/FixedUiElement" import { FixedUiElement } from "./UI/Base/FixedUiElement"
import {PdfExportGui} from "./UI/BigComponents/PdfExportGui";
const layout = QueryParameters.GetQueryParameter("layout", undefined).data ?? "" const layout = QueryParameters.GetQueryParameter("layout", undefined).data ?? ""
const customLayout = QueryParameters.GetQueryParameter("userlayout", undefined).data ?? "" const customLayout = QueryParameters.GetQueryParameter("userlayout", undefined).data ?? ""
@ -42,6 +45,13 @@ if (mode.data === "statistics") {
console.log("Statistics mode!") console.log("Statistics mode!")
new FixedUiElement("").AttachTo("centermessage") new FixedUiElement("").AttachTo("centermessage")
new StatisticsGUI().SetClass("w-full h-full pointer-events-auto").AttachTo("topleft-tools") 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 { } else {
new AllThemesGui().setup() new AllThemesGui().setup()
} }

View file

@ -407,10 +407,38 @@
"es": "Cerámica", "es": "Cerámica",
"da": "flisebeklædning" "da": "flisebeklædning"
} }
},
{
"if": "artwork_type=woodcarving",
"then": {
"nl": "Houtsculptuur",
"en": "Woodcarving"
}
} }
], ],
"id": "artwork-artwork_type" "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": { "question": {
"en": "Which artist created this?", "en": "Which artist created this?",
@ -449,6 +477,7 @@
"freeform": { "freeform": {
"key": "artist_name" "key": "artist_name"
}, },
"condition": "artist:wikidata=",
"id": "artwork-artist_name" "id": "artwork-artist_name"
}, },
{ {
@ -492,44 +521,20 @@
}, },
"id": "artwork-website" "id": "artwork-website"
}, },
"wikipedia",
{ {
"id": "artwork_subject",
"condition": "subject:wikidata~*",
"question": { "question": {
"en": "Which Wikidata-entry corresponds with <b>this artwork</b>?", "en": "What does this artwork depict?"
"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>questopera darte</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>"
}, },
"freeform": { "freeform": {
"key": "wikidata", "key": "subject:wikidata",
"type": "wikidata" "type": "wikidata"
}, },
"id": "artwork-wikidata" "render": {
"en": "This artwork depicts {wikidata_label(subject:wikidata)}{wikipedia(subject:wikidata)}"
}
} }
], ],
"deletion": { "deletion": {
@ -563,5 +568,8 @@
"render": "10" "render": "10"
} }
} }
],
"filter": [
"has_image"
] ]
} }

View file

@ -281,24 +281,9 @@
"reviews" "reviews"
], ],
"filter": [ "filter": [
{ "open_now",
"id": "opened-now", "accepts_cash",
"options": [ "accepts_cards"
{
"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"
}
]
}
], ],
"deletion": { "deletion": {
"softDeletionTags": { "softDeletionTags": {

View file

@ -6,7 +6,7 @@
"nl": "Een dummy-laag die tagrenderings bevat, gedeeld over de verschillende klimsport lagen", "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" "de": "Eine Dummy-Ebene, die Tagrenderings enthält, die von den Kletterebenen gemeinsam genutzt werden"
}, },
"minzoom": 25, "minzoom": 19,
"source": { "source": {
"osmTags": "sport=climbing" "osmTags": "sport=climbing"
}, },

View file

@ -162,20 +162,7 @@
} }
], ],
"filter": [ "filter": [
{ "open_now"
"id": "opened-now",
"options": [
{
"question": {
"en": "Opened now",
"de": "Jetzt geöffnet",
"nl": "Nu geopend",
"fr": "Ouvert maintenant"
},
"osmTags": "_isOpen=yes"
}
]
}
], ],
"mapRendering": [ "mapRendering": [
{ {

View 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="
]
}
}
]
}
]
}

View file

@ -770,22 +770,7 @@
"reviews" "reviews"
], ],
"filter": [ "filter": [
{ "open_now",
"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"
}
]
},
{ {
"id": "vegetarian", "id": "vegetarian",
"options": [ "options": [
@ -849,36 +834,8 @@
} }
] ]
}, },
{ "accepts_cash",
"id": "accepts-cash", "accepts_cards"
"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"
}
}
]
}
], ],
"deletion": { "deletion": {
"nonDeleteMappings": [ "nonDeleteMappings": [

View file

@ -1,7 +1,7 @@
[ [
{ {
"path": "hotel.svg", "path": "hotel.svg",
"license": "", "license": "CC0",
"authors": [ "authors": [
"Andy Allan", "Andy Allan",
"Michael Glanznig", "Michael Glanznig",

View file

@ -58,6 +58,7 @@
} }
], ],
"tagRenderings": [ "tagRenderings": [
"images",
{ {
"id": "kerb-type", "id": "kerb-type",
"question": { "question": {

View file

@ -212,25 +212,7 @@
} }
], ],
"filter": [ "filter": [
{ "open_now"
"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"
}
]
}
], ],
"allowMove": { "allowMove": {
"enableImproveAccuracy": true "enableImproveAccuracy": true

View file

@ -8,7 +8,7 @@
"en": "Layer showing individual parking spaces.", "en": "Layer showing individual parking spaces.",
"de": "Ebene mit den einzelnen PKW Stellplätzen." "de": "Ebene mit den einzelnen PKW Stellplätzen."
}, },
"minzoom": 20, "minzoom": 19,
"source": { "source": {
"osmTags": "amenity=parking_space" "osmTags": "amenity=parking_space"
}, },

View file

@ -151,6 +151,7 @@
"osmTags": "dispensing=yes" "osmTags": "dispensing=yes"
} }
] ]
} },
"open_now"
] ]
} }

View file

@ -121,25 +121,7 @@
} }
], ],
"filter": [ "filter": [
{ "open_now"
"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"
}
]
}
], ],
"mapRendering": [ "mapRendering": [
{ {

View file

@ -990,21 +990,7 @@
} }
], ],
"filter": [ "filter": [
{ "open_now",
"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"
}
]
},
{ {
"id": "recyclingType", "id": "recyclingType",
"options": [ "options": [

View file

@ -295,6 +295,7 @@
} }
], ],
"filter": [ "filter": [
"open_now",
{ {
"id": "shop-type", "id": "shop-type",
"options": [ "options": [
@ -337,35 +338,7 @@
} }
] ]
}, },
{ "accepts_cash",
"id": "accepts-cash", "accepts_cards"
"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"
}
}
]
}
] ]
} }

View file

@ -170,6 +170,7 @@
], ],
"filter": [ "filter": [
{ {
"#": "ignore-possible-duplicate",
"id": "public-access", "id": "public-access",
"options": [ "options": [
{ {

View file

@ -21,7 +21,7 @@
"render": "{export_as_geojson()}" "render": "{export_as_geojson()}"
}, },
"wikipedia": { "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}", "render": "{wikipedia():max-height:25rem}",
"question": { "question": {
"en": "What is the corresponding Wikidata entity?", "en": "What is the corresponding Wikidata entity?",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.6 MiB

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