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:
- You are a longtime contributor and do know the OpenStreetMap tagging scheme very well.
- You are not afraid of editing a JSON file. If you don't know what a JSON-file is, [read this intro](https://www.w3schools.com/whatis/whatis_json.asp)
- You are not afraid of editing a JSON file. If you don't know what a JSON file is, [read this intro](https://www.w3schools.com/whatis/whatis_json.asp)
- Your theme will add well-understood tags (aka: the tags have a wiki page, are not controversial and are objective)
- You are in contact with your local OpenStreetMap community and do know some other members to discuss tagging and to
help testing
@ -106,13 +106,13 @@ It asks some relevant questions, with the most important and easiests questions
#### Don't: use a layer to filter
**Do not define a layer which filters on an attribute**, such as <del>all restaurants with a vegetarian diet</del>, <del>all shops which accept bitcoin</del>.
This makes _addition_ of new points difficult as information might not yet be known. Conser the following situation:
This makes _addition_ of new points difficult as information might not yet be known. Consider the following situation:
1. A theme defines a layer `vegetarian restaurants`, which matches `amenity=restaurant` & `diet:vegetarian=yes`.
2. An object exists in OSM with `amenity=restaurant`;`name=Fancy Food`;`diet:vegan=yes`;`phone=...`;...
3. A contributor visits the themes and will notice that _Fancy Food_ is missing
4. The contributor will add _Fancy Food_
5. There are now **two** Fancy Food objects in OSM.
5. There are now **two** _Fancy Food_ objects in OSM.
Instead, use the filter functionality instead. This can be used from the layer to hide some objects based on their properties.
When the contributor wants to add a new point, they'll be notified that some features might be hidden and only be allowed to add a new point when the points are shown.
@ -230,7 +230,7 @@ The entire tagRendering will thus be:
The template
------------
[A basic template is available here](https://github.com/pietervdvn/MapComplete/blob/develop/Docs/theme-template.json)
[A basic template is available here](https://github.com/pietervdvn/MapComplete/blob/develop/Docs/theme-template.json).
The custom theme generator
--------------------------
@ -258,7 +258,7 @@ If you have your JSON file, there are three ways to distribute your theme:
up `https://mapcomplete.osm.be?userlayout=<url-to-the-raw.json>`
- Ask to have your theme included into the official MapComplete - requirements below
### Getting your theme included into the official mapcomplete
### Getting your theme included into the official MapComplete
Did you make an awesome theme that you want to share with the OpenStreetMap community? Have it included in the main
application. This makes sure that:
@ -314,15 +314,15 @@ There are three important levels in the JSON file:
- A `layer` describes a layer. It contains the `name`, `icon`, `tags of objects to download from overpass`, and
especially the `icon` and a way to dynamically render tags and ask questions. A lot of those fields (`icon`
, `title`, ...) are actually a `TagRendering`.
- A `TagRendering` is an object describing a relationship between what should be shown on screen and the OSM-tagging. It
- A `TagRendering` is an object describing a relationship between what should be shown on screen and the OSM tagging. It
works in two ways: if the correct tag is known, the appropriate text will be shown. If the tag is missing (and a
question is defined), the question will be shown.
Every field is documented in the source code itself - you can find them here:
- [The top level `LayoutConfig`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/LayoutConfigJson.ts)
- [A layer object `LayerConfig`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/LayerConfigJson.ts)
- [The `TagRendering`](https://github.com/pietervdvn/MapComplete/blob/master/Models/ThemeConfig/Json/TagRenderingConfigJson.ts)
- [The top level `LayoutConfig`](/Models/ThemeConfig/Json/LayoutConfigJson.ts)
- [A layer object `LayerConfig`](/Models/ThemeConfig/Json/LayerConfigJson.ts)
- [The `TagRendering`](/Models/ThemeConfig/Json/TagRenderingConfigJson.ts)
- At last, the exact semantics of tags are documented [here](Tags_format.md)
A JSON schema file is available in `Docs/Schemas` - use `LayoutConfig.schema.json` to validate a theme file.
@ -334,7 +334,7 @@ in. [An overview of all these metatags is available here](CalculatedTags.md).
### TagRendering groups
A `tagRendering` can have a `group`-attribute, which acts as a tag. All `tagRendering`s with the same group name will be
A `tagRendering` can have a `group` attribute, which acts as a tag. All `tagRendering`s with the same group name will be
rendered together, in the same order as they were defined.
For example, if the defined `tagRendering`s have groups `A A B A A B B B`, the group order is `A B` and first all
@ -411,7 +411,7 @@ Some pitfalls
### Not publishing
Not publishing because 'it is not good enough'. _Share your theme, even if it is still not great, let the community help
it improve_
improve it._
### Thinking in terms of a question
@ -423,7 +423,7 @@ The correct way to handle this is to use _This bench does have a backrest_ and _
answers.
One has to think first in terms of _what is shown to the user if it is known_, only then in terms of _what is the
question I want to ask_
question I want to ask_.
### Forgetting the casual/noob mapper
@ -437,7 +437,7 @@ In order to maximize contribution:
4. Make sure the icons (on the map and in the questions) are big enough, clear enough and contrast enough with the
background map
### Using layers to distinguish on attributes
### Using layers to distinguish different object subtypes by attributes
One layer should portray one kind of physical object, e.g. "benches" or "restaurants". It should contain all of them,
disregarding other properties.
@ -459,6 +459,6 @@ through!
Some new contributors might add a POI to indicate something that resembles it, but quite isn't.
For example, if they are only offered a layer with public bookcases, they might map their local library with a public bookcase.
The perfect solution for this is to provide both the library-layer and public bookcases layer - but this requires having both layers.
The perfect solution for this is to provide both the library layer and public bookcases layer - but this requires having both layers.
A good solution is to clearly explain what a certain feature is and what it is not.
A good solution is to clearly explain what a certain feature is and what it is not.

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)) {
let features = JSON.parse(readFileSync(path, "UTF-8"))
features = features?.features ?? features
console.log(features)
features.push(...features.features) // day-stats are generally a list already, but in some ad-hoc cases might be a geojson-collection too
console.log(
"Loaded ",
path,
"from disk, got",
"from disk, has",
features.length,
"features now"
)

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.Watercolor", "Watercolor (by Stamen)"),
l("Stadia.OSMBright", "Osm Bright (by Stadia)"),
l("Stadia.AlidadeSmoothDark", "Alidade Smooth Dark (by Stadia)"),
l("CartoDB.Positron", "Positron (by CartoDB)"),
l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"),
l("CartoDB.Voyager", "Voyager (by CartoDB)"),
l("CartoDB.VoyagerNoLabels", "Voyager - no labels (by CartoDB)"),
l("CartoDB.DarkMatter", "Dark Matter (by CartoDB)"),
l("CartoDB.DarkMatterNoLabels", "Dark Matter - no labels (by CartoDB)"),
]
return Utils.NoNull(layers)
}

View file

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

View file

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

View file

@ -1,23 +1,24 @@
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import { QueryParameters } from "./Web/QueryParameters"
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import { Utils } from "../Utils"
import {QueryParameters} from "./Web/QueryParameters"
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"
import {FixedUiElement} from "../UI/Base/FixedUiElement"
import {Utils} from "../Utils"
import Combine from "../UI/Base/Combine"
import { SubtleButton } from "../UI/Base/SubtleButton"
import {SubtleButton} from "../UI/Base/SubtleButton"
import BaseUIElement from "../UI/BaseUIElement"
import { UIEventSource } from "./UIEventSource"
import { LocalStorageSource } from "./Web/LocalStorageSource"
import {UIEventSource} from "./UIEventSource"
import {LocalStorageSource} from "./Web/LocalStorageSource"
import LZString from "lz-string"
import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"
import SharedTagRenderings from "../Customizations/SharedTagRenderings"
import * as known_layers from "../assets/generated/known_layers.json"
import { PrepareTheme } from "../Models/ThemeConfig/Conversion/PrepareTheme"
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"
import * as licenses from "../assets/generated/license_info.json"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import { FixImages } from "../Models/ThemeConfig/Conversion/FixImages"
import {FixImages} from "../Models/ThemeConfig/Conversion/FixImages"
import Svg from "../Svg"
import {DoesImageExist, PrevalidateTheme, ValidateThemeAndLayers} from "../Models/ThemeConfig/Conversion/Validation";
export default class DetermineLayout {
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
@ -129,11 +130,11 @@ export default class DetermineLayout {
}),
json !== undefined
? new SubtleButton(Svg.download_svg(), "Download the JSON file").onClick(() => {
Utils.offerContentsAsDownloadableFile(
JSON.stringify(json, null, " "),
"theme_definition.json"
)
})
Utils.offerContentsAsDownloadableFile(
JSON.stringify(json, null, " "),
"theme_definition.json"
)
})
: undefined,
])
.SetClass("flex flex-col clickable")
@ -179,6 +180,23 @@ export default class DetermineLayout {
json.id = forceId ?? json.id
{
let {errors} = new PrevalidateTheme().convert(json, "validation")
if (errors.length > 0) {
throw "Detected errors: " + errors.join("\n")
}
}
{
let {errors} = new ValidateThemeAndLayers(
new DoesImageExist(new Set<string>(), _ => true),
"",
false,
SharedTagRenderings.SharedTagRendering
).convert(json, "validation")
if (errors.length > 0) {
throw "Detected errors: " + errors.join("\n")
}
}
return new LayoutConfig(json, false, {
definitionRaw: JSON.stringify(raw, null, " "),
definedAtUrl: sourceUrl,

View file

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

View file

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

View file

@ -8,11 +8,12 @@ import {
booleanWithin,
Coord,
Feature,
Geometry,
Geometry, Lines,
MultiPolygon,
Polygon,
Properties,
} from "@turf/turf"
import {GeoJSON, LineString, Point} from "geojson";
export class GeoOperations {
private static readonly _earthRadius = 6378137
@ -26,8 +27,8 @@ export class GeoOperations {
* Converts a GeoJson feature to a point GeoJson feature
* @param feature
*/
static centerpoint(feature: any) {
const newFeature = turf.center(feature)
static centerpoint(feature: any): Feature<Point> {
const newFeature : Feature<Point> = turf.center(feature)
newFeature.properties = feature.properties
newFeature.id = feature.id
return newFeature
@ -270,7 +271,8 @@ export class GeoOperations {
}
/**
* Generates the closest point on a way from a given point
* Generates the closest point on a way from a given point.
* If the passed-in geojson object is a polygon, the outer ring will be used as linestring
*
* The properties object will contain three values:
// - `index`: closest point was found on nth line part,
@ -279,15 +281,15 @@ export class GeoOperations {
* @param way The road on which you want to find a point
* @param point Point defined as [lon, lat]
*/
public static nearestPoint(way, point: [number, number]) {
public static nearestPoint(way: Feature<LineString | Polygon>, point: [number, number]) {
if (way.geometry.type === "Polygon") {
way = { ...way }
way.geometry = { ...way.geometry }
way.geometry.type = "LineString"
way.geometry.coordinates = way.geometry.coordinates[0]
way.geometry.coordinates = (<Polygon> way.geometry).coordinates[0]
}
return turf.nearestPointOnLine(way, point, { units: "kilometers" })
return turf.nearestPointOnLine(<Feature<LineString>> way, point, { units: "kilometers" })
}
public static toCSV(features: any[]): string {

View file

@ -19,7 +19,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
private readonly _lon: number
private readonly _snapOnto: OsmWay
private readonly _reusePointDistance: number
private meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string }
private readonly meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string }
private readonly _reusePreviouslyCreatedPoint: boolean
constructor(

View file

@ -3,9 +3,10 @@ import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import ChangeTagAction from "./ChangeTagAction"
import { TagsFilter } from "../../Tags/TagsFilter"
import { And } from "../../Tags/And"
import { Tag } from "../../Tags/Tag"
import {TagsFilter} from "../../Tags/TagsFilter"
import {And} from "../../Tags/And"
import {Tag} from "../../Tags/Tag"
import {OsmId} from "../../../Models/OsmFeature";
import { Utils } from "../../../Utils"
export default class DeleteAction extends OsmChangeAction {
@ -15,12 +16,13 @@ export default class DeleteAction extends OsmChangeAction {
specialMotivation: string
changeType: "deletion"
}
private readonly _id: string
private _hardDelete: boolean
private readonly _id: OsmId
private readonly _hardDelete: boolean
constructor(
id: string,
softDeletionTags: TagsFilter,
id: OsmId,
softDeletionTags: TagsFilter | undefined,
meta: {
theme: string
specialMotivation: string
@ -37,17 +39,32 @@ export default class DeleteAction extends OsmChangeAction {
this._softDeletionTags = new And(
Utils.NoNull([
softDeletionTags,
new Tag(
"fixme",
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`
),
])
)
new Tag(
"fixme",
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`
)
]))
}
}
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const osmObject = await OsmObject.DownloadObjectAsync(this._id)
/**
*
* import {OsmNode} from "../OsmObject"
*
* const obj : OsmNode= new OsmNode(1)
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
* const da = new DeleteAction("node/1", new Tag("man_made",""), {theme: "test", specialMotivation: "Testcase"}, true)
* const descr = await da.CreateChangeDescriptions(new Changes(), obj)
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase",changeType: "deletion"}, type: "node",id: 1 }
*
* // Must not crash if softDeletionTags are undefined
* const da = new DeleteAction("node/1", undefined, {theme: "test", specialMotivation: "Testcase"}, true)
* const obj : OsmNode= new OsmNode(1)
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
* const descr = await da.CreateChangeDescriptions(new Changes(), obj)
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase", changeType: "deletion"}, type: "node",id: 1 }
*/
public async CreateChangeDescriptions(changes: Changes, object?: OsmObject): Promise<ChangeDescription[]> {
const osmObject = object ?? await OsmObject.DownloadObjectAsync(this._id)
if (this._hardDelete) {
return [

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants"
import { TagUtils } from "./Tags/TagUtils"
import {Feature, LineString} from "geojson";
export class SimpleMetaTagger {
public readonly keys: string[]
@ -420,6 +421,38 @@ export default class SimpleMetaTaggers {
return true
}
)
private static directionCenterpoint = new SimpleMetaTagger(
{
keys:["_direction:centerpoint"],
isLazy: true,
doc: "_direction:centerpoint is the direction of the linestring (in degrees) if one were standing at the projected centerpoint."
},
(feature: Feature) => {
if(feature.geometry.type !== "LineString"){
return false
}
const ls = <Feature<LineString>> feature;
Object.defineProperty(feature.properties, "_direction:centerpoint", {
enumerable: false,
configurable: true,
get: () => {
const centroid = GeoOperations.centerpoint(feature)
const projected = GeoOperations.nearestPoint(ls, <[number,number]> centroid.geometry.coordinates)
const nextPoint = ls.geometry.coordinates[projected.properties.index + 1]
const bearing = GeoOperations.bearing(projected.geometry.coordinates, nextPoint)
delete feature.properties["_direction:centerpoint"]
feature.properties["_direction:centerpoint"] = bearing
return bearing
},
})
return true
}
)
private static currentTime = new SimpleMetaTagger(
{
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
@ -457,6 +490,7 @@ export default class SimpleMetaTaggers {
SimpleMetaTaggers.country,
SimpleMetaTaggers.isOpen,
SimpleMetaTaggers.directionSimplified,
SimpleMetaTaggers.directionCenterpoint,
SimpleMetaTaggers.currentTime,
SimpleMetaTaggers.objectMetaInfo,
SimpleMetaTaggers.noBothButLeftRight,

View file

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

View file

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

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

View file

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

View file

@ -1,23 +1,65 @@
import {
Concat,
Conversion,
DesugaringContext,
DesugaringStep,
Each,
FirstOf,
Fuse,
On,
SetDefault,
} from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
import { Utils } from "../../../Utils"
import {Concat, Conversion, DesugaringContext, DesugaringStep, Each, FirstOf, Fuse, On, SetDefault,} from "./Conversion"
import {LayerConfigJson} from "../Json/LayerConfigJson"
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"
import {Utils} from "../../../Utils"
import RewritableConfigJson from "../Json/RewritableConfigJson"
import SpecialVisualizations from "../../../UI/SpecialVisualizations"
import Translations from "../../../UI/i18n/Translations"
import { Translation } from "../../../UI/i18n/Translation"
import {Translation} from "../../../UI/i18n/Translation"
import * as tagrenderingconfigmeta from "../../../assets/tagrenderingconfigmeta.json"
import { AddContextToTranslations } from "./AddContextToTranslations"
import {AddContextToTranslations} from "./AddContextToTranslations"
import FilterConfigJson from "../Json/FilterConfigJson";
import * as predifined_filters from "../../../assets/layers/filters/filters.json"
class ExpandFilter extends DesugaringStep<LayerConfigJson>{
private static load_filters(): Map<string, FilterConfigJson>{
let filters = new Map<string, FilterConfigJson>();
for (const filter of (<FilterConfigJson[]>predifined_filters.filter)) {
filters.set(filter.id, filter)
}
return filters;
}
private static readonly predefinedFilters = ExpandFilter.load_filters();
constructor() {
super("Expands filters: replaces a shorthand by the value found in 'filters.json'", ["filter"], "ExpandFilter");
}
convert(json: LayerConfigJson, context: string): { result: LayerConfigJson; errors?: string[]; warnings?: string[]; information?: string[] } {
if(json.filter === undefined || json.filter === null){
return {result: json} // Nothing to change here
}
if( json.filter["sameAs"] !== undefined){
return {result: json} // Nothing to change here
}
const newFilters : FilterConfigJson[] = []
const errors :string[]= []
for (const filter of (<(FilterConfigJson|string)[]> json.filter)) {
if (typeof filter !== "string") {
newFilters.push(filter)
continue
}
// Search for the filter:
const found = ExpandFilter.predefinedFilters.get(filter)
if(found === undefined){
const suggestions = Utils.sortedByLevenshteinDistance(filter, Array.from(ExpandFilter.predefinedFilters.keys()), t => t)
const err = context+".filter: while searching for predifined filter "+filter+": this filter is not found. Perhaps you meant one of: "+suggestions
errors.push(err)
}
newFilters.push(found)
}
return {result: {
...json, filter: newFilters
}, errors};
}
}
class ExpandTagRendering extends Conversion<
string | TagRenderingConfigJson | { builtin: string | string[]; override: any },
@ -178,7 +220,7 @@ class ExpandTagRendering extends Conversion<
if (lookup === undefined) {
let candidates = Array.from(state.tagRenderings.keys())
if (name.indexOf(".") > 0) {
const [layerName, search] = name.split(".")
const [layerName] = name.split(".")
let layer = state.sharedLayers.get(layerName)
if (layerName === this._self.id) {
layer = this._self
@ -699,7 +741,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
)
),
new SetDefault("titleIcons", ["defaults"]),
new On("titleIcons", (layer) => new Concat(new ExpandTagRendering(state, layer)))
new On("titleIcons", (layer) => new Concat(new ExpandTagRendering(state, layer))),
new ExpandFilter()
)
}
}

View file

@ -1,19 +1,23 @@
import { DesugaringStep, Each, Fuse, On } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import {DesugaringStep, Each, Fuse, On} from "./Conversion"
import {LayerConfigJson} from "../Json/LayerConfigJson"
import LayerConfig from "../LayerConfig"
import { Utils } from "../../../Utils"
import {Utils} from "../../../Utils"
import Constants from "../../Constants"
import { Translation } from "../../../UI/i18n/Translation"
import { LayoutConfigJson } from "../Json/LayoutConfigJson"
import {Translation} from "../../../UI/i18n/Translation"
import {LayoutConfigJson} from "../Json/LayoutConfigJson"
import LayoutConfig from "../LayoutConfig"
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { ExtractImages } from "./FixImages"
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"
import {TagUtils} from "../../../Logic/Tags/TagUtils"
import {ExtractImages} from "./FixImages"
import ScriptUtils from "../../../scripts/ScriptUtils"
import { And } from "../../../Logic/Tags/And"
import {And} from "../../../Logic/Tags/And"
import Translations from "../../../UI/i18n/Translations"
import Svg from "../../../Svg"
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
import {QuestionableTagRenderingConfigJson} from "../Json/QuestionableTagRenderingConfigJson"
import FilterConfigJson from "../Json/FilterConfigJson";
import {control} from "leaflet";
import layers = control.layers;
import DeleteConfig from "../DeleteConfig";
class ValidateLanguageCompleteness extends DesugaringStep<any> {
private readonly _languages: string[]
@ -40,12 +44,12 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> {
.forEach((missing) => {
errors.push(
context +
"A theme should be translation-complete for " +
neededLanguage +
", but it lacks a translation for " +
missing.context +
".\n\tThe known translation is " +
missing.tr.textFor("en")
"A theme should be translation-complete for " +
neededLanguage +
", but it lacks a translation for " +
missing.context +
".\n\tThe known translation is " +
missing.tr.textFor("en")
)
})
}
@ -79,16 +83,16 @@ export class DoesImageExist extends DesugaringStep<string> {
const information = []
if (image.indexOf("{") >= 0) {
information.push("Ignoring image with { in the path: " + image)
return { result: image }
return {result: image}
}
if (image === "assets/SocialImage.png") {
return { result: image }
return {result: image}
}
if (image.match(/[a-z]*/)) {
if (Svg.All[image + ".svg"] !== undefined) {
// This is a builtin img, e.g. 'checkmark' or 'crosshair'
return { result: image }
return {result: image}
}
}
@ -147,7 +151,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
const warnings = []
const information = []
const theme = new LayoutConfig(json, true)
const theme = new LayoutConfig(json, this._isBuiltin)
{
// Legacy format checks
@ -155,20 +159,20 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
if (json["units"] !== undefined) {
errors.push(
"The theme " +
json.id +
" has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) "
json.id +
" has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) "
)
}
if (json["roamingRenderings"] !== undefined) {
errors.push(
"Theme " +
json.id +
" contains an old 'roamingRenderings'. Use an 'overrideAll' instead"
json.id +
" contains an old 'roamingRenderings'. Use an 'overrideAll' instead"
)
}
}
}
{
if (this._isBuiltin) {
// Check images: are they local, are the licenses there, is the theme icon square, ...
const images = new ExtractImages(
this._isBuiltin,
@ -178,10 +182,10 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
for (const remoteImage of remoteImages) {
errors.push(
"Found a remote image: " +
remoteImage +
" in theme " +
json.id +
", please download it."
remoteImage +
" in theme " +
json.id +
", please download it."
)
}
for (const image of images) {
@ -210,10 +214,10 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
const h = parseInt(height)
if (w < 370 || h < 370) {
const e: string = [
`the icon for theme ${json.id} is too small. Please rescale the icon at ${json.icon}`,
`Even though an SVG is 'infinitely scaleable', the icon should be dimensioned bigger. One of the build steps of the theme does convert the image to a PNG (to serve as PWA-icon) and having a small dimension will cause blurry images.`,
` Width = ${width} height = ${height}; we recommend a size of at least 500px * 500px and to use a square aspect ratio.`,
].join("\n")
`the icon for theme ${json.id} is too small. Please rescale the icon at ${json.icon}`,
`Even though an SVG is 'infinitely scaleable', the icon should be dimensioned bigger. One of the build steps of the theme does convert the image to a PNG (to serve as PWA-icon) and having a small dimension will cause blurry images.`,
` Width = ${width} height = ${height}; we recommend a size of at least 500px * 500px and to use a square aspect ratio.`,
].join("\n")
;(json.hideFromOverview ? warnings : errors).push(e)
}
})
@ -224,32 +228,35 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
}
try {
if (theme.id !== theme.id.toLowerCase()) {
errors.push("Theme ids should be in lowercase, but it is " + theme.id)
}
if (this._isBuiltin) {
const filename = this._path.substring(
this._path.lastIndexOf("/") + 1,
this._path.length - 5
)
if (theme.id !== filename) {
errors.push(
"Theme ids should be the same as the name.json, but we got id: " +
if (theme.id !== theme.id.toLowerCase()) {
errors.push("Theme ids should be in lowercase, but it is " + theme.id)
}
const filename = this._path.substring(
this._path.lastIndexOf("/") + 1,
this._path.length - 5
)
if (theme.id !== filename) {
errors.push(
"Theme ids should be the same as the name.json, but we got id: " +
theme.id +
" and filename " +
filename +
" (" +
this._path +
")"
)
}
this._validateImage.convertJoin(
theme.icon,
context + ".icon",
errors,
warnings,
information
)
}
this._validateImage.convertJoin(
theme.icon,
context + ".icon",
errors,
warnings,
information
)
const dups = Utils.Dupiclates(json.layers.map((layer) => layer["id"]))
if (dups.length > 0) {
errors.push(
@ -298,7 +305,7 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
super(
"Validates a theme and the contained layers",
new ValidateTheme(doesImageExist, path, isBuiltin, sharedTagRenderings),
new On("layers", new Each(new ValidateLayer(undefined, false, doesImageExist)))
new On("layers", new Each(new ValidateLayer(undefined, isBuiltin, doesImageExist)))
)
}
}
@ -318,7 +325,7 @@ class OverrideShadowingCheck extends DesugaringStep<LayoutConfigJson> {
): { result: LayoutConfigJson; errors?: string[]; warnings?: string[] } {
const overrideAll = json.overrideAll
if (overrideAll === undefined) {
return { result: json }
return {result: json}
}
const errors = []
@ -345,7 +352,7 @@ class OverrideShadowingCheck extends DesugaringStep<LayoutConfigJson> {
}
}
return { result: json, errors }
return {result: json, errors}
}
}
@ -455,7 +462,7 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
const errors = []
const warnings = []
if (json.mappings === undefined || json.mappings.length === 0) {
return { result: json }
return {result: json}
}
const defaultProperties = {}
for (const calculatedTagName of this._calculatedTagNames) {
@ -484,7 +491,7 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
}
const keyValues = parsedConditions[i].asChange(defaultProperties)
const properties = {}
keyValues.forEach(({ k, v }) => {
keyValues.forEach(({k, v}) => {
properties[k] = v
})
for (let j = 0; j < i; j++) {
@ -501,12 +508,12 @@ export class DetectShadowedMappings extends DesugaringStep<QuestionableTagRender
// The current mapping is shadowed!
errors.push(`At ${context}: Mapping ${i} is shadowed by mapping ${j} and will thus never be shown:
The mapping ${parsedConditions[i].asHumanString(
false,
false,
{}
)} is fully matched by a previous mapping (namely ${j}), which matches:
false,
false,
{}
)} is fully matched by a previous mapping (namely ${j}), which matches:
${parsedConditions[j].asHumanString(false, false, {})}.
To fix this problem, you can try to:
- Move the shadowed mapping up
- Do you want to use a different text in 'question mode'? Add 'hideInAnswer=true' to the first mapping
@ -573,7 +580,7 @@ export class DetectMappingsWithImages extends DesugaringStep<TagRenderingConfigJ
const warnings: string[] = []
const information: string[] = []
if (json.mappings === undefined || json.mappings.length === 0) {
return { result: json }
return {result: json}
}
const ignoreToken = "ignore-image-in-then"
for (let i = 0; i < json.mappings.length; i++) {
@ -659,13 +666,13 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
if (json.title === undefined) {
errors.push(
context +
": this layer does not have a title defined but it does have tagRenderings. Not having a title will disable the popups, resulting in an unclickable element. Please add a title. If not having a popup is intended and the tagrenderings need to be kept (e.g. in a library layer), set `title: null` to disable this error."
": this layer does not have a title defined but it does have tagRenderings. Not having a title will disable the popups, resulting in an unclickable element. Please add a title. If not having a popup is intended and the tagrenderings need to be kept (e.g. in a library layer), set `title: null` to disable this error."
)
}
if (json.title === null) {
information.push(
context +
": title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set."
": title is `null`. This results in an element that cannot be clicked - even though tagRenderings is set."
)
}
}
@ -692,22 +699,28 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
if (duplicates.length > 0) {
errors.push(
"At " +
context +
": some tagrenderings have a duplicate id: " +
duplicates.join(", ")
context +
": some tagrenderings have a duplicate id: " +
duplicates.join(", ")
)
}
}
if(json.deletion !== undefined && json.deletion instanceof DeleteConfig){
if(json.deletion.softDeletionTags === undefined){
warnings.push("No soft-deletion tags in deletion block for layer "+json.id)
}
}
try {
{
if (this._isBuiltin) {
// Some checks for legacy elements
if (json["overpassTags"] !== undefined) {
errors.push(
"Layer " +
json.id +
'still uses the old \'overpassTags\'-format. Please use "source": {"osmTags": <tags>}\' instead of "overpassTags": <tags> (note: this isn\'t your fault, the custom theme generator still spits out the old format)'
json.id +
'still uses the old \'overpassTags\'-format. Please use "source": {"osmTags": <tags>}\' instead of "overpassTags": <tags> (note: this isn\'t your fault, the custom theme generator still spits out the old format)'
)
}
const forbiddenTopLevel = [
@ -725,18 +738,18 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
if (json[forbiddenKey] !== undefined)
errors.push(
context +
": layer " +
json.id +
" still has a forbidden key " +
forbiddenKey
": layer " +
json.id +
" still has a forbidden key " +
forbiddenKey
)
}
if (json["hideUnderlayingFeaturesMinPercentage"] !== undefined) {
errors.push(
context +
": layer " +
json.id +
" contains an old 'hideUnderlayingFeaturesMinPercentage'"
": layer " +
json.id +
" contains an old 'hideUnderlayingFeaturesMinPercentage'"
)
}
@ -747,15 +760,15 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
warnings.push(context + " has a tagRendering as `isShown`")
}
}
{
if (this._isBuiltin) {
// Check location of layer file
const expected: string = `assets/layers/${json.id}/${json.id}.json`
if (this._path != undefined && this._path.indexOf(expected) < 0) {
errors.push(
"Layer is in an incorrect place. The path is " +
this._path +
", but expected " +
expected
this._path +
", but expected " +
expected
)
}
}
@ -795,6 +808,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
}
}
}
if (json.tagRenderings !== undefined) {
const r = new On(
"tagRenderings",
@ -812,9 +826,9 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
if (hasCondition?.length > 0) {
errors.push(
"At " +
context +
":\n One or more icons in the mapRenderings have a condition set. Don't do this, as this will result in an invisible but clickable element. Use extra filters in the source instead. The offending mapRenderings are:\n" +
JSON.stringify(hasCondition, null, " ")
context +
":\n One or more icons in the mapRenderings have a condition set. Don't do this, as this will result in an invisible but clickable element. Use extra filters in the source instead. The offending mapRenderings are:\n" +
JSON.stringify(hasCondition, null, " ")
)
}
}
@ -826,7 +840,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
const preset = json.presets[i]
const tags: { k: string; v: string }[] = new And(
preset.tags.map((t) => TagUtils.Tag(t))
).asChange({ id: "node/-1" })
).asChange({id: "node/-1"})
const properties = {}
for (const tag of tags) {
properties[tag.k] = tag.v
@ -835,12 +849,12 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
if (!doMatch) {
errors.push(
context +
".presets[" +
i +
"]: This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " +
JSON.stringify(properties) +
"\n The required tags are: " +
baseTags.asHumanString(false, false, {})
".presets[" +
i +
"]: This preset does not match the required tags of this layer. This implies that a newly added point will not show up.\n A newly created point will have properties: " +
JSON.stringify(properties) +
"\n The required tags are: " +
baseTags.asHumanString(false, false, {})
)
}
}
@ -857,3 +871,109 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
}
}
}
export class DetectDuplicateFilters extends DesugaringStep<{ layers: LayerConfigJson[], themes: LayoutConfigJson[]}> {
constructor() {
super("Tries to detect layers where a shared filter can be used (or where similar filters occur)", [], "DetectDuplicateFilters");
}
/**
* Add all filter options into 'perOsmTag'
*/
private addLayerFilters(layer: LayerConfigJson, perOsmTag: Map<string, {
layer: LayerConfigJson,
layout: LayoutConfigJson | undefined,
filter: FilterConfigJson
}[]>, layout?: LayoutConfigJson | undefined): void {
if (layer.filter === undefined || layer.filter === null) {
return;
}
if (layer.filter["sameAs"] !== undefined) {
return;
}
for (const filter of (<(string | FilterConfigJson) []>layer.filter)) {
if (typeof filter === "string") {
continue
}
if(filter["#"]?.indexOf("ignore-possible-duplicate")>=0){
continue
}
for (const option of filter.options) {
if (option.osmTags === undefined) {
continue
}
const key = JSON.stringify(option.osmTags)
if (!perOsmTag.has(key)) {
perOsmTag.set(key, [])
}
perOsmTag.get(key).push({
layer, filter, layout
})
}
}
}
convert(json: { layers: LayerConfigJson[]; themes: LayoutConfigJson[] }, context: string): { result: { layers: LayerConfigJson[]; themes: LayoutConfigJson[] }; errors?: string[]; warnings?: string[]; information?: string[] } {
const errors: string[] = []
const warnings: string[] = []
const information: string[] = []
const {layers, themes} = json
const perOsmTag = new Map<string, {
layer: LayerConfigJson,
layout: LayoutConfigJson | undefined,
filter: FilterConfigJson
}[]>()
for (const layer of layers) {
this.addLayerFilters(layer, perOsmTag)
}
for (const theme of themes) {
if(theme.id === "personal"){
continue
}
for (const layer of theme.layers) {
if(typeof layer === "string"){
continue
}
if(layer["builtin"] !== undefined){
continue
}
this.addLayerFilters(<LayerConfigJson> layer, perOsmTag, theme)
}
}
// At this point, we have gathered all filters per tag - time to find duplicates
perOsmTag.forEach((value, key) => {
if(value.length <= 1){
// Seen this key just once, it is unique
return;
}
let msg = "Possible duplicate filter: "+ key
for (const {filter, layer, layout} of value) {
let id = ""
if(layout !== undefined){
id = layout.id + ":"
}
msg += `\n - ${id}${layer.id}.${filter.id}`
}
warnings.push(msg)
})
return {
result: json,
errors,
warnings,
information
};
}
}

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.

View file

@ -371,7 +371,7 @@ export default class LayerConfig extends WithContextLoader {
throw "Error in " + context + ": use 'filter' instead of 'filters'"
}
this.titleIcons = this.ParseTagRenderings(<TagRenderingConfigJson[]>json.titleIcons, {
this.titleIcons = this.ParseTagRenderings(<TagRenderingConfigJson[]>json.titleIcons ?? [], {
readOnlyMode: true,
})

View file

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

View file

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

View file

@ -267,6 +267,9 @@ export default class TagRenderingConfig {
if (this.freeform.key === "wikidata" && txt.indexOf("{wikipedia()") >= 0) {
continue
}
if (this.freeform.type === "wikidata" && txt.indexOf(`{wikidata_label(${this.freeform.key})`) >= 0) {
continue
}
throw `${context}: The rendering for language ${ln} does not contain the freeform key {${this.freeform.key}}. This is a bug, as this rendering should show exactly this freeform key!\nThe rendering is ${txt} `
}
}

View file

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

View file

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

View file

@ -109,10 +109,27 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
mp.remove()
}
public async TakeScreenshot() {
/**
* Takes a screenshot of the current map
* @param format: image: give a base64 encoded png image;
* @constructor
*/
public async TakeScreenshot(): Promise<string> ;
public async TakeScreenshot(format: "image"): Promise<string> ;
public async TakeScreenshot(format: "blob"): Promise<Blob> ;
public async TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> ;
public async TakeScreenshot(format: "image" | "blob" = "image"): Promise<string | Blob> {
console.log("Taking a screenshot...")
const screenshotter = new SimpleMapScreenshoter()
screenshotter.addTo(this.leafletMap.data)
return await screenshotter.takeScreen("image")
const result = <any> await screenshotter.takeScreen((<any> format) ?? "image")
if(format === "image" && typeof result === "string"){
return result
}
if(format === "blob" && result instanceof Blob){
return result
}
throw "Something went wrong while creating the screenshot: "+result
}
protected InnerConstructElement(): HTMLElement {

View file

@ -148,7 +148,7 @@ export default abstract class BaseUIElement {
} catch (e) {
const domExc = e as DOMException
if (domExc) {
console.log("An exception occured", domExc.code, domExc.message, domExc.name)
console.error("An exception occured", domExc.code, domExc.message, domExc.name, domExc)
}
console.error(e)
}

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"
export default class SearchAndGo extends Combine {
constructor(state: { leafletMap: UIEventSource<any>; selectedElement: UIEventSource<any> }) {
constructor(state: { leafletMap: UIEventSource<any>; selectedElement?: UIEventSource<any> }) {
const goButton = Svg.search_ui().SetClass("w-8 h-8 full-rounded border-black float-right")
const placeholder = new UIEventSource<Translation>(Translations.t.general.search.search)
@ -63,7 +63,7 @@ export default class SearchAndGo extends Combine {
[bb[0], bb[2]],
[bb[1], bb[3]],
]
state.selectedElement.setData(undefined)
state.selectedElement?.setData(undefined)
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id)
state.leafletMap.data.fitBounds(bounds)
placeholder.setData(Translations.t.general.search.search)

View file

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

View file

@ -11,7 +11,7 @@ import Link from "../Base/Link"
import LinkToWeblate from "../Base/LinkToWeblate"
import Toggleable from "../Base/Toggleable"
import Title from "../Base/Title"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import * as native_languages from "../../assets/language_native.json"
@ -89,8 +89,6 @@ class TranslatorsPanelContent extends Combine {
]
}
//
//
// "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
const translated = seed.Subs({
total,

View file

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

View file

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

View file

@ -1,44 +1,55 @@
import { ReadonlyInputElement } from "./InputElement"
import {ReadonlyInputElement} from "./InputElement"
import Loc from "../../Models/Loc"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Minimap, { MinimapObj } from "../Base/Minimap"
import {Store, UIEventSource} from "../../Logic/UIEventSource"
import Minimap, {MinimapObj} from "../Base/Minimap"
import BaseLayer from "../../Models/BaseLayer"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import State from "../../State"
import { GeoOperations } from "../../Logic/GeoOperations"
import {GeoOperations} from "../../Logic/GeoOperations"
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { BBox } from "../../Logic/BBox"
import { FixedUiElement } from "../Base/FixedUiElement"
import {BBox} from "../../Logic/BBox"
import {FixedUiElement} from "../Base/FixedUiElement"
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import BaseUIElement from "../BaseUIElement"
import Toggle from "./Toggle"
import * as matchpoint from "../../assets/layers/matchpoint/matchpoint.json"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {ElementStorage} from "../../Logic/ElementStorage";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {RelationId, WayId} from "../../Models/OsmFeature";
import {Feature, LineString, Polygon} from "geojson";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
export default class LocationInput
extends BaseUIElement
implements ReadonlyInputElement<Loc>, MinimapObj
{
implements ReadonlyInputElement<Loc>, MinimapObj {
private static readonly matchLayer = new LayerConfig(
matchpoint,
"LocationInput.matchpoint",
true
)
public readonly snappedOnto: UIEventSource<any> = new UIEventSource<any>(undefined)
public readonly snappedOnto: UIEventSource<Feature & { properties : { id : WayId} }> = new UIEventSource(undefined)
public readonly _matching_layer: LayerConfig
public readonly leafletMap: UIEventSource<any>
public readonly bounds
public readonly location
private _centerLocation: UIEventSource<Loc>
private readonly _centerLocation: UIEventSource<Loc>
private readonly mapBackground: UIEventSource<BaseLayer>
/**
* The features to which the input should be snapped
* @private
*/
private readonly _snapTo: Store<{ feature: any }[]>
private readonly _snapTo: Store< (Feature<LineString | Polygon> & {properties: {id : WayId}})[]>
/**
* The features to which the input should be snapped without cleanup of relations and memberships
* Used for rendering
* @private
*/
private readonly _snapToRaw: Store< {feature: Feature}[]>
private readonly _value: Store<Loc>
private readonly _snappedPoint: Store<any>
private readonly _maxSnapDistance: number
@ -47,33 +58,80 @@ export default class LocationInput
private readonly map: BaseUIElement & MinimapObj
private readonly clickLocation: UIEventSource<Loc>
private readonly _minZoom: number
private readonly _state: {
readonly filteredLayers: Store<FilteredLayer[]>;
readonly backgroundLayer: UIEventSource<BaseLayer>;
readonly layoutToUse: LayoutConfig;
readonly selectedElement: UIEventSource<any>;
readonly allElements: ElementStorage
}
constructor(options: {
/**
* Given a list of geojson-features, will prepare these features to be snappable:
* - points are removed
* - LineStrings are passed as-is
* - Multipolygons are decomposed into their member ways by downloading them
*
* @private
*/
private static async prepareSnapOnto(features: Feature[]): Promise<(Feature<LineString | Polygon> & {properties : {id: WayId}})[]> {
const linesAndPolygon : Feature<LineString | Polygon>[] = <any> features.filter(f => f.geometry.type !== "Point")
// Clean the features: multipolygons are split into their it's members
const linestrings : (Feature<LineString | Polygon> & {properties: {id: WayId}})[] = []
for (const feature of linesAndPolygon) {
if(feature.properties.id.startsWith("way")){
// A normal way - we continue
linestrings.push(<any> feature)
continue
}
// We have a multipolygon, thus: a relation
// Download the members
const relation = await OsmObject.DownloadObjectAsync(<RelationId> feature.properties.id, 60 * 60)
const members: OsmWay[] = await Promise.all(relation.members
.filter(m => m.type === "way")
.map(m => OsmObject.DownloadObjectAsync(<WayId> ("way/"+m.ref), 60 * 60)))
linestrings.push(...members.map(m => m.asGeoJson()))
}
return linestrings
}
constructor(options?: {
minZoom?: number
mapBackground?: UIEventSource<BaseLayer>
snapTo?: UIEventSource<{ feature: any }[]>
snapTo?: UIEventSource<{ feature: Feature }[]>
maxSnapDistance?: number
snappedPointTags?: any
requiresSnapping?: boolean
centerLocation: UIEventSource<Loc>
centerLocation?: UIEventSource<Loc>
bounds?: UIEventSource<BBox>
state?: {
readonly filteredLayers: Store<FilteredLayer[]>;
readonly backgroundLayer: UIEventSource<BaseLayer>;
readonly layoutToUse: LayoutConfig;
readonly selectedElement: UIEventSource<any>;
readonly allElements: ElementStorage
}
}) {
super()
this._snapTo = options.snapTo?.map((features) =>
features?.filter((feat) => feat.feature.geometry.type !== "Point")
)
this._maxSnapDistance = options.maxSnapDistance
this._centerLocation = options.centerLocation
this._snappedPointTags = options.snappedPointTags
this._bounds = options.bounds
this._minZoom = options.minZoom
this._snapToRaw = options?.snapTo?.map(feats => feats.filter(f => f.feature.geometry.type !== "Point"))
this._snapTo = options?.snapTo?.bind((features) => UIEventSource.FromPromise(LocationInput.prepareSnapOnto(features.map(f => f.feature))))?.map(f => f ?? [])
this._maxSnapDistance = options?.maxSnapDistance
this._centerLocation = options?.centerLocation ?? new UIEventSource<Loc>({
lat: 0, lon: 0, zoom: 0
})
this._snappedPointTags = options?.snappedPointTags
this._bounds = options?.bounds
this._minZoom = options?.minZoom
this._state = options?.state
if (this._snapTo === undefined) {
this._value = this._centerLocation
} else {
const self = this
if (self._snappedPointTags !== undefined) {
const layout = State.state.layoutToUse
const layout = this._state.layoutToUse
let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) {
@ -86,36 +144,39 @@ export default class LocationInput
this._matching_layer = LocationInput.matchLayer
}
this._snappedPoint = options.centerLocation.map(
// Calculate the location of the point based by snapping it onto a way
// As a side-effect, the actual snapped-onto way (if any) is saved into 'snappedOnto'
this._snappedPoint = this._centerLocation.map(
(loc) => {
if (loc === undefined) {
return undefined
}
// We reproject the location onto every 'snap-to-feature' and select the closest
let min = undefined
let matchedWay = undefined
let matchedWay: Feature<LineString | Polygon> & {properties : {id : WayId}} = undefined
for (const feature of self._snapTo.data ?? []) {
try {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [
const nearestPointOnLine = GeoOperations.nearestPoint(feature, [
loc.lon,
loc.lat,
])
if (min === undefined) {
min = nearestPointOnLine
matchedWay = feature.feature
matchedWay = feature
continue
}
if (min.properties.dist > nearestPointOnLine.properties.dist) {
min = nearestPointOnLine
matchedWay = feature.feature
matchedWay = feature
}
} catch (e) {
console.log(
"Snapping to a nearest point failed for ",
feature.feature,
feature,
"due to ",
e
)
@ -123,18 +184,25 @@ export default class LocationInput
}
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options.requiresSnapping) {
if (options?.requiresSnapping) {
return undefined
} else {
// No match found - the original coordinates are returned as is
return {
type: "Feature",
properties: options.snappedPointTags ?? min.properties,
geometry: { type: "Point", coordinates: [loc.lon, loc.lat] },
properties: options?.snappedPointTags ?? min.properties,
geometry: {type: "Point", coordinates: [loc.lon, loc.lat]},
}
}
}
min.properties = options.snappedPointTags ?? min.properties
self.snappedOnto.setData(matchedWay)
min.properties = options?.snappedPointTags ?? min.properties
if(matchedWay.properties.id.startsWith("relation/")){
// We matched a relation instead of a way
console.log("Snapping onto a relation. The relation is", matchedWay)
}
self.snappedOnto.setData(<any> matchedWay)
return min
},
[this._snapTo]
@ -149,14 +217,14 @@ export default class LocationInput
}
})
}
this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer
this.mapBackground = options?.mapBackground ?? this._state?.backgroundLayer ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
this.SetClass("block h-full")
this.clickLocation = new UIEventSource<Loc>(undefined)
this.map = Minimap.createMiniMap({
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state?.backgroundLayer,
attribution: this.mapBackground !== this._state?.backgroundLayer,
lastClickLocation: this.clickLocation,
bounds: this._bounds,
addLayerControl: true,
@ -177,15 +245,11 @@ export default class LocationInput
this.map.installBounds(factor, showRange)
}
TakeScreenshot(): Promise<any> {
return this.map.TakeScreenshot()
}
protected InnerConstructElement(): HTMLElement {
try {
const self = this
const hasMoved = new UIEventSource(false)
const startLocation = { ...this._centerLocation.data }
const startLocation = {...this._centerLocation.data}
this._centerLocation.addCallbackD((newLocation) => {
const f = 100000
console.log(newLocation.lon, startLocation.lon)
@ -201,21 +265,21 @@ export default class LocationInput
this.clickLocation.addCallbackAndRunD((location) =>
this._centerLocation.setData(location)
)
if (this._snapTo !== undefined) {
if (this._snapToRaw !== undefined) {
// Show the lines to snap to
console.log("Constructing the snap-to layer", this._snapTo)
console.log("Constructing the snap-to layer", this._snapToRaw)
new ShowDataMultiLayer({
features: StaticFeatureSource.fromDateless(this._snapTo),
features: StaticFeatureSource.fromDateless(this._snapToRaw),
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layers: State.state.filteredLayers,
layers: this._state.filteredLayers,
})
// Show the central point
const matchPoint = this._snappedPoint.map((loc) => {
if (loc === undefined) {
return []
}
return [{ feature: loc }]
return [{feature: loc}]
})
console.log("Constructing the match layer", matchPoint)
@ -224,8 +288,8 @@ export default class LocationInput
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layerToShow: this._matching_layer,
state: State.state,
selectedElement: State.state.selectedElement,
state: this._state,
selectedElement: this._state.selectedElement,
})
}
this.mapBackground.map(
@ -270,4 +334,11 @@ export default class LocationInput
.ConstructElement()
}
}
TakeScreenshot(format: "image"): Promise<string>;
TakeScreenshot(format: "blob"): Promise<Blob>;
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob>;
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> {
return this.map.TakeScreenshot(format)
}
}

View file

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

View file

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

View file

@ -22,6 +22,7 @@ import Title from "../Base/Title"
import { SubstitutedTranslation } from "../SubstitutedTranslation"
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState"
import TagRenderingQuestion from "./TagRenderingQuestion"
import {OsmId} from "../../Models/OsmFeature";
export default class DeleteWizard extends Toggle {
/**
@ -43,7 +44,7 @@ export default class DeleteWizard extends Toggle {
* @param state: the state of the application
* @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted
*/
constructor(id: string, state: FeaturePipelineState, options: DeleteConfig) {
constructor(id: OsmId, state: FeaturePipelineState, options: DeleteConfig) {
const deleteAbility = new DeleteabilityChecker(id, state, options.neededChangesets)
const tagsSource = state.allElements.getEventSourceById(id)

View file

@ -248,31 +248,29 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
)
editElements.push(
new VariableUiElement(
state.featureSwitchIsDebugging.map((isDebugging) => {
if (isDebugging) {
const config_all_tags: TagRenderingConfig = new TagRenderingConfig(
{ render: "{all_tags()}" },
""
)
const config_download: TagRenderingConfig = new TagRenderingConfig(
{ render: "{export_as_geojson()}" },
""
)
const config_id: TagRenderingConfig = new TagRenderingConfig(
{ render: "{open_in_iD()}" },
""
)
Toggle.If(state.featureSwitchIsDebugging,
() => {
const config_all_tags: TagRenderingConfig = new TagRenderingConfig(
{ render: "{all_tags()}" },
""
)
const config_download: TagRenderingConfig = new TagRenderingConfig(
{ render: "{export_as_geojson()}" },
""
)
const config_id: TagRenderingConfig = new TagRenderingConfig(
{ render: "{open_in_iD()}" },
""
)
return new Combine([
new TagRenderingAnswer(tags, config_all_tags, state),
new TagRenderingAnswer(tags, config_download, state),
new TagRenderingAnswer(tags, config_id, state),
"This is layer " + layerConfig.id,
])
}
})
)
return new Combine([
new TagRenderingAnswer(tags, config_all_tags, state),
new TagRenderingAnswer(tags, config_download, state),
new TagRenderingAnswer(tags, config_id, state),
"This is layer " + layerConfig.id,
])
}
)
)
return new Combine(editElements).SetClass("flex flex-col")

View file

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

View file

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

View file

@ -1,24 +1,27 @@
/**
* The statistics-gui shows statistics from previous MapComplete-edits
*/
import { UIEventSource } from "../Logic/UIEventSource"
import { VariableUiElement } from "./Base/VariableUIElement"
import {UIEventSource} from "../Logic/UIEventSource"
import {VariableUiElement} from "./Base/VariableUIElement"
import Loading from "./Base/Loading"
import { Utils } from "../Utils"
import {Utils} from "../Utils"
import Combine from "./Base/Combine"
import { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
import { LayerFilterPanel } from "./BigComponents/FilterView"
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import {StackedRenderingChart} from "./BigComponents/TagRenderingChart"
import {LayerFilterPanel} from "./BigComponents/FilterView"
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"
import MapState from "../Logic/State/MapState"
import BaseUIElement from "./BaseUIElement"
import Title from "./Base/Title"
import { FixedUiElement } from "./Base/FixedUiElement"
import List from "./Base/List";
class StatisticsForOverviewFile extends Combine {
constructor(homeUrl: string, paths: string[]) {
paths = paths.filter(p => !p.endsWith("file-overview.json"))
const layer = AllKnownLayouts.allKnownLayouts.get("mapcomplete-changes").layers[0]
const filteredLayer = MapState.InitializeFilteredLayers(
{ id: "statistics-view", layers: [layer] },
{id: "statistics-view", layers: [layer]},
undefined
)[0]
const filterPanel = new LayerFilterPanel(undefined, filteredLayer)
@ -27,9 +30,18 @@ class StatisticsForOverviewFile extends Combine {
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
for (const filepath of paths) {
if(filepath.endsWith("file-overview.json")){
continue
}
Utils.downloadJson(homeUrl + filepath).then((data) => {
if (data === undefined) {
return
}
if (data.features === undefined) {
data.features = data
}
data?.features?.forEach((item) => {
item.properties = { ...item.properties, ...item.properties.metadata }
item.properties = {...item.properties, ...item.properties.metadata}
delete item.properties.metadata
})
downloaded.data.push(data)
@ -43,6 +55,7 @@ class StatisticsForOverviewFile extends Combine {
)
)
super([
filterPanel,
new VariableUiElement(
@ -83,7 +96,49 @@ class StatisticsForOverviewFile extends Combine {
const trs = layer.tagRenderings.filter(
(tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined
)
const elements: BaseUIElement[] = []
const allKeys = new Set<string>()
for (const cs of overview._meta) {
for (const propertiesKey in cs.properties) {
allKeys.add(propertiesKey)
}
}
console.log("All keys:", allKeys)
const valuesToSum = [
"create",
"modify",
"delete",
"answer",
"move",
"deletion",
"add-image",
"plantnet-ai-detection",
"import",
"conflation",
"link-image",
"soft-delete"]
const allThemes = Utils.Dedup(overview._meta.map(f => f.properties.theme))
const excludedThemes = new Set<string>()
if(allThemes.length > 1){
excludedThemes.add("grb")
excludedThemes.add("etymology")
}
const summedValues = valuesToSum
.map(key => [key, overview.sum(key, excludedThemes)])
.filter(kv => kv[1] != 0)
.map(kv => kv.join(": "))
const elements: BaseUIElement[] = [
new Title(allThemes .length === 1 ? "General statistics for "+allThemes[0] :"General statistics (excluding etymology- and GRB-theme changes)"),
new Combine([
overview._meta.length + " changesets match the filters",
new List(summedValues)
]).SetClass("flex flex-col border rounded-xl"),
new Title("Breakdown")
]
for (const tr of trs) {
let total = undefined
if (tr.freeform?.key !== undefined) {
@ -186,6 +241,20 @@ class ChangesetsOverview {
return new ChangesetsOverview(this._meta.filter(predicate))
}
public sum(key: string, excludeThemes: Set<string>): number {
let s = 0
for (const feature of this._meta) {
if(excludeThemes.has(feature.properties.theme)){
continue
}
const parsed = Number(feature.properties[key])
if (!isNaN(parsed)) {
s += parsed
}
}
return s
}
private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
if (cs === undefined) {
return undefined
@ -211,7 +280,8 @@ class ChangesetsOverview {
}
try {
cs.properties.host = new URL(cs.properties.host).host
} catch (e) {}
} catch (e) {
}
return cs
}
}

View file

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

View file

@ -12,8 +12,8 @@ export class Utils {
url: string,
headers?: any
) => Promise<{ content: string } | { redirect: string }>
public static Special_visualizations_tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`.
This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature.
public static Special_visualizations_tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`.
This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature.
If a value to substitute is undefined, empty string will be used instead.
@ -41,11 +41,11 @@ There are also some technicalities in your theme to keep in mind:
This should be used to change the appearance or even to hide it (eg by changing the icon size to zero)
3. There should be a way for the theme to detect previously imported points, even after reloading.
A reference number to the original dataset is an excellent way to do this
4. When importing ways, the theme creator is also responsible of avoiding overlapping ways.
4. When importing ways, the theme creator is also responsible of avoiding overlapping ways.
#### Disabled in unofficial themes
The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md).
The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md).
The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console.
In the case that MapComplete is pointed to the testing grounds, the edit will be made on https://master.apis.dev.openstreetmap.org`
private static knownKeys = [
@ -823,7 +823,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
} else if (xhr.status === 509 || xhr.status === 429) {
reject("rate limited")
} else {
reject(xhr.statusText)
reject("Could not download "+url+" due to "+xhr.statusText)
}
}
xhr.open("GET", url)

113
Utils/pngMapCreator.ts Normal file
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 AllThemesGui from "./UI/AllThemesGui"
import { QueryParameters } from "./Logic/Web/QueryParameters"
import StatisticsGUI from "./UI/StatisticsGUI"
import { FixedUiElement } from "./UI/Base/FixedUiElement"
import {PdfExportGui} from "./UI/BigComponents/PdfExportGui";
const layout = QueryParameters.GetQueryParameter("layout", undefined).data ?? ""
const customLayout = QueryParameters.GetQueryParameter("userlayout", undefined).data ?? ""
@ -42,6 +45,13 @@ if (mode.data === "statistics") {
console.log("Statistics mode!")
new FixedUiElement("").AttachTo("centermessage")
new StatisticsGUI().SetClass("w-full h-full pointer-events-auto").AttachTo("topleft-tools")
} else if(mode.data === "pdf"){
MinimapImplementation.initialize()
new FixedUiElement("").AttachTo("centermessage")
const div = document.createElement("div")
div.id = "extra_div_for_maps"
new PdfExportGui(div.id).SetClass("pointer-events-auto").AttachTo("topleft-tools")
document.getElementById("topleft-tools").appendChild(div)
} else {
new AllThemesGui().setup()
}

View file

@ -407,10 +407,38 @@
"es": "Cerámica",
"da": "flisebeklædning"
}
},
{
"if": "artwork_type=woodcarving",
"then": {
"nl": "Houtsculptuur",
"en": "Woodcarving"
}
}
],
"id": "artwork-artwork_type"
},
{
"id": "artwork-artist-wikidata",
"render": {
"en": "This artwork was made by {wikidata_label(artist:wikidata):font-weight:bold}<br/>{wikipedia(artist:wikidata)}"
},
"question": {
"en": "Who made this artwork?"
},
"freeform": {
"key": "artist:wikidata",
"type": "wikidata",
"helperArgs": [
{
"key": "artist_name",
"instanceOf": [
"Q5"
]
}
]
}
},
{
"question": {
"en": "Which artist created this?",
@ -449,6 +477,7 @@
"freeform": {
"key": "artist_name"
},
"condition": "artist:wikidata=",
"id": "artwork-artist_name"
},
{
@ -492,44 +521,20 @@
},
"id": "artwork-website"
},
"wikipedia",
{
"id": "artwork_subject",
"condition": "subject:wikidata~*",
"question": {
"en": "Which Wikidata-entry corresponds with <b>this artwork</b>?",
"nl": "Welk Wikidata-item beschrijft <b>dit kunstwerk</b>?",
"fr": "Quelle entrée Wikidata correspond à <b>cette œuvre d'art</b> ?",
"de": "Gibt es ein Wikidata Element für <b>dieses Kunstwerk</b>?",
"it": "Quale elemento Wikidata corrisponde a <b>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>"
"en": "What does this artwork depict?"
},
"freeform": {
"key": "wikidata",
"key": "subject:wikidata",
"type": "wikidata"
},
"id": "artwork-wikidata"
"render": {
"en": "This artwork depicts {wikidata_label(subject:wikidata)}{wikipedia(subject:wikidata)}"
}
}
],
"deletion": {
@ -563,5 +568,8 @@
"render": "10"
}
}
],
"filter": [
"has_image"
]
}

View file

@ -281,24 +281,9 @@
"reviews"
],
"filter": [
{
"id": "opened-now",
"options": [
{
"question": {
"en": "Opened now",
"nl": "Nu geopend",
"de": "Derzeit geöffnet",
"fr": "Ouvert maintenant",
"hu": "Most nyitva van",
"ca": "Obert ara",
"es": "Abiert oahora",
"da": "Åbent nu"
},
"osmTags": "_isOpen=yes"
}
]
}
"open_now",
"accepts_cash",
"accepts_cards"
],
"deletion": {
"softDeletionTags": {

View file

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

View file

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

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"
],
"filter": [
{
"id": "opened-now",
"options": [
{
"question": {
"en": "Opened now",
"nl": "Nu geopened",
"de": "Aktuell geöffnet",
"ca": "Obert ara",
"es": "Abierta ahora",
"fr": "Ouvert maintenant"
},
"osmTags": "_isOpen=yes"
}
]
},
"open_now",
{
"id": "vegetarian",
"options": [
@ -849,36 +834,8 @@
}
]
},
{
"id": "accepts-cash",
"options": [
{
"osmTags": "payment:cash=yes",
"question": {
"en": "Accepts cash",
"de": "Akzeptiert Bargeld",
"es": "Acepta efectivo",
"nl": "Accepteert cash",
"fr": "Accepte les paiements en espèces"
}
}
]
},
{
"id": "accepts-cards",
"options": [
{
"osmTags": "payment:cards=yes",
"question": {
"en": "Accepts payment cards",
"de": "Akzeptiert Kartenzahlung",
"es": "Acepta tarjetas de pago",
"nl": "Accepteert betaalkaarten",
"fr": "Accepte les cartes de paiement"
}
}
]
}
"accepts_cash",
"accepts_cards"
],
"deletion": {
"nonDeleteMappings": [

View file

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

View file

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

View file

@ -212,25 +212,7 @@
}
],
"filter": [
{
"id": "is_open",
"options": [
{
"question": {
"en": "Currently open",
"de": "Aktuell geöffnet",
"zh_Hant": "目前開放",
"id": "Saat ini buka",
"hu": "Most nyitva",
"nl": "Momenteel geopend",
"ca": "Actualment obert",
"es": "Actualmente abierta",
"fr": "Ouvert actuellement"
},
"osmTags": "_isOpen=yes"
}
]
}
"open_now"
],
"allowMove": {
"enableImproveAccuracy": true

View file

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

View file

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

View file

@ -121,25 +121,7 @@
}
],
"filter": [
{
"id": "is_open",
"options": [
{
"question": {
"en": "Currently open",
"de": "Aktuell geöffnet",
"zh_Hant": "目前開放",
"id": "Saat ini buka",
"hu": "Most nyitva",
"nl": "Momenteel geopend",
"ca": "Actualment obert",
"es": "Actualmente abierta",
"fr": "Ouvert actuellement"
},
"osmTags": "_isOpen=yes"
}
]
}
"open_now"
],
"mapRendering": [
{

View file

@ -990,21 +990,7 @@
}
],
"filter": [
{
"id": "isOpen",
"options": [
{
"question": {
"en": "Currently open",
"nl": "Op dit moment open",
"de": "Derzeit geöffnet",
"es": "Actualmente abierto",
"it": "Aperto ora"
},
"osmTags": "_isOpen=yes"
}
]
},
"open_now",
{
"id": "recyclingType",
"options": [

View file

@ -295,6 +295,7 @@
}
],
"filter": [
"open_now",
{
"id": "shop-type",
"options": [
@ -337,35 +338,7 @@
}
]
},
{
"id": "accepts-cash",
"options": [
{
"osmTags": "payment:cash=yes",
"question": {
"en": "Accepts cash",
"de": "Akzeptiert Bargeld",
"nl": "Accepteert cash",
"es": "Acepta efectivo",
"fr": "Accepte les espèces"
}
}
]
},
{
"id": "accepts-cards",
"options": [
{
"osmTags": "payment:cards=yes",
"question": {
"en": "Accepts payment cards",
"de": "Akzeptiert Kartenzahlung",
"nl": "Accepteert betaalkaarten",
"es": "Acepta el pago por tarjeta",
"fr": "Accepte les cartes de paiement"
}
}
]
}
"accepts_cash",
"accepts_cards"
]
}

View file

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

View file

@ -21,7 +21,7 @@
"render": "{export_as_geojson()}"
},
"wikipedia": {
"description": "Shows a wikipedia box with the corresponding wikipedia article",
"description": "Shows a wikipedia box with the corresponding wikipedia article; the wikidata-item link can be changed by a contributor",
"render": "{wikipedia():max-height:25rem}",
"question": {
"en": "What is the corresponding Wikidata entity?",

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