Merge branch 'develop'

This commit is contained in:
Pieter Vander Vennet 2021-10-06 12:40:38 +02:00
commit 9f3ba46530
309 changed files with 36982 additions and 9766 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.json merge=json

View file

@ -11,7 +11,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v1.2.0 uses: actions/setup-node@v1.2.0
with: with:
node-version: '15' node-version: '16'
env: env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'

View file

@ -1,71 +0,0 @@
name: Pull request check
on:
pull_request_target:
types: [ opened, edited, synchronize, ready_for_review, review_requested ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v1.2.0
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
- name: install deps
run: npm ci
- name: create generated dir
run: mkdir ./assets/generated
- name: create stub themes
run: "echo '{\"layers\": [], \"themes\": []}' > ./assets/generated/known_layers_and_themes.json"
- name: generate assets
run: npm run generate:images
- name: generate translations
run: npm run generate:translations
- name: Compile license info
run: npm run generate:licenses
- name: Compile and validate themes and layers
run: npm run validate:layeroverview
- name: Validate license info
run: npm run validate:licenses
- name: Set failure key
run: |
ls
if [[ -f "layer_report.txt" || -f "missing_licenses.txt" ]]; then
echo "Found a report..."
echo "VALIDATION_FAILED=true" >> $GITHUB_ENV
else
echo "VALIDATION_FAILED=false" >> $GITHUB_ENV
fi
- name: Test variable
run: echo "${{ env.VALIDATION_FAILED }}"
- name: Archive reports
uses: actions/upload-artifact@v2
if: >-
env.VALIDATION_FAILED == 'true'
with:
name: reports
path: |
layer_report.txt
missing_licenses.txt
- name: Comment PR
uses: allthatjazzleo/actions-pull-request-add-comment@master
if: >-
env.VALIDATION_FAILED == 'true'
with:
message: "cat layer_report.txt missing_licenses.txt"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -13,7 +13,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v1.2.0 uses: actions/setup-node@v1.2.0
with: with:
node-version: '15' node-version: '16'
env: env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
@ -23,9 +23,6 @@ jobs:
- name: create generated dir - name: create generated dir
run: mkdir ./assets/generated run: mkdir ./assets/generated
- name: create stub themes
run: "echo '{\"layers\": [], \"themes\": []}' > ./assets/generated/known_layers_and_themes.json"
- name: Prepare deploy - name: Prepare deploy
run: npm run prepare-deploy run: npm run prepare-deploy

View file

@ -1 +1 @@
nodejs 14.14.0 nodejs 16.9.1

View file

@ -1,6 +1,7 @@
import AllKnownLayers from "./AllKnownLayers"; import AllKnownLayers from "./AllKnownLayers";
import * as known_themes from "../assets/generated/known_layers_and_themes.json" import * as known_themes from "../assets/generated/known_layers_and_themes.json"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
export class AllKnownLayouts { export class AllKnownLayouts {
@ -8,6 +9,26 @@ export class AllKnownLayouts {
public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts(); public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts();
public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(AllKnownLayouts.allKnownLayouts); public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(AllKnownLayouts.allKnownLayouts);
public static AllPublicLayers(){
const allLayers : LayerConfig[] = []
const seendIds = new Set<string>()
const publicLayouts = AllKnownLayouts.layoutsList.filter(l => !l.hideFromOverview)
for (const layout of publicLayouts) {
if(layout.hideFromOverview){
continue
}
for (const layer of layout.layers) {
if(seendIds.has(layer.id)){
continue
}
seendIds.add(layer.id)
allLayers.push(layer)
}
}
return allLayers
}
private static GenerateOrderedList(allKnownLayouts: Map<string, LayoutConfig>): LayoutConfig[] { private static GenerateOrderedList(allKnownLayouts: Map<string, LayoutConfig>): LayoutConfig[] {
const keys = ["personal", "cyclofix", "hailhydrant", "bookcases", "toilets", "aed"] const keys = ["personal", "cyclofix", "hailhydrant", "bookcases", "toilets", "aed"]
const list = [] const list = []

View file

@ -1,3 +1,4 @@
Metatags Metatags
========== ==========
@ -5,11 +6,9 @@ Metatags
Metatags are extra tags available, in order to display more data or to give better questions. Metatags are extra tags available, in order to display more data or to give better questions.
The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.
overview of the available metatags.
**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a **Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object
box in the popup for features which shows all the properties of the object
Metatags calculated by MapComplete Metatags calculated by MapComplete
@ -17,53 +16,76 @@ Metatags calculated by MapComplete
The following values are always calculated, by default, by MapComplete and are available automatically on all elements The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme
in every theme
### _lat, _lon ### _lat, _lon
The latitude and longitude of the point (or centerpoint in the case of a way/area) The latitude and longitude of the point (or centerpoint in the case of a way/area)
### _surface, _surface:ha ### _surface, _surface:ha
The surface area of the feature, in square meters and in hectare. Not set on points and ways The surface area of the feature, in square meters and in hectare. Not set on points and ways
### _length, _length:km ### _length, _length:km
The total length of a feature in meters (and in kilometers, rounded to one decimal for '_length:km'). For a surface, the
length of the perimeter
The total length of a feature in meters (and in kilometers, rounded to one decimal for '_length:km'). For a surface, the length of the perimeter
### Theme-defined keys ### Theme-defined keys
If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical
form (e.g. `1meter` will be rewritten to `1m`)
If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical form (e.g. `1meter` will be rewritten to `1m`)
### _country ### _country
The country code of the property (with latlon2country) The country code of the property (with latlon2country)
### _isOpen, _isOpen:description ### _isOpen, _isOpen:description
If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no') If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no')
### _width:needed, _width:needed:no_pedestrians, _width:difference ### _width:needed, _width:needed:no_pedestrians, _width:difference
Legacy for a specific project calculating the needed width for safe traffic on a road. Only activated if 'width:
carriageway' is present
Legacy for a specific project calculating the needed width for safe traffic on a road. Only activated if 'width:carriageway' is present
### _direction:numerical, _direction:leftright ### _direction:numerical, _direction:leftright
_direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only
present if a valid direction is found (e.g. 38.5 or NE). _direction:leftright is either 'left' or 'right', which is
left-looking on the map or 'right-looking' on the map _direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). _direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map
### _now:date, _now:datetime, _loaded:date, _loaded:_datetime ### _now:date, _now:datetime, _loaded:date, _loaded:_datetime
Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:
mm, aka 'sortable' aka ISO-8601-but-not-entirely
Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely
### _last_edit:contributor, _last_edit:contributor:uid, _last_edit:changeset, _last_edit:timestamp, _version_number ### _last_edit:contributor, _last_edit:contributor:uid, _last_edit:changeset, _last_edit:timestamp, _version_number
Information about the last edit of this object. Information about the last edit of this object.
@ -72,18 +94,20 @@ Calculating tags with Javascript
In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above.
default (e.g. `lat`, `lon`, `_country`), as detailed above.
It is also possible to calculate your own tags - but this requires some javascript knowledge. It is also possible to calculate your own tags - but this requires some javascript knowledge.
Before proceeding, some warnings: Before proceeding, some warnings:
- DO NOT DO THIS AS BEGINNER - DO NOT DO THIS AS BEGINNER
- **Only do this if all other techniques fail** This should _not_ be done to create a rendering effect, only to - **Only do this if all other techniques fail** This should _not_ be done to create a rendering effect, only to calculate a specific value
calculate a specific value - **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.
- **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the
internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.
To enable this feature, add a field `calculatedTags` in the layer object, e.g.: To enable this feature, add a field `calculatedTags` in the layer object, e.g.:
@ -101,12 +125,16 @@ To enable this feature, add a field `calculatedTags` in the layer object, e.g.:
```` ````
The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended
geojson object:
The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:
- `area` contains the surface area (in square meters) of the object - `area` contains the surface area (in square meters) of the object
- `lat` and `lon` contain the latitude and longitude - `lat` and `lon` contain the latitude and longitude
Some advanced functions are available on **feat** as well: Some advanced functions are available on **feat** as well:
- distanceTo - distanceTo
@ -117,25 +145,22 @@ Some advanced functions are available on **feat** as well:
### distanceTo ### distanceTo
Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object
coordinates, a geojson feature or the ID of an object
0. longitude 0. longitude
1. latitude 1. latitude
### overlapWith ### overlapWith
Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point.
point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]`
where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`
feature is a line or `undefined` if the current feature is a point
0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap) 0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
### closest ### closest
Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.
In the case of ways/polygons, only the centerpoint is considered.
0. list of features 0. list of features
@ -145,12 +170,13 @@ Gives a list of `{role: string, relation: Relation}`-objects, containing all the
For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')` For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
### score ### score
Given the path of an aspected routing json file, will calculate the score. This score is wrapped in a UIEventSource, so Given the path of an aspected routing json file, will calculate the score. This score is wrapped in a UIEventSource, so for further calculations, use `.map(score => ...)`
for further calculations, use `.map(score => ...)`
For For example: `_comfort_score=feat.score('https://raw.githubusercontent.com/pietervdvn/AspectedRouting/master/Examples/bicycle/aspects/bicycle.comfort.json')`
example: `_comfort_score=feat.score('https://raw.githubusercontent.com/pietervdvn/AspectedRouting/master/Examples/bicycle/aspects/bicycle.comfort.json')`
0. path Generated from SimpleMetaTagger, ExtraFunction 0. path
Generated from SimpleMetaTagger, ExtraFunction

View file

@ -26,14 +26,17 @@ Devcontainer (see more details later).
To develop and build MapComplete, you To develop and build MapComplete, you
0. Make sure you have a recent version of nodejs - at least 12.0, preferably 15
0. Make a fork and clone the repository. 0. Make a fork and clone the repository.
1. Install `npm`. Linux: `sudo apt install npm` (or your favourite package manager), Windows: install 0. Install the nodejs version specified in [.tool-versions](./.tool-versions)
- You can [use asdf to manage your runtime versions](https://asdf-vm.com/).
0. Install `npm`. Linux: `sudo apt install npm` (or your favourite package manager), Windows: install
nodeJS: https://nodejs.org/en/download/ nodeJS: https://nodejs.org/en/download/
3. Run `npm run init` and generate some additional dependencies and generated files. Note that it'll install the 0. On iOS, install `wget` (`brew install wget`)
dependencies too 0. Run `npm run init` which …
4. Run `npm run start` to host a local testversion at http://localhost:1234/index.html - runs `npm install`
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` - generates some additional dependencies and files
0. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
0. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename`
or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs ( or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (
e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version. e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
@ -99,6 +102,11 @@ Weird errors
Try removing `node_modules`, `package-lock.json` and `.cache` Try removing `node_modules`, `package-lock.json` and `.cache`
Misc setup
----------
The json-git-merger is used to quickly merge translation files, [documentation here](https://github.com/jonatanpedersen/git-json-merge#single-project--directory)
Overview of package.json-scripts Overview of package.json-scripts
-------------------------------- --------------------------------
@ -125,4 +133,3 @@ Overview of package.json-scripts
- `deploy:staging`,`deploy:pietervdvn`, `deploy:production`: deploy the latest code on various locations - `deploy:staging`,`deploy:pietervdvn`, `deploy:production`: deploy the latest code on various locations
- `lint`: get depressed by the amount of warnings - `lint`: get depressed by the amount of warnings
- `clean`: remove some generated files which are annoying in the repo - `clean`: remove some generated files which are annoying in the repo

View file

@ -3,6 +3,41 @@ Release Notes
Some highlights of new releases. Some highlights of new releases.
0.10
----
The 0.10 version contains a lot of refactorings on various core of the application, namely in the rendering stack, the fetching of data and uploading.
Some highlights are:
1. The addition of fallback overpass servers
2. Fetching data from OSM directly (especially useful in the personal theme)
3. Splitting all the features per tile (with a maximum amount of features per tile, splitting further if needed), making everything a ton faster
4. If a tile has too much features, the featuers are not shown. Instead, a rectangle with the feature amount is shown.
Furthermore, it contains a few new themes and theme updates:
- Restaurants and fast food
- Pubs and cafés
- Charging stations got a major overhaul - thanks for all the input on the available plugs
- Observation towers and binoculars
- The addition of a hackerspace theme (as made on SOTM)
Other various small improvements:
- The filter state is now exposed in the URL, so can be shared
- Lots of other fixes, as usual
0.8 and 0.9
-----------
Addition of filters per layer
Addition of a download-as-pdf for select themes
Addition of a download-as-geojson and download-as-csv for select themes
...
0.7.0 0.7.0
----- -----

View file

@ -1,7 +1,6 @@
# Available types for text fields # Available types for text fields
The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them
activate them
## string ## string
@ -21,8 +20,7 @@ A geographical direction, in degrees. 0° is north, 90° is east, ... Will retur
## length ## length
A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]
Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]
## wikidata ## wikidata

View file

@ -1,10 +1,7 @@
### Special tag renderings ### Special tag renderings
In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's. General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_fcs need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args
visualizations to be reused by custom themes or even to query third-party API's. General usage is `{func_name()}`
, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_fcs need to use quotes around
your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args
### all_tags ### all_tags
Prints all key-value pairs of the object - used for debugging Prints all key-value pairs of the object - used for debugging
@ -12,14 +9,13 @@ Prints all key-value pairs of the object - used for debugging
name | default | description name | default | description
------ | --------- | ------------- ------ | --------- | -------------
#### Example usage #### Example usage
{all_tags()} `{all_tags()}`
### image_carousel ### image_carousel
Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)
Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)
name | default | description name | default | description
------ | --------- | ------------- ------ | --------- | -------------
@ -28,8 +24,7 @@ smart search | true | Also include images given via 'Wikidata', 'wikimedia_commo
#### Example usage #### Example usage
{image_carousel(image,true)} `{image_carousel(image,true)}`
### image_upload ### image_upload
Creates a button where a user can upload an image to IMGUR Creates a button where a user can upload an image to IMGUR
@ -40,8 +35,7 @@ image-key | image | Image tag to add the URL to (or image-tag:0, image-tag:1 whe
#### Example usage #### Example usage
{image_upload(image)} `{image_upload(image)}`
### minimap ### minimap
A small map showing the selected feature. Note that no styling is applied, wrap this in a div A small map showing the selected feature. Note that no styling is applied, wrap this in a div
@ -53,13 +47,10 @@ idKey | id | (Matches all resting arguments) This argument should be the key of
#### Example usage #### Example usage
`{minimap()}` `{minimap()}`, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`
, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`
### reviews ### reviews
Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten
object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten
name | default | description name | default | description
------ | --------- | ------------- ------ | --------- | -------------
@ -68,13 +59,10 @@ fallback | undefined | The identifier to use, if <i>tags[subjectKey]</i> as spec
#### Example usage #### Example usage
<b>{reviews()}<b> for a vanilla review, <b>{reviews(name, play_forest)}</b> to review a play forest. If a name is known, `{reviews()}` for a vanilla review, `{reviews(name, play_forest)}` to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used
the name will be used as identifier, otherwise 'play_forest' is used
### opening_hours_table ### opening_hours_table
Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag ' Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag 'opening_hours'.
opening_hours'.
name | default | description name | default | description
------ | --------- | ------------- ------ | --------- | -------------
@ -82,14 +70,10 @@ key | opening_hours | The tagkey from which the table is constructed.
#### Example usage #### Example usage
{opening_hours_table(opening_hours)} `{opening_hours_table(opening_hours)}`
### live ### live
Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed_value)}
download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will
return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format},
needed_value)}
name | default | description name | default | description
------ | --------- | ------------- ------ | --------- | -------------
@ -99,9 +83,7 @@ path | undefined | The path (or shorthand) that should be returned
#### Example usage #### Example usage
{live({url},{url:format},hour)} {live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)}
{live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)}
### histogram ### histogram
Create a histogram for a list of given values, read from the properties. Create a histogram for a list of given values, read from the properties.
@ -116,7 +98,6 @@ colors* | undefined | (Matches all resting arguments - optional) Matches a regex
#### Example usage #### Example usage
`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram `{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram
### share_link ### share_link
Creates a link that (attempts to) open the native 'share'-screen Creates a link that (attempts to) open the native 'share'-screen
@ -128,7 +109,6 @@ url | undefined | The url to share (default: current URL)
#### Example usage #### Example usage
{share_link()} to share the current page, {share_link(<some_url>)} to share the given url {share_link()} to share the current page, {share_link(<some_url>)} to share the given url
### canonical ### canonical
Converts a short, canonical value into the long, translated text Converts a short, canonical value into the long, translated text
@ -139,4 +119,25 @@ key | undefined | The key of the tag to give the canonical text for
#### Example usage #### Example usage
{canonical(length)} will give 42 metre (in french) Generated from UI/SpecialVisualisations.ts {canonical(length)} will give 42 metre (in french)
### import_button
This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes.
If you want to import a dataset, make sure that:
1. The dataset to import has a suitable license
2. The community has been informed of the import
3. The theme will filter out duplicate nodes4. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed
name | default | description
------ | --------- | -------------
tags | undefined | Tags to copy-specification. This contains one or more pairs (seperated by a `;`), e.g. `amenity=fast_food; 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. (Hint: prepare these values, e.g. with calculatedTags)
text | Import this data into OpenStreetMap | The text to show on the button
icon | ./assets/svg/addSmall.svg | A nice icon to show in the button
#### Example usage
`{import_button(,Import this data into OpenStreetMap,./assets/svg/addSmall.svg)}`
Generated from UI/SpecialVisualisations.ts

View file

@ -106,121 +106,464 @@
}, },
{ {
"key": "socket:schuko", "key": "socket:schuko",
"description": "Layer 'Charging stations' shows socket:schuko=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/CEE7_4F.svg'/> <b>Schuko wall plug</b> without ground pin (CEE7/4 type F)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:schuko=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/CEE7_4F.svg'/> <b>Schuko wall plug</b> without ground pin (CEE7/4 type F)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:schuko", "key": "socket:schuko",
"description": "Layer 'Charging stations' shows socket:schuko~^..*$&socket:schuko!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/CEE7_4F.svg'/> <b>Schuko wall plug</b> without ground pin (CEE7/4 type F)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:schuko~^..*$&socket:schuko!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/CEE7_4F.svg'/> <b>Schuko wall plug</b> without ground pin (CEE7/4 type F)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:typee", "key": "socket:typee",
"description": "Layer 'Charging stations' shows socket:typee=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/TypeE.svg'/> <b>European wall plug</b> with ground pin (CEE7/4 type E)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:typee=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/TypeE.svg'/> <b>European wall plug</b> with ground pin (CEE7/4 type E)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:typee", "key": "socket:typee",
"description": "Layer 'Charging stations' shows socket:typee~^..*$&socket:typee!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/TypeE.svg'/> <b>European wall plug</b> with ground pin (CEE7/4 type E)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:typee~^..*$&socket:typee!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/TypeE.svg'/> <b>European wall plug</b> with ground pin (CEE7/4 type E)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:chademo", "key": "socket:chademo",
"description": "Layer 'Charging stations' shows socket:chademo=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Chademo_type4.svg'/> <b>Chademo</b>' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:chademo=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Chademo_type4.svg'/> <b>Chademo</b>' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:chademo", "key": "socket:chademo",
"description": "Layer 'Charging stations' shows socket:chademo~^..*$&socket:chademo!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Chademo_type4.svg'/> <b>Chademo</b>' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:chademo~^..*$&socket:chademo!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Chademo_type4.svg'/> <b>Chademo</b>' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:type1_cable", "key": "socket:type1_cable",
"description": "Layer 'Charging stations' shows socket:type1_cable=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 with cable</b> (J1772)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:type1_cable=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 with cable</b> (J1772)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:type1_cable", "key": "socket:type1_cable",
"description": "Layer 'Charging stations' shows socket:type1_cable~^..*$&socket:type1_cable!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 with cable</b> (J1772)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:type1_cable~^..*$&socket:type1_cable!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 with cable</b> (J1772)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:type1", "key": "socket:type1",
"description": "Layer 'Charging stations' shows socket:type1=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 <i>without</i> cable</b> (J1772)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:type1=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 <i>without</i> cable</b> (J1772)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:type1", "key": "socket:type1",
"description": "Layer 'Charging stations' shows socket:type1~^..*$&socket:type1!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 <i>without</i> cable</b> (J1772)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:type1~^..*$&socket:type1!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1_J1772.svg'/> <b>Type 1 <i>without</i> cable</b> (J1772)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:type1_combo", "key": "socket:type1_combo",
"description": "Layer 'Charging stations' shows socket:type1_combo=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1-ccs.svg'/> <b>Type 1 CCS</b> (aka Type 1 Combo)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:type1_combo=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1-ccs.svg'/> <b>Type 1 CCS</b> (aka Type 1 Combo)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:type1_combo", "key": "socket:type1_combo",
"description": "Layer 'Charging stations' shows socket:type1_combo~^..*$&socket:type1_combo!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type1-ccs.svg'/> <b>Type 1 CCS</b> (aka Type 1 Combo)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:type1_combo~^..*$&socket:type1_combo!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type1-ccs.svg'/> <b>Type 1 CCS</b> (aka Type 1 Combo)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:tesla_supercharger", "key": "socket:tesla_supercharger",
"description": "Layer 'Charging stations' shows socket:tesla_supercharger=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> <b>Tesla Supercharger</b>' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:tesla_supercharger=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> <b>Tesla Supercharger</b>' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:tesla_supercharger", "key": "socket:tesla_supercharger",
"description": "Layer 'Charging stations' shows socket:tesla_supercharger~^..*$&socket:tesla_supercharger!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> <b>Tesla Supercharger</b>' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:tesla_supercharger~^..*$&socket:tesla_supercharger!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> <b>Tesla Supercharger</b>' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:type2", "key": "socket:type2",
"description": "Layer 'Charging stations' shows socket:type2=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type2_socket.svg'/> <b>Type 2</b> (mennekes)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:type2=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type2_socket.svg'/> <b>Type 2</b> (mennekes)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:type2", "key": "socket:type2",
"description": "Layer 'Charging stations' shows socket:type2~^..*$&socket:type2!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type2_socket.svg'/> <b>Type 2</b> (mennekes)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:type2~^..*$&socket:type2!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type2_socket.svg'/> <b>Type 2</b> (mennekes)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:type2_combo", "key": "socket:type2_combo",
"description": "Layer 'Charging stations' shows socket:type2_combo=1 with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type2_CCS.svg'/> <b>Type 2 CCS</b> (mennekes)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows socket:type2_combo=1 with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type2_CCS.svg'/> <b>Type 2 CCS</b> (mennekes)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1" "value": "1"
}, },
{ {
"key": "socket:type2_combo", "key": "socket:type2_combo",
"description": "Layer 'Charging stations' shows socket:type2_combo~^..*$&socket:type2_combo!~^1$ with a fixed text, namely '<img style='width:3rem; margin-left: 1rem; margin-right: 1rem' src='./assets/layers/charging_station/Type2_CCS.svg'/> <b>Type 2 CCS</b> (mennekes)' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows socket:type2_combo~^..*$&socket:type2_combo!~^1$ with a fixed text, namely '<img class='w-12 mx-4' src='./assets/layers/charging_station/Type2_CCS.svg'/> <b>Type 2 CCS</b> (mennekes)' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{ {
"key": "socket:schuko", "key": "socket:schuko",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:schuko' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:schuko' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:schuko:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:schuko:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:schuko:voltage",
"description": "Layer 'Charging stations' shows socket:socket:schuko:voltage=230 V with a fixed text, namely '<b><b>Schuko wall plug</b> without ground pin (CEE7/4 type F)</b> <img style='width:1rem;' src='./assets/layers/charging_station/CEE7_4F.svg'/> outputs 230 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "230 V"
},
{
"key": "socket:schuko:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:schuko:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:schuko:current",
"description": "Layer 'Charging stations' shows socket:socket:schuko:current=16 A with a fixed text, namely '<b><b>Schuko wall plug</b> without ground pin (CEE7/4 type F)</b> <img style='width:1rem;' src='./assets/layers/charging_station/CEE7_4F.svg'/> outputs at most 16 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "16 A"
},
{
"key": "socket:schuko:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:schuko:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:schuko:output",
"description": "Layer 'Charging stations' shows socket:socket:schuko:output=3.6 kw with a fixed text, namely '<b><b>Schuko wall plug</b> without ground pin (CEE7/4 type F)</b> <img style='width:1rem;' src='./assets/layers/charging_station/CEE7_4F.svg'/> outputs at most 3.6 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "3.6 kw"
},
{ {
"key": "socket:typee", "key": "socket:typee",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:typee' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:typee' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:typee:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:typee:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:typee:voltage",
"description": "Layer 'Charging stations' shows socket:socket:typee:voltage=230 V with a fixed text, namely '<b><b>European wall plug</b> with ground pin (CEE7/4 type E)</b> <img style='width:1rem;' src='./assets/layers/charging_station/TypeE.svg'/> outputs 230 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "230 V"
},
{
"key": "socket:typee:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:typee:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:typee:current",
"description": "Layer 'Charging stations' shows socket:socket:typee:current=16 A with a fixed text, namely '<b><b>European wall plug</b> with ground pin (CEE7/4 type E)</b> <img style='width:1rem;' src='./assets/layers/charging_station/TypeE.svg'/> outputs at most 16 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "16 A"
},
{
"key": "socket:typee:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:typee:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:typee:output",
"description": "Layer 'Charging stations' shows socket:socket:typee:output=3 kw with a fixed text, namely '<b><b>European wall plug</b> with ground pin (CEE7/4 type E)</b> <img style='width:1rem;' src='./assets/layers/charging_station/TypeE.svg'/> outputs at most 3 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "3 kw"
},
{
"key": "socket:socket:typee:output",
"description": "Layer 'Charging stations' shows socket:socket:typee:output=22 kw with a fixed text, namely '<b><b>European wall plug</b> with ground pin (CEE7/4 type E)</b> <img style='width:1rem;' src='./assets/layers/charging_station/TypeE.svg'/> outputs at most 22 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "22 kw"
},
{ {
"key": "socket:chademo", "key": "socket:chademo",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:chademo' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:chademo' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:chademo:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:chademo:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:chademo:voltage",
"description": "Layer 'Charging stations' shows socket:socket:chademo:voltage=500 V with a fixed text, namely '<b><b>Chademo</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Chademo_type4.svg'/> outputs 500 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "500 V"
},
{
"key": "socket:chademo:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:chademo:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:chademo:current",
"description": "Layer 'Charging stations' shows socket:socket:chademo:current=120 A with a fixed text, namely '<b><b>Chademo</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Chademo_type4.svg'/> outputs at most 120 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "120 A"
},
{
"key": "socket:chademo:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:chademo:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:chademo:output",
"description": "Layer 'Charging stations' shows socket:socket:chademo:output=50 kw with a fixed text, namely '<b><b>Chademo</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Chademo_type4.svg'/> outputs at most 50 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "50 kw"
},
{ {
"key": "socket:type1_cable", "key": "socket:type1_cable",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_cable' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_cable' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:type1_cable:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_cable:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_cable:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1_cable:voltage=200 V with a fixed text, namely '<b><b>Type 1 with cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs 200 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "200 V"
},
{
"key": "socket:socket:type1_cable:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1_cable:voltage=240 V with a fixed text, namely '<b><b>Type 1 with cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs 240 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "240 V"
},
{
"key": "socket:type1_cable:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_cable:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_cable:current",
"description": "Layer 'Charging stations' shows socket:socket:type1_cable:current=32 A with a fixed text, namely '<b><b>Type 1 with cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 32 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "32 A"
},
{
"key": "socket:type1_cable:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_cable:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_cable:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_cable:output=3.7 kw with a fixed text, namely '<b><b>Type 1 with cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 3.7 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "3.7 kw"
},
{
"key": "socket:socket:type1_cable:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_cable:output=7 kw with a fixed text, namely '<b><b>Type 1 with cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 7 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "7 kw"
},
{ {
"key": "socket:type1", "key": "socket:type1",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:type1:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1:voltage=200 V with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs 200 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "200 V"
},
{
"key": "socket:socket:type1:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1:voltage=240 V with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs 240 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "240 V"
},
{
"key": "socket:type1:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1:current",
"description": "Layer 'Charging stations' shows socket:socket:type1:current=32 A with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 32 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "32 A"
},
{
"key": "socket:type1:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1:output",
"description": "Layer 'Charging stations' shows socket:socket:type1:output=3.7 kw with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 3.7 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "3.7 kw"
},
{
"key": "socket:socket:type1:output",
"description": "Layer 'Charging stations' shows socket:socket:type1:output=6.6 kw with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 6.6 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "6.6 kw"
},
{
"key": "socket:socket:type1:output",
"description": "Layer 'Charging stations' shows socket:socket:type1:output=7 kw with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 7 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "7 kw"
},
{
"key": "socket:socket:type1:output",
"description": "Layer 'Charging stations' shows socket:socket:type1:output=7.2 kw with a fixed text, namely '<b><b>Type 1 <i>without</i> cable</b> (J1772)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1_J1772.svg'/> outputs at most 7.2 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "7.2 kw"
},
{ {
"key": "socket:type1_combo", "key": "socket:type1_combo",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_combo' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_combo' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:type1_combo:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_combo:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_combo:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:voltage=400 V with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs 400 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "400 V"
},
{
"key": "socket:socket:type1_combo:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:voltage=1000 V with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs 1000 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "1000 V"
},
{
"key": "socket:type1_combo:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_combo:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_combo:current",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:current=50 A with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 50 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "50 A"
},
{
"key": "socket:socket:type1_combo:current",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:current=125 A with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 125 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "125 A"
},
{
"key": "socket:type1_combo:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type1_combo:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type1_combo:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:output=50 kw with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 50 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "50 kw"
},
{
"key": "socket:socket:type1_combo:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:output=62.5 kw with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 62.5 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "62.5 kw"
},
{
"key": "socket:socket:type1_combo:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:output=150 kw with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 150 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "150 kw"
},
{
"key": "socket:socket:type1_combo:output",
"description": "Layer 'Charging stations' shows socket:socket:type1_combo:output=350 kw with a fixed text, namely '<b><b>Type 1 CCS</b> (aka Type 1 Combo)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type1-ccs.svg'/> outputs at most 350 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "350 kw"
},
{ {
"key": "socket:tesla_supercharger", "key": "socket:tesla_supercharger",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:tesla_supercharger' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:tesla_supercharger' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:tesla_supercharger:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:tesla_supercharger:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:tesla_supercharger:voltage",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:voltage=480 V with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs 480 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "480 V"
},
{
"key": "socket:tesla_supercharger:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:tesla_supercharger:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:tesla_supercharger:current",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:current=125 A with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs at most 125 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "125 A"
},
{
"key": "socket:socket:tesla_supercharger:current",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:current=350 A with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs at most 350 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "350 A"
},
{
"key": "socket:tesla_supercharger:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:tesla_supercharger:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:tesla_supercharger:output",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:output=120 kw with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs at most 120 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "120 kw"
},
{
"key": "socket:socket:tesla_supercharger:output",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:output=150 kw with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs at most 150 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "150 kw"
},
{
"key": "socket:socket:tesla_supercharger:output",
"description": "Layer 'Charging stations' shows socket:socket:tesla_supercharger:output=250 kw with a fixed text, namely '<b><b>Tesla Supercharger</b></b> <img style='width:1rem;' src='./assets/layers/charging_station/Tesla-hpwc-model-s.svg'/> outputs at most 250 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "250 kw"
},
{ {
"key": "socket:type2", "key": "socket:type2",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:type2:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type2:voltage=230 V with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs 230 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "230 V"
},
{
"key": "socket:socket:type2:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type2:voltage=400 V with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs 400 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "400 V"
},
{
"key": "socket:type2:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2:current",
"description": "Layer 'Charging stations' shows socket:socket:type2:current=16 A with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs at most 16 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "16 A"
},
{
"key": "socket:socket:type2:current",
"description": "Layer 'Charging stations' shows socket:socket:type2:current=32 A with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs at most 32 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "32 A"
},
{
"key": "socket:type2:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2:output",
"description": "Layer 'Charging stations' shows socket:socket:type2:output=11 kw with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs at most 11 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "11 kw"
},
{
"key": "socket:socket:type2:output",
"description": "Layer 'Charging stations' shows socket:socket:type2:output=22 kw with a fixed text, namely '<b><b>Type 2</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_socket.svg'/> outputs at most 22 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "22 kw"
},
{ {
"key": "socket:type2_combo", "key": "socket:type2_combo",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2_combo' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2_combo' (in the MapComplete.osm.be theme 'Charging stations')"
}, },
{
"key": "socket:type2_combo:voltage",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2_combo:voltage' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2_combo:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type2_combo:voltage=500 V with a fixed text, namely '<b><b>Type 2 CCS</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_CCS.svg'/> outputs 500 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "500 V"
},
{
"key": "socket:socket:type2_combo:voltage",
"description": "Layer 'Charging stations' shows socket:socket:type2_combo:voltage=920 V with a fixed text, namely '<b><b>Type 2 CCS</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_CCS.svg'/> outputs 920 volt' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "920 V"
},
{
"key": "socket:type2_combo:current",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2_combo:current' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2_combo:current",
"description": "Layer 'Charging stations' shows socket:socket:type2_combo:current=125 A with a fixed text, namely '<b><b>Type 2 CCS</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_CCS.svg'/> outputs at most 125 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "125 A"
},
{
"key": "socket:socket:type2_combo:current",
"description": "Layer 'Charging stations' shows socket:socket:type2_combo:current=350 A with a fixed text, namely '<b><b>Type 2 CCS</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_CCS.svg'/> outputs at most 350 A' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "350 A"
},
{
"key": "socket:type2_combo:output",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'socket:type2_combo:output' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "socket:socket:type2_combo:output",
"description": "Layer 'Charging stations' shows socket:socket:type2_combo:output=50 kw with a fixed text, namely '<b><b>Type 2 CCS</b> (mennekes)</b> <img style='width:1rem;' src='./assets/layers/charging_station/Type2_CCS.svg'/> outputs at most 50 kw' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "50 kw"
},
{ {
"key": "authentication:membership_card", "key": "authentication:membership_card",
"description": "Layer 'Charging stations' shows authentication:membership_card=yes with a fixed text, namely 'Authentication by a membership card' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows authentication:membership_card=yes with a fixed text, namely 'Authentication by a membership card' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
@ -274,6 +617,44 @@
"description": "Layer 'Charging stations' shows opening_hours=24/7 with a fixed text, namely '24/7 opened (including holidays)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')", "description": "Layer 'Charging stations' shows opening_hours=24/7 with a fixed text, namely '24/7 opened (including holidays)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "24/7" "value": "24/7"
}, },
{
"key": "charge",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'charge' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "fee",
"description": "Layer 'Charging stations' shows fee=no&charge= with a fixed text, namely 'Free to use' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "no"
},
{
"key": "charge",
"description": "Layer 'Charging stations' shows fee=no&charge= with a fixed text, namely 'Free to use' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations') Picking this answer will delete the key charge.",
"value": ""
},
{
"key": "payment:cash",
"description": "Layer 'Charging stations' shows payment:cash=yes with a fixed text, namely 'Cash is accepted here' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "yes"
},
{
"key": "payment:cards",
"description": "Layer 'Charging stations' shows payment:cards=yes with a fixed text, namely 'Payment cards are accepted here' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "yes"
},
{
"key": "payment:app",
"description": "Layer 'Charging stations' shows payment:app=yes with a fixed text, namely 'Payment is done using a dedicated app' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "yes"
},
{
"key": "maxstay",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'maxstay' (in the MapComplete.osm.be theme 'Charging stations')"
},
{
"key": "maxstay",
"description": "Layer 'Charging stations' shows maxstay=unlimited with a fixed text, namely 'No timelimit on leaving your vehicle here' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Charging stations')",
"value": "unlimited"
},
{ {
"key": "network", "key": "network",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'network' (in the MapComplete.osm.be theme 'Charging stations')" "description": "Layer 'Charging stations' shows and asks freeform values for key 'network' (in the MapComplete.osm.be theme 'Charging stations')"

View file

@ -0,0 +1,124 @@
{
"data_format": 1,
"project": {
"name": "MapComplete Observation towers",
"description": "Publicly accessible towers to enjoy the view",
"project_url": "https://mapcomplete.osm.be/observation_towers",
"doc_url": "https://github.com/pietervdvn/MapComplete/tree/master/assets/themes/",
"icon_url": "https://mapcomplete.osm.be/assets/layers/observation_tower/Tower_observation.svg",
"contact_name": "Pieter Vander Vennet, ",
"contact_email": "pietervdvn@posteo.net"
},
"tags": [
{
"key": "tower:type",
"description": "The MapComplete theme Observation towers has a layer Observation towers showing features with this tag",
"value": "observation"
},
{
"key": "image",
"description": "The layer 'Observation towers allows to upload images and adds them under the 'image'-tag (and image:0, image:1, ... for multiple images). Furhtermore, this layer shows images based on the keys image, wikidata, wikipedia, wikimedia_commons and mapillary"
},
{
"key": "mapillary",
"description": "The layer 'Observation towers allows to upload images and adds them under the 'image'-tag (and image:0, image:1, ... for multiple images). Furhtermore, this layer shows images based on the keys image, wikidata, wikipedia, wikimedia_commons and mapillary"
},
{
"key": "wikidata",
"description": "The layer 'Observation towers allows to upload images and adds them under the 'image'-tag (and image:0, image:1, ... for multiple images). Furhtermore, this layer shows images based on the keys image, wikidata, wikipedia, wikimedia_commons and mapillary"
},
{
"key": "wikipedia",
"description": "The layer 'Observation towers allows to upload images and adds them under the 'image'-tag (and image:0, image:1, ... for multiple images). Furhtermore, this layer shows images based on the keys image, wikidata, wikipedia, wikimedia_commons and mapillary"
},
{
"key": "name",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'name' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "noname",
"description": "Layer 'Observation towers' shows noname=yes with a fixed text, namely 'This tower doesn't have a specific name' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
},
{
"key": "height",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'height' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "operator",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'operator' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "website",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'website' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "charge",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'charge' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "fee",
"description": "Layer 'Observation towers' shows fee=no&charge= with a fixed text, namely 'Free to visit' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "no"
},
{
"key": "charge",
"description": "Layer 'Observation towers' shows fee=no&charge= with a fixed text, namely 'Free to visit' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers') Picking this answer will delete the key charge.",
"value": ""
},
{
"key": "payment:cash",
"description": "Layer 'Observation towers' shows payment:cash=yes with a fixed text, namely 'Cash is accepted here' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
},
{
"key": "payment:cards",
"description": "Layer 'Observation towers' shows payment:cards=yes with a fixed text, namely 'Payment cards are accepted here' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
},
{
"key": "payment:app",
"description": "Layer 'Observation towers' shows payment:app=yes with a fixed text, namely 'Payment is done using a dedicated app' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
},
{
"key": "wheelchair",
"description": "Layer 'Observation towers' shows wheelchair=designated with a fixed text, namely 'This place is specially adapated for wheelchair users' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "designated"
},
{
"key": "wheelchair",
"description": "Layer 'Observation towers' shows wheelchair=yes with a fixed text, namely 'This place is easily reachable with a wheelchair' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
},
{
"key": "wheelchair",
"description": "Layer 'Observation towers' shows wheelchair=limited with a fixed text, namely 'It is possible to reach this place in a wheelchair, but it is not easy' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "limited"
},
{
"key": "wheelchair",
"description": "Layer 'Observation towers' shows wheelchair=no with a fixed text, namely 'This place is not reachable with a wheelchair' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "no"
},
{
"key": "service:bicycle:cleaning:charge",
"description": "Layer 'Observation towers' shows and asks freeform values for key 'service:bicycle:cleaning:charge' (in the MapComplete.osm.be theme 'Observation towers')"
},
{
"key": "service:bicycle:cleaning:fee",
"description": "Layer 'Observation towers' shows service:bicycle:cleaning:fee=no&service:bicycle:cleaning:charge= with a fixed text, namely 'The cleaning service is free to use' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "no&service:bicycle:cleaning:charge="
},
{
"key": "service:bicycle:cleaning:fee",
"description": "Layer 'Observation towers' shows service:bicycle:cleaning:fee=no& with a fixed text, namely 'Free to use' (in the MapComplete.osm.be theme 'Observation towers')",
"value": "no&"
},
{
"key": "service:bicycle:cleaning:fee",
"description": "Layer 'Observation towers' shows service:bicycle:cleaning:fee=yes with a fixed text, namely 'The cleaning service has a fee' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Observation towers')",
"value": "yes"
}
]
}

View file

@ -55,6 +55,11 @@
"description": "Layer 'Toilets' shows access=key with a fixed text, namely 'Accessible, but one has to ask a key to enter' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Open Toilet Map')", "description": "Layer 'Toilets' shows access=key with a fixed text, namely 'Accessible, but one has to ask a key to enter' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Open Toilet Map')",
"value": "key" "value": "key"
}, },
{
"key": "access",
"description": "Layer 'Toilets' shows access=public with a fixed text, namely 'Public access' (in the MapComplete.osm.be theme 'Open Toilet Map')",
"value": "public"
},
{ {
"key": "fee", "key": "fee",
"description": "Layer 'Toilets' shows fee=yes with a fixed text, namely 'These are paid toilets' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Open Toilet Map')", "description": "Layer 'Toilets' shows fee=yes with a fixed text, namely 'These are paid toilets' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Open Toilet Map')",

View file

@ -0,0 +1,39 @@
{
"data_format": 1,
"project": {
"name": "MapComplete UK Addresses",
"description": "Help to build an open dataset of UK addresses",
"project_url": "https://mapcomplete.osm.be/uk_addresses",
"doc_url": "https://github.com/pietervdvn/MapComplete/tree/master/assets/themes/",
"icon_url": "https://mapcomplete.osm.be/assets/themes/uk_addresses/housenumber_unknown.svg",
"contact_name": "Pieter Vander Vennet, Pieter Vander Vennet, Rob Nickerson, Russ Garrett",
"contact_email": "pietervdvn@posteo.net"
},
"tags": [
{
"key": "inspireid",
"description": "The MapComplete theme UK Addresses has a layer Addresses to check showing features with this tag"
},
{
"key": "addr:housenumber",
"description": "The MapComplete theme UK Addresses has a layer Known addresses in OSM showing features with this tag"
},
{
"key": "addr:street",
"description": "The MapComplete theme UK Addresses has a layer Known addresses in OSM showing features with this tag"
},
{
"key": "addr:housenumber",
"description": "Layer 'Known addresses in OSM' shows and asks freeform values for key 'addr:housenumber' (in the MapComplete.osm.be theme 'UK Addresses')"
},
{
"key": "nohousenumber",
"description": "Layer 'Known addresses in OSM' shows nohousenumber=yes with a fixed text, namely 'This building has no house number' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'UK Addresses')",
"value": "yes"
},
{
"key": "addr:street",
"description": "Layer 'Known addresses in OSM' shows and asks freeform values for key 'addr:street' (in the MapComplete.osm.be theme 'UK Addresses')"
}
]
}

View file

@ -14,6 +14,36 @@
"key": "amenity", "key": "amenity",
"description": "The MapComplete theme Waste Basket has a layer Waste Basket showing features with this tag", "description": "The MapComplete theme Waste Basket has a layer Waste Basket showing features with this tag",
"value": "waste_basket" "value": "waste_basket"
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste= with a fixed text, namely 'A waste basket for general waste' (in the MapComplete.osm.be theme 'Waste Basket') Picking this answer will delete the key waste.",
"value": ""
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste=trash with a fixed text, namely 'A waste basket for general waste' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Waste Basket')",
"value": "trash"
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste=dog_excrement with a fixed text, namely 'A waste basket for dog excrements' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Waste Basket')",
"value": "dog_excrement"
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste=cigarettes with a fixed text, namely 'A waste basket for cigarettes' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Waste Basket')",
"value": "cigarettes"
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste=drugs with a fixed text, namely 'A waste basket for drugs' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Waste Basket')",
"value": "drugs"
},
{
"key": "waste",
"description": "Layer 'Waste Basket' shows waste=sharps with a fixed text, namely 'A waste basket for needles and other sharp objects' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Waste Basket')",
"value": "sharps"
} }
] ]
} }

View file

@ -31,6 +31,8 @@ Strict not equals
To check if a key does _not_ equal a certain value, use `key!=value`. This is converted behind the scenes To check if a key does _not_ equal a certain value, use `key!=value`. This is converted behind the scenes
to `key!~^value$` to `key!~^value$`
If `key` is not present or empty, this will match too.
### If key is present ### If key is present
This implies that, to check if a key is present, `key!=` can be used. This will only match if the key is present and not This implies that, to check if a key is present, `key!=` can be used. This will only match if the key is present and not

View file

@ -75,9 +75,7 @@ class StatsDownloader {
while (url) { while (url) {
ScriptUtils.erasableLog(`Downloading stats for ${year}-${month}, page ${page} ${url}`) ScriptUtils.erasableLog(`Downloading stats for ${year}-${month}, page ${page} ${url}`)
const result = await ScriptUtils.DownloadJSON(url, { const result = await ScriptUtils.DownloadJSON(url, headers)
headers: headers
})
page++; page++;
allFeatures.push(...result.features) allFeatures.push(...result.features)
if (result.features === undefined) { if (result.features === undefined) {

View file

@ -1,3 +1,4 @@
URL-parameters and URL-hash URL-parameters and URL-hash
============================ ============================
@ -8,8 +9,8 @@ What is a URL parameter?
URL-parameters are extra parts of the URL used to set the state. URL-parameters are extra parts of the URL used to set the state.
For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`, the For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`,
URL-parameters are stated in the part between the `?` and the `#`. There are multiple, all separated by `&`, namely: the URL-parameters are stated in the part between the `?` and the `#`. There are multiple, all separated by `&`, namely:
- The url-parameter `lat` is `51.0` in this instance - The url-parameter `lat` is `51.0` in this instance
- The url-parameter `lon` is `4.3` in this instance - The url-parameter `lon` is `4.3` in this instance
@ -34,8 +35,7 @@ Whether or not the filter view is shown The default value is _false_
tab tab
----- -----
The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
z z
@ -59,8 +59,7 @@ The initial/current longitude of the app The default value is _0_
fs-userbadge fs-userbadge
-------------- --------------
Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
fs-search fs-search
@ -84,8 +83,7 @@ Disables/Enables the filter The default value is _true_
fs-add-new fs-add-new
------------ ------------
Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
value is _true_
fs-welcome-message fs-welcome-message
@ -139,8 +137,7 @@ Enable the PDF download button The default value is _false_
test test
------ ------
If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_
console instead of actually uploaded to osm.org The default value is _false_
debug debug
@ -158,15 +155,13 @@ If true, 'dryrun' mode is activated and a fake user account is loaded The defaul
backend backend
--------- ---------
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
value is _osm_
overpassUrl overpassUrl
------------- -------------
Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter The default value Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter The default value is _https://overpass-api.de/api/interpreter_
is _https://overpass-api.de/api/interpreter_
overpassTimeout overpassTimeout

View file

@ -4,8 +4,8 @@
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/personal personal] |name= [https://mapcomplete.osm.be/personal personal]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:es|en}}, {{#language:ca|en}}, {{#language:gl|en}}, {{#language:de|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:es|en}}, {{#language:ca|en}}, {{#language:gl|en}}, {{#language:fr|en}}, {{#language:de|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: Create a personal theme based on all the available layers of all themes |descr= A MapComplete theme: Create a personal theme based on all the available layers of all themes. In order to show some data, open [[#filter]]
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, personal |genre= POI, editor, personal
@ -13,25 +13,29 @@
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/cyclofix cyclofix] |name= [https://mapcomplete.osm.be/cyclofix cyclofix]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:gl|en}}, {{#language:de|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:gl|en}}, {{#language:de|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:it|en}}
|descr= A MapComplete theme: The goal of this map is to present cyclists with an easy-to-use solution to find the appropriate infrastructure for their needs.<br><br>You can track your precise location (mobile only) and select layers that are relevant for you in the bottom left corner. You can also use this tool to add or edit pins (points of interest) to the map and provide more data by answering the questions.<br><br>All changes you make will automatically be saved in the global database of OpenStreetMap and can be freely re-used by others.<br><br>For more information about the cyclofix project, go to [[https://cyclofix.osm.be/]]. |descr= A MapComplete theme: The goal of this map is to present cyclists with an easy-to-use solution to find the appropriate infrastructure for their needs.<br><br>You can track your precise location (mobile only) and select layers that are relevant for you in the bottom left corner. You can also use this tool to add or edit pins (points of interest) to the map and provide more data by answering the questions.<br><br>All changes you make will automatically be saved in the global database of OpenStreetMap and can be freely re-used by others.<br><br>For more information about the cyclofix project, go to [[https://cyclofix.osm.be/]].
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, cyclofix |genre= POI, editor, cyclofix
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/aed aed] |name= [https://mapcomplete.osm.be/hailhydrant hailhydrant]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:ca|en}}, {{#language:es|en}}, {{#language:fr|en}}, {{#language:nl|en}}, {{#language:de|en}} |lang= {{#language:en|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}, {{#language:fr|en}}, {{#language:nb_NO|en}}, {{#language:it|en}}, {{#language:id|en}}
|descr= A MapComplete theme: On this map, one can find and mark nearby defibrillators |descr= A MapComplete theme: On this map you can find and update hydrants, fire stations, ambulance stations, and extinguishers in your favorite neighborhoods.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
You can track your precise location (mobile only) and select layers that are relevant for you in the bottom left corner. You can also use this tool to add or edit pins (points of interest) to the map and provide additional details by answering available questions.
All changes you make will automatically be saved in the global database of OpenStreetMap and can be freely re-used by others.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Erwin Olario;]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, aed |genre= POI, editor, hailhydrant
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/bookcases bookcases] |name= [https://mapcomplete.osm.be/bookcases bookcases]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:de|en}}, {{#language:fr|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:de|en}}, {{#language:fr|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:it|en}}, {{#language:pt_BR|en}}
|descr= A MapComplete theme: A public bookcase is a small streetside cabinet, box, old phone boot or some other objects where books are stored. Everyone can place or take a book. This map aims to collect all these bookcases. You can discover new bookcases nearby and, with a free OpenStreetMap account, quickly add your favourite bookcases. |descr= A MapComplete theme: A public bookcase is a small streetside cabinet, box, old phone boot or some other objects where books are stored. Everyone can place or take a book. This map aims to collect all these bookcases. You can discover new bookcases nearby and, with a free OpenStreetMap account, quickly add your favourite bookcases.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
@ -40,47 +44,173 @@
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/toilets toilets] |name= [https://mapcomplete.osm.be/toilets toilets]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:de|en}}, {{#language:fr|en}} |lang= {{#language:en|en}}, {{#language:de|en}}, {{#language:fr|en}}, {{#language:nl|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:pl|en}}
|descr= A MapComplete theme: A map of public toilets |descr= A MapComplete theme: A map of public toilets
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, toilets |genre= POI, editor, toilets
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/artworks artworks] |name= [https://mapcomplete.osm.be/aed aed]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:de|en}} |lang= {{#language:en|en}}, {{#language:ca|en}}, {{#language:es|en}}, {{#language:fr|en}}, {{#language:nl|en}}, {{#language:de|en}}, {{#language:hu|en}}, {{#language:id|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:nb_NO|en}}, {{#language:sv|en}}, {{#language:pl|en}}, {{#language:pt_BR|en}}
|descr= A MapComplete theme: Welcome to Open Artwork Map, a map of statues, busts, grafittis, ... all over the world |descr= A MapComplete theme: On this map, one can find and mark nearby defibrillators
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, artworks |genre= POI, editor, aed
}}
{{service_item
|name= [https://mapcomplete.osm.be/artwork artwork]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:de|en}}, {{#language:hu|en}}, {{#language:id|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:sv|en}}, {{#language:pl|en}}, {{#language:es|en}}, {{#language:nb_NO|en}}
|descr= A MapComplete theme: Welcome to Open Artwork Map, a map of statues, busts, grafittis and other artwork all over the world
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, artwork
}}
{{service_item
|name= [https://mapcomplete.osm.be/benches benches]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:de|en}}, {{#language:fr|en}}, {{#language:nl|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:nb_NO|en}}, {{#language:pt_BR|en}}
|descr= A MapComplete theme: This map shows all benches that are recorded in OpenStreetMap: Individual benches, and benches belonging to public transport stops or shelters. With an OpenStreetMap account, you can map new benches or edit details of existing benches.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Florian Edelmann;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, benches
}}
{{service_item
|name= [https://mapcomplete.osm.be/bicyclelib bicyclelib]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:fr|en}}, {{#language:zh_Hant|en}}, {{#language:nb_NO|en}}, {{#language:de|en}}, {{#language:pt_BR|en}}
|descr= A MapComplete theme: A bicycle library is a place where bicycles can be lent, often for a small yearly fee. A notable use case are bicycle libraries for kids, which allows them to change for a bigger bike when they've outgrown their current bike
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, bicyclelib
}}
{{service_item
|name= [https://mapcomplete.osm.be/binoculars binoculars]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: A map with binoculars fixed in place with a pole. It can typically be found on touristic locations, viewpoints, on top of panoramic towers or occasionally on a nature reserve.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, binoculars
}}
{{service_item
|name= [https://mapcomplete.osm.be/cafes_and_pubs cafes_and_pubs]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}}, {{#language:en|en}}
|descr= A MapComplete theme: Cafés, kroegen en drinkgelegenheden
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, cafes_and_pubs
}}
{{service_item
|name= [https://mapcomplete.osm.be/campersite campersite]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:fr|en}}, {{#language:zh_Hant|en}}, {{#language:pt_BR|en}}, {{#language:id|en}}, {{#language:nb_NO|en}}
|descr= A MapComplete theme: This site collects all official camper stopover places and places where you can dump grey and black water. You can add details about the services provided and the cost. Add pictures and reviews. This is a website and a webapp. The data is stored in OpenStreetMap, so it will be free forever and can be re-used by any app.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by joost schouppe;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, campersite
}}
{{service_item
|name= [https://mapcomplete.osm.be/charging_stations charging_stations]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:id|en}}, {{#language:it|en}}, {{#language:ja|en}}, {{#language:ru|en}}, {{#language:zh_Hant|en}}, {{#language:it|en}}, {{#language:nl|en}}, {{#language:nb_NO|en}}
|descr= A MapComplete theme: On this open map, one can find and mark information about charging stations
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, charging_stations
}}
{{service_item
|name= [https://mapcomplete.osm.be/climbing climbing]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:de|en}}, {{#language:en|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:nb_NO|en}}, {{#language:it|en}}, {{#language:ca|en}}, {{#language:fr|en}}, {{#language:id|en}}
|descr= A MapComplete theme: On this map you will find various climbing opportunities such as climbing gyms, bouldering halls and rocks in nature.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Christian Neumann <christian@utopicode.de>;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, climbing
}}
{{service_item
|name= [https://mapcomplete.osm.be/cycle_infra cycle_infra]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: A map where you can view and edit things related to the bicycle infrastructure. Made during #osoc21.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, cycle_infra
}}
{{service_item
|name= [https://mapcomplete.osm.be/cyclestreets cyclestreets]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:en|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:nb_NO|en}}, {{#language:it|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: A cyclestreet is is a street where <b>motorized traffic is not allowed to overtake cyclists</b>. They are signposted by a special traffic sign. Cyclestreets can be found in the Netherlands and Belgium, but also in Germany and France.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, cyclestreets
}}
{{service_item
|name= [https://mapcomplete.osm.be/drinking_water drinking_water]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:it|en}}
|descr= A MapComplete theme: On this map, publicly accessible drinking water spots are shown and can be easily added
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, drinking_water
}}
{{service_item
|name= [https://mapcomplete.osm.be/facadegardens facadegardens]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:en|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:it|en}}, {{#language:fr|en}}, {{#language:nb_NO|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: [[https://nl.wikipedia.org/wiki/Geveltuin' target=_blank>Facade gardens</a>, green facades and trees in the city not only bring peace and quiet, but also a more beautiful city, greater biodiversity, a cooling effect and better air quality. <br/> Klimaan VZW and Mechelen Klimaatneutraal want to map existing and new facade gardens as an example for people who want to build their own garden or for city walkers who love nature.<br/>More info about the project at <a href='https://klimaan.be/' target=_blank>klimaan.be</a>.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by joost schouppe; stla;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, facadegardens
}}
{{service_item
|name= [https://mapcomplete.osm.be/food food]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}}, {{#language:en|en}}
|descr= A MapComplete theme: Restaurants en fast food
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, food
}}
{{service_item
|name= [https://mapcomplete.osm.be/fritures fritures]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}}, {{#language:en|en}}, {{#language:ja|en}}, {{#language:ca|en}}, {{#language:id|en}}, {{#language:ru|en}}, {{#language:it|en}}, {{#language:nb_NO|en}}
|descr= A MapComplete theme: Op deze kaart vind je je favoriete frituur!
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, fritures
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/ghostbikes ghostbikes] |name= [https://mapcomplete.osm.be/ghostbikes ghostbikes]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:de|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:de|en}}, {{#language:ja|en}}, {{#language:nb_NO|en}}, {{#language:zh_Hant|en}}, {{#language:fr|en}}, {{#language:eo|en}}, {{#language:es|en}}, {{#language:fi|en}}, {{#language:gl|en}}, {{#language:hu|en}}, {{#language:it|en}}, {{#language:pl|en}}, {{#language:pt_BR|en}}, {{#language:ru|en}}, {{#language:sv|en}}
|descr= A MapComplete theme: A <b>ghost bike</b> is a memorial for a cyclist who died in a traffic accident, in the form of a white bicycle placed permanently near the accident location.<br/><br/>On this map, one can see all the ghost bikes which are known by OpenStreetMap. Is a ghost bike missing? Everyone can add or update information here - you only need to have a (free) OpenStreetMap account. |descr= A MapComplete theme: A <b>ghost bike</b> is a memorial for a cyclist who died in a traffic accident, in the form of a white bicycle placed permanently near the accident location.<br/><br/>On this map, one can see all the ghost bikes which are known by OpenStreetMap. Is a ghost bike missing? Everyone can add or update information here - you only need to have a (free) OpenStreetMap account.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, ghostbikes |genre= POI, editor, ghostbikes
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/shops shops] |name= [https://mapcomplete.osm.be/hackerspaces hackerspaces]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:fr|en}} |lang= {{#language:en|en}}
|descr= A MapComplete theme: On this map, one can mark basic information about shops, add opening hours and phone numbers |descr= A MapComplete theme: On this map you can see hackerspaces, add a new hackerspace or update data directly
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, shops |genre= POI, editor, hackerspaces
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/drinking_water drinking_water] |name= [https://mapcomplete.osm.be/maps maps]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: On this map, publicly accessible drinkging water spots are shown and can be easily added |descr= A MapComplete theme: On this map you can find all maps OpenStreetMap knows - typically a big map on an information board showing the area, city or region, e.g. a tourist map on the back of a billboard, a map of a nature reserve, a map of cycling networks in the region, ...) <br/><br/>If a map is missing, you can easily map this map on OpenStreetMap.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, drinking_water |genre= POI, editor, maps
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/nature nature] |name= [https://mapcomplete.osm.be/nature nature]
@ -92,111 +222,93 @@
|genre= POI, editor, nature |genre= POI, editor, nature
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/fietsstraten fietsstraten] |name= [https://mapcomplete.osm.be/observation_towers observation_towers]
|region= Worldwide
|lang= {{#language:nl|en}}
|descr= A MapComplete theme: Een fietsstraat is een straat waar <ul><li><b>automobilisten geen fietsers mogen inhalen</b></li><li>Er een maximumsnelheid van <b>30km/u</b> geldt</li><li>Fietsers gemotoriseerde voortuigen links mogen inhalen</li><li>Fietsers nog steeds voorrang aan rechts moeten verlenen - ook aan auto's en voetgangers op het zebrapad</li></ul><br/><br/>Op deze open kaart kan je alle gekende fietsstraten zien en kan je ontbrekende fietsstraten aanduiden. Om de kaart aan te passen, moet je je aanmelden met OpenStreetMap en helemaal inzoomen tot straatniveau.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, fietsstraten
}}
{{service_item
|name= [https://mapcomplete.osm.be/bicyclelib bicyclelib]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: A bicycle library is a place where bicycles can be lent, often for a small yearly fee. A notable use case are bicycle libraries for kids, which allows them to change for a bigger bike when they've outgrown their current bike |descr= A MapComplete theme: Publicly accessible towers to enjoy the view
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, bicyclelib |genre= POI, editor, observation_towers
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/maps maps] |name= [https://mapcomplete.osm.be/openwindpowermap openwindpowermap]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}} |lang= {{#language:en|en}}, {{#language:fr|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: On this map you can find all maps OpenStreetMap knows - typically a big map on an information board showing the area, city or region, e.g. a tourist map on the back of a billboard, a map of a nature reserve, a map of cycling networks in the region, ...) <br/><br/>If a map is missing, you can easily map this map on OpenStreetMap. |descr= A MapComplete theme: A map for showing and editing wind turbines.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Seppe Santens;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, openwindpowermap
}}
{{service_item
|name= [https://mapcomplete.osm.be/parkings parkings]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:en|en}}
|descr= A MapComplete theme: This map shows different parking spots
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, maps |genre= POI, editor, parkings
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/fritures fritures] |name= [https://mapcomplete.osm.be/playgrounds playgrounds]
|region= Worldwide |region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}} |lang= {{#language:nl|en}}, {{#language:en|en}}, {{#language:fr|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: Op deze kaart vind je je favoriete frituur! |descr= A MapComplete theme: On this map, you find playgrounds and can add more information
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, fritures |genre= POI, editor, playgrounds
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/benches benches] |name= [https://mapcomplete.osm.be/shops shops]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:de|en}}, {{#language:fr|en}} |lang= {{#language:en|en}}, {{#language:fr|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}, {{#language:nl|en}}, {{#language:ca|en}}, {{#language:id|en}}
|descr= A MapComplete theme: This map shows all benches that are recorded in OpenStreetMap: Individual benches, and benches belonging to public transport stops or shelters. With an OpenStreetMap account, you can map new benches or edit details of existing benches. |descr= A MapComplete theme: On this map, one can mark basic information about shops, add opening hours and phone numbers
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Florian Edelmann;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, benches
}}
{{service_item
|name= [https://mapcomplete.osm.be/charging_stations charging_stations]
|region= Worldwide
|lang= {{#language:en|en}}
|descr= A MapComplete theme: On this open map, one can find and mark information about charging stations
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, charging_stations |genre= POI, editor, shops
}}
{{service_item
|name= [https://mapcomplete.osm.be/sport_pitches sport_pitches]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}}, {{#language:en|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: A sport pitch is an area where sports are played
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, sport_pitches
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/surveillance surveillance] |name= [https://mapcomplete.osm.be/surveillance surveillance]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:fr|en}}, {{#language:pl|en}}
|descr= A MapComplete theme: On this open map, you can find surveillance cameras. |descr= A MapComplete theme: On this open map, you can find surveillance cameras.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, surveillance |genre= POI, editor, surveillance
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/climbing climbing]
|region= Worldwide
|lang= {{#language:de|en}}, {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: On this map you will find various climbing opportunities such as climbing gyms, bouldering halls and rocks in nature.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Christian Neumann <christian@utopicode.de>;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, climbing
}}
{{service_item
|name= [https://mapcomplete.osm.be/playgrounds playgrounds]
|region= Worldwide
|lang= {{#language:nl|en}}
|descr= A MapComplete theme: Op deze kaart vind je speelplekken zoals speeltuinen, speelbossen en sportterreinen
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, playgrounds
}}
{{service_item
|name= [https://mapcomplete.osm.be/trees trees] |name= [https://mapcomplete.osm.be/trees trees]
|region= Worldwide |region= Worldwide
|lang= {{#language:nl|en}}, {{#language:en|en}} |lang= {{#language:nl|en}}, {{#language:en|en}}, {{#language:fr|en}}, {{#language:it|en}}, {{#language:ru|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:pl|en}}
|descr= A MapComplete theme: Map all the trees! |descr= A MapComplete theme: Map all the trees!
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Midgard;]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes, by Midgard;]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, trees |genre= POI, editor, trees
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/campersite campersite] |name= [https://mapcomplete.osm.be/uk_addresses uk_addresses]
|region= Worldwide |region= Worldwide
|lang= {{#language:en|en}} |lang= {{#language:en|en}}
|descr= A MapComplete theme: This site collects all official camper stopover places and places where you can dump grey and black water. You can add details about the services provided and the cost. Add pictures and reviews. This is a website and a webapp. The data is stored in OpenStreetMap, so it will be free forever and can be re-used by any app. |descr= A MapComplete theme: Contribute to OpenStreetMap by filling out address information
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by joost schouppe;]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes, by Pieter Vander Vennet, Rob Nickerson, Russ Garrett;]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, campersite |genre= POI, editor, uk_addresses
}} }}
{{service_item {{service_item
|name= [https://mapcomplete.osm.be/sport_pitches sport_pitches] |name= [https://mapcomplete.osm.be/waste_basket waste_basket]
|region= Worldwide |region= Worldwide
|lang= {{#language:nl|en}} |lang= {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: Een sportveld is een ingerichte plaats met infrastructuur om een sport te beoefenen |descr= A MapComplete theme: On this map, you'll find waste baskets near you. If a waste basket is missing on this map, you can add it yourself
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}} |material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png |image= MapComplete_Screenshot.png
|genre= POI, editor, sport_pitches |genre= POI, editor, waste_basket
}} }}
|} |}

View file

@ -1,7 +1,6 @@
import {FixedUiElement} from "./UI/Base/FixedUiElement"; import {FixedUiElement} from "./UI/Base/FixedUiElement";
import Toggle from "./UI/Input/Toggle"; import Toggle from "./UI/Input/Toggle";
import State from "./State"; import State from "./State";
import LoadFromOverpass from "./Logic/Actors/OverpassFeatureSource";
import {UIEventSource} from "./Logic/UIEventSource"; import {UIEventSource} from "./Logic/UIEventSource";
import {QueryParameters} from "./Logic/Web/QueryParameters"; import {QueryParameters} from "./Logic/Web/QueryParameters";
import StrayClickHandler from "./Logic/Actors/StrayClickHandler"; import StrayClickHandler from "./Logic/Actors/StrayClickHandler";
@ -16,31 +15,32 @@ import Link from "./UI/Base/Link";
import * as personal from "./assets/themes/personal/personal.json"; import * as personal from "./assets/themes/personal/personal.json";
import * as L from "leaflet"; import * as L from "leaflet";
import Img from "./UI/Base/Img"; import Img from "./UI/Base/Img";
import UserDetails from "./Logic/Osm/OsmConnection";
import Attribution from "./UI/BigComponents/Attribution"; import Attribution from "./UI/BigComponents/Attribution";
import LayerResetter from "./Logic/Actors/LayerResetter"; import BackgroundLayerResetter from "./Logic/Actors/BackgroundLayerResetter";
import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs"; import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs";
import ShowDataLayer from "./UI/ShowDataLayer"; import ShowDataLayer from "./UI/ShowDataLayer/ShowDataLayer";
import Hash from "./Logic/Web/Hash"; import Hash from "./Logic/Web/Hash";
import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline"; import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
import ScrollableFullScreen from "./UI/Base/ScrollableFullScreen"; import ScrollableFullScreen from "./UI/Base/ScrollableFullScreen";
import Translations from "./UI/i18n/Translations"; import Translations from "./UI/i18n/Translations";
import MapControlButton from "./UI/MapControlButton"; import MapControlButton from "./UI/MapControlButton";
import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler";
import LZString from "lz-string"; import LZString from "lz-string";
import FeatureSource from "./Logic/FeatureSource/FeatureSource";
import AllKnownLayers from "./Customizations/AllKnownLayers";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
import {TagsFilter} from "./Logic/Tags/TagsFilter";
import LeftControls from "./UI/BigComponents/LeftControls"; import LeftControls from "./UI/BigComponents/LeftControls";
import RightControls from "./UI/BigComponents/RightControls"; import RightControls from "./UI/BigComponents/RightControls";
import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson"; import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson";
import LayoutConfig from "./Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "./Models/ThemeConfig/LayoutConfig";
import LayerConfig from "./Models/ThemeConfig/LayerConfig";
import Minimap from "./UI/Base/Minimap"; import Minimap from "./UI/Base/Minimap";
import Constants from "./Models/Constants"; import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler";
import Combine from "./UI/Base/Combine"; import Combine from "./UI/Base/Combine";
import {SubtleButton} from "./UI/Base/SubtleButton"; import {SubtleButton} from "./UI/Base/SubtleButton";
import ShowTileInfo from "./UI/ShowDataLayer/ShowTileInfo";
import {Tiles} from "./Models/TileRange";
import {TileHierarchyAggregator} from "./UI/ShowDataLayer/TileHierarchyAggregator";
import FilterConfig from "./Models/ThemeConfig/FilterConfig";
import FilteredLayer from "./Models/FilteredLayer";
import {BBox} from "./Logic/BBox";
import {AllKnownLayouts} from "./Customizations/AllKnownLayouts";
export class InitUiElements { export class InitUiElements {
static InitAll( static InitAll(
@ -68,8 +68,22 @@ export class InitUiElements {
layoutFromBase64 layoutFromBase64
); );
if(layoutToUse.id === personal.id){
layoutToUse.layers = AllKnownLayouts.AllPublicLayers()
for (const layer of layoutToUse.layers) {
layer.minzoomVisible = Math.max(layer.minzoomVisible, layer.minzoom)
layer.minzoom = Math.max(16, layer.minzoom)
}
}
State.state = new State(layoutToUse); State.state = new State(layoutToUse);
if(layoutToUse.id === personal.id) {
// Disable overpass all together
State.state.overpassMaxZoom.setData(0)
}
// This 'leaks' the global state via the window object, useful for debugging // This 'leaks' the global state via the window object, useful for debugging
// @ts-ignore // @ts-ignore
window.mapcomplete_state = State.state; window.mapcomplete_state = State.state;
@ -99,46 +113,6 @@ export class InitUiElements {
} }
} }
function updateFavs() {
// This is purely for the personal theme to load the layers there
const favs = State.state.favouriteLayers.data ?? [];
const neededLayers = new Set<LayerConfig>();
console.log("Favourites are: ", favs);
layoutToUse.layers.splice(0, layoutToUse.layers.length);
let somethingChanged = false;
for (const fav of favs) {
if (AllKnownLayers.sharedLayers.has(fav)) {
const layer = AllKnownLayers.sharedLayers.get(fav);
if (!neededLayers.has(layer)) {
neededLayers.add(layer);
somethingChanged = true;
}
}
for (const layouts of State.state.installedThemes.data) {
for (const layer of layouts.layout.layers) {
if (typeof layer === "string") {
continue;
}
if (layer.id === fav) {
if (!neededLayers.has(layer)) {
neededLayers.add(layer);
somethingChanged = true;
}
}
}
}
}
if (somethingChanged) {
console.log("layoutToUse.layers:", layoutToUse.layers);
State.state.layoutToUse.data.layers = Array.from(neededLayers);
State.state.layoutToUse.ping();
State.state.layerUpdater?.ForceRefresh();
}
}
if (layoutToUse.customCss !== undefined) { if (layoutToUse.customCss !== undefined) {
Utils.LoadCustomCss(layoutToUse.customCss); Utils.LoadCustomCss(layoutToUse.customCss);
} }
@ -171,9 +145,19 @@ export class InitUiElements {
).AttachTo("messagesbox"); ).AttachTo("messagesbox");
} }
State.state.osmConnection.userDetails function addHomeMarker() {
.map((userDetails: UserDetails) => userDetails?.home) const userDetails = State.state.osmConnection.userDetails.data;
.addCallbackAndRunD((home) => { if (userDetails === undefined) {
return false;
}
const home = userDetails.home;
if (home === undefined) {
return userDetails.loggedIn; // If logged in, the home is not set and we unregister. If not logged in, we stay registered if a login still comes
}
const leaflet = State.state.leafletMap.data;
if (leaflet === undefined) {
return false;
}
const color = getComputedStyle(document.body).getPropertyValue( const color = getComputedStyle(document.body).getPropertyValue(
"--subtle-detail-color" "--subtle-detail-color"
); );
@ -185,21 +169,19 @@ export class InitUiElements {
iconAnchor: [15, 15], iconAnchor: [15, 15],
}); });
const marker = L.marker([home.lat, home.lon], {icon: icon}); const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(State.state.leafletMap.data); marker.addTo(leaflet);
}); return true;
if (layoutToUse.id === personal.id) {
updateFavs();
} }
State.state.osmConnection.userDetails
.addCallbackAndRunD(_ => addHomeMarker());
State.state.leafletMap.addCallbackAndRunD(_ => addHomeMarker())
InitUiElements.setupAllLayerElements(); InitUiElements.setupAllLayerElements();
if (layoutToUse.id === personal.id) {
State.state.favouriteLayers.addCallback(updateFavs);
State.state.installedThemes.addCallback(updateFavs);
} else {
State.state.locationControl.ping(); State.state.locationControl.ping();
}
new SelectedFeatureHandler(Hash.hash, State.state)
// Reset the loading message once things are loaded // Reset the loading message once things are loaded
new CenterMessageBox().AttachTo("centermessage"); new CenterMessageBox().AttachTo("centermessage");
@ -334,40 +316,39 @@ export class InitUiElements {
(layer) => layer.id (layer) => layer.id
); );
new LayerResetter( new BackgroundLayerResetter(
State.state.backgroundLayer, State.state.backgroundLayer,
State.state.locationControl, State.state.locationControl,
State.state.availableBackgroundLayers, State.state.availableBackgroundLayers,
State.state.layoutToUse.map( State.state.layoutToUse.defaultBackgroundId
(layout: LayoutConfig) => layout.defaultBackgroundId
)
); );
const attr = new Attribution( const attr = new Attribution(
State.state.locationControl, State.state.locationControl,
State.state.osmConnection.userDetails, State.state.osmConnection.userDetails,
State.state.layoutToUse, State.state.layoutToUse,
State.state.leafletMap State.state.currentBounds
); );
new Minimap({ Minimap.createMiniMap({
background: State.state.backgroundLayer, background: State.state.backgroundLayer,
location: State.state.locationControl, location: State.state.locationControl,
leafletMap: State.state.leafletMap, leafletMap: State.state.leafletMap,
bounds: State.state.currentBounds,
attribution: attr, attribution: attr,
lastClickLocation: State.state.LastClickLocation lastClickLocation: State.state.LastClickLocation
}).SetClass("w-full h-full") }).SetClass("w-full h-full")
.AttachTo("leafletDiv") .AttachTo("leafletDiv")
const layout = State.state.layoutToUse.data; const layout = State.state.layoutToUse;
if (layout.lockLocation) { if (layout.lockLocation) {
if (layout.lockLocation === true) { if (layout.lockLocation === true) {
const tile = Utils.embedded_tile( const tile = Tiles.embedded_tile(
layout.startLat, layout.startLat,
layout.startLon, layout.startLon,
layout.startZoom - 1 layout.startZoom - 1
); );
const bounds = Utils.tile_bounds(tile.z, tile.x, tile.y); const bounds = Tiles.tile_bounds(tile.z, tile.x, tile.y);
// We use the bounds to get a sense of distance for this zoom level // We use the bounds to get a sense of distance for this zoom level
const latDiff = bounds[0][0] - bounds[1][0]; const latDiff = bounds[0][0] - bounds[1][0];
const lonDiff = bounds[0][1] - bounds[1][1]; const lonDiff = bounds[0][1] - bounds[1][1];
@ -385,76 +366,150 @@ export class InitUiElements {
} }
} }
private static InitLayers(): FeatureSource { private static InitLayers(): void {
const state = State.state; const state = State.state;
state.filteredLayers = state.layoutToUse.map((layoutToUse) => { const empty = []
const flayers = [];
for (const layer of layoutToUse.layers) { const flayers: FilteredLayer[] = [];
const isDisplayed = QueryParameters.GetQueryParameter(
for (const layer of state.layoutToUse.layers) {
let defaultShown = "true"
if(state.layoutToUse.id === personal.id){
defaultShown = "false"
}
let isDisplayed: UIEventSource<boolean>
if(state.layoutToUse.id === personal.id){
isDisplayed = State.state.osmConnection.GetPreference("personal-theme-layer-" + layer.id + "-enabled")
.map(value => value === "yes", [], enabled => {
return enabled ? "yes" : "";
})
isDisplayed.addCallbackAndRun(d =>console.log("IsDisplayed for layer", layer.id, "is currently", d) )
}else{
isDisplayed = QueryParameters.GetQueryParameter(
"layer-" + layer.id, "layer-" + layer.id,
"true", defaultShown,
"Wether or not layer " + layer.id + " is shown" "Wether or not layer " + layer.id + " is shown"
).map<boolean>( ).map<boolean>(
(str) => str !== "false", (str) => str !== "false",
[], [],
(b) => b.toString() (b) => b.toString()
); );
}
const flayer = { const flayer = {
isDisplayed: isDisplayed, isDisplayed: isDisplayed,
layerDef: layer, layerDef: layer,
appliedFilters: new UIEventSource<TagsFilter>(undefined), appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]),
}; };
if (layer.filters.length > 0) {
const filtersPerName = new Map<string, FilterConfig>()
layer.filters.forEach(f => filtersPerName.set(f.id, f))
const qp = QueryParameters.GetQueryParameter("filter-" + layer.id, "","Filtering state for a layer")
flayer.appliedFilters.map(filters => {
filters = filters ?? []
return filters.map(f => f.filter.id + "." + f.selected).join(",")
}, [], textual => {
if(textual.length === 0){
return empty
}
return textual.split(",").map(part => {
const [filterId, selected] = part.split(".");
return {filter: filtersPerName.get(filterId), selected: Number(selected)}
}).filter(f => f.filter !== undefined && !isNaN(f.selected))
}).syncWith(qp, true)
}
flayers.push(flayer); flayers.push(flayer);
} }
return flayers; state.filteredLayers = new UIEventSource<FilteredLayer[]>(flayers);
});
const updater = new LoadFromOverpass(
state.locationControl,
state.layoutToUse,
state.leafletMap,
state.overpassUrl,
state.overpassTimeout,
Constants.useOsmApiAt
);
State.state.layerUpdater = updater;
const source = new FeaturePipeline(
state.filteredLayers,
State.state.changes,
updater,
state.osmApiFeatureSource,
state.layoutToUse,
state.locationControl,
state.selectedElement
);
State.state.featurePipeline = source;
const clusterCounter = TileHierarchyAggregator.createHierarchy()
new ShowDataLayer({
features: clusterCounter.getCountsForZoom(State.state.locationControl, State.state.layoutToUse.clustering.minNeededElements),
leafletMap: State.state.leafletMap,
layerToShow: ShowTileInfo.styling,
enablePopups: false
})
State.state.featurePipeline = new FeaturePipeline(
source => {
clusterCounter.addTile(source)
const clustering = State.state.layoutToUse.clustering
const doShowFeatures = source.features.map(
f => {
const z = State.state.locationControl.data.zoom
if(!source.layer.isDisplayed.data){
return false;
}
if (z < source.layer.layerDef.minzoom) {
// Layer is always hidden for this zoom level
return false;
}
if (z >= clustering.maxZoom) {
return true
}
if (f.length > clustering.minNeededElements) {
// This tile alone already has too much features
return false
}
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex);
if (tileZ >= z) {
while (tileZ > z) {
tileZ--
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
}
if (clusterCounter.getTile(Tiles.tile_index(tileZ, tileX, tileY))?.totalValue > clustering.minNeededElements) {
return false
}
}
const bounds = State.state.currentBounds.data
if(bounds === undefined){
// Map is not yet displayed
return false;
}
if (!source.bbox.overlapsWith(bounds)) {
// Not within range
return false
}
return true
}, [State.state.currentBounds]
)
new ShowDataLayer( new ShowDataLayer(
source.features, {
State.state.leafletMap, features: source,
State.state.layoutToUse leafletMap: State.state.leafletMap,
layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures
}
); );
}, state
const selectedFeatureHandler = new SelectedFeatureHandler(
Hash.hash,
State.state.selectedElement,
source,
State.state.osmApiFeatureSource
); );
selectedFeatureHandler.zoomToSelectedFeature(
State.state.locationControl
);
return source;
} }
private static setupAllLayerElements() { private static setupAllLayerElements() {
// ------------- Setup the layers ------------------------------- // ------------- Setup the layers -------------------------------
const source = InitUiElements.InitLayers(); InitUiElements.InitLayers();
new LeftControls(source).AttachTo("bottom-left"); new LeftControls(State.state).AttachTo("bottom-left");
new RightControls().AttachTo("bottom-right"); new RightControls().AttachTo("bottom-right");
// ------------------ Setup various other UI elements ------------ // ------------------ Setup various other UI elements ------------

View file

@ -6,13 +6,13 @@ import Loc from "../../Models/Loc";
/** /**
* Sets the current background layer to a layer that is actually available * Sets the current background layer to a layer that is actually available
*/ */
export default class LayerResetter { export default class BackgroundLayerResetter {
constructor(currentBackgroundLayer: UIEventSource<BaseLayer>, constructor(currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>, location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>, availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: UIEventSource<string> = undefined) { defaultLayerId: string = undefined) {
defaultLayerId = defaultLayerId ?? new UIEventSource<string>(AvailableBaseLayers.osmCarto.id); defaultLayerId = defaultLayerId ?? AvailableBaseLayers.osmCarto.id;
// Change the baselayer back to OSM if we go out of the current range of the layer // Change the baselayer back to OSM if we go out of the current range of the layer
availableLayers.addCallbackAndRun(availableLayers => { availableLayers.addCallbackAndRun(availableLayers => {
@ -28,7 +28,7 @@ export default class LayerResetter {
if (availableLayer.min_zoom > location.data.zoom) { if (availableLayer.min_zoom > location.data.zoom) {
break; break;
} }
if (availableLayer.id === defaultLayerId.data) { if (availableLayer.id === defaultLayerId) {
defaultLayer = availableLayer; defaultLayer = availableLayer;
} }
return; // All good - the current layer still works! return; // All good - the current layer still works!

View file

@ -60,12 +60,12 @@ export default class GeoLocationHandler extends VariableUiElement {
* @private * @private
*/ */
private readonly _previousLocationGrant: UIEventSource<string>; private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>; private readonly _layoutToUse: LayoutConfig;
constructor( constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>, currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>, leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig> layoutToUse: LayoutConfig
) { ) {
const hasLocation = currentGPSLocation.map( const hasLocation = currentGPSLocation.map(
(location) => location !== undefined (location) => location !== undefined
@ -207,6 +207,9 @@ export default class GeoLocationHandler extends VariableUiElement {
}); });
const map = self._leafletMap.data; const map = self._leafletMap.data;
if(map === undefined){
return;
}
const newMarker = L.marker(location.latlng, {icon: icon}); const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map); newMarker.addTo(map);
@ -230,7 +233,7 @@ export default class GeoLocationHandler extends VariableUiElement {
navigator?.permissions navigator?.permissions
?.query({name: "geolocation"}) ?.query({name: "geolocation"})
?.then(function (status) { ?.then(function (status) {
console.log("Geolocation is already", status); console.log("Geolocation permission is ", status.state);
if (status.state === "granted") { if (status.state === "granted") {
self.StartGeolocating(forceZoom); self.StartGeolocating(forceZoom);
} }
@ -264,7 +267,7 @@ export default class GeoLocationHandler extends VariableUiElement {
} }
// We check that the GPS location is not out of bounds // We check that the GPS location is not out of bounds
const b = this._layoutToUse.data.lockLocation; const b = this._layoutToUse.lockLocation;
let inRange = true; let inRange = true;
if (b) { if (b) {
if (b !== true) { if (b !== true) {
@ -289,7 +292,6 @@ export default class GeoLocationHandler extends VariableUiElement {
private StartGeolocating(zoomToGPS = true) { private StartGeolocating(zoomToGPS = true) {
const self = this; const self = this;
console.log("Starting geolocation");
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0); this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
if (self._permission.data === "denied") { if (self._permission.data === "denied") {
@ -301,8 +303,6 @@ export default class GeoLocationHandler extends VariableUiElement {
this.MoveToCurrentLoction(16); this.MoveToCurrentLoction(16);
} }
console.log("Searching location using GPS");
if (self._isActive.data) { if (self._isActive.data) {
return; return;
} }

View file

@ -1,173 +0,0 @@
import {ImagesInCategory, Wikidata, Wikimedia} from "../ImageProviders/Wikimedia";
import {UIEventSource} from "../UIEventSource";
/**
* There are multiple way to fetch images for an object
* 1) There is an image tag
* 2) There is an image tag, the image tag contains multiple ';'-separated URLS
* 3) there are multiple image tags, e.g. 'image', 'image:0', 'image:1', and 'image_0', 'image_1' - however, these are pretty rare so we are gonna ignore them
* 4) There is a wikimedia_commons-tag, which either has a 'File': or a 'category:' containing images
* 5) There is a wikidata-tag, and the wikidata item either has an 'image' attribute or has 'a link to a wikimedia commons category'
* 6) There is a wikipedia article, from which we can deduct the wikidata item
*
* For some images, author and license should be shown
*/
/**
* Class which search for all the possible locations for images and which builds a list of UI-elements for it.
* Note that this list is embedded into an UIEVentSource, ready to put it into a carousel.
*
*/
export class ImageSearcher extends UIEventSource<{ key: string, url: string }[]> {
private static _cache = new Map<string, ImageSearcher>();
private readonly _wdItem = new UIEventSource<string>("");
private readonly _commons = new UIEventSource<string>("");
private constructor(tags: UIEventSource<any>, imagePrefix = "image", loadSpecial = true) {
super([])
const self = this;
function AddImages(images: { key: string, url: string }[]) {
const oldUrls = self.data.map(kurl => kurl.url);
let somethingChanged = false;
for (const image of images) {
const url = image.url;
if (url === undefined || url === null || url === "") {
continue;
}
if (oldUrls.indexOf(url) >= 0) {
// Already exists
continue;
}
self.data.push(image);
somethingChanged = true;
}
if (somethingChanged) {
self.ping();
}
}
function addImage(image: string) {
AddImages([{url: image, key: undefined}]);
}
// By wrapping this in a UIEventSource, we prevent multiple queries of loadWikiData
this._wdItem.addCallback(wdItemContents => {
ImageSearcher.loadWikidata(wdItemContents, addImage);
});
this._commons.addCallback(commonsData => {
ImageSearcher.LoadCommons(commonsData, addImage)
});
tags.addCallbackAndRun(tags => {
AddImages(ImageSearcher.LoadImages(tags, imagePrefix));
});
if (loadSpecial) {
tags.addCallbackAndRunD(tags => {
const wdItem = tags.wikidata;
if (wdItem !== undefined) {
self._wdItem.setData(wdItem);
}
const commons = tags.wikimedia_commons;
if (commons !== undefined) {
self._commons.setData(commons);
}
if (tags.mapillary) {
let mapillary = tags.mapillary;
const prefix = "https://www.mapillary.com/map/im/";
let regex = /https?:\/\/www.mapillary.com\/app\/.*pKey=([^&]*).*/
let match = mapillary.match(regex);
if (match) {
mapillary = match[1];
}
if (mapillary.indexOf(prefix) < 0) {
mapillary = prefix + mapillary;
}
AddImages([{url: mapillary, key: undefined}]);
}
})
}
}
public static construct(tags: UIEventSource<any>, imagePrefix = "image", loadSpecial = true): ImageSearcher {
const key = tags.data["id"] + " " + imagePrefix + loadSpecial;
if (tags.data["id"] !== undefined && ImageSearcher._cache.has(key)) {
return ImageSearcher._cache.get(key)
}
const searcher = new ImageSearcher(tags, imagePrefix, loadSpecial);
ImageSearcher._cache.set(key, searcher)
return searcher;
}
private static loadWikidata(wikidataItem, addImage: ((url: string) => void)): void {
// Load the wikidata item, then detect usage on 'commons'
let allWikidataId = wikidataItem.split(";");
for (let wikidataId of allWikidataId) {
// @ts-ignore
if (wikidataId.startsWith("Q")) {
wikidataId = wikidataId.substr(1);
}
Wikimedia.GetWikiData(parseInt(wikidataId), (wd: Wikidata) => {
addImage(wd.image);
Wikimedia.GetCategoryFiles(wd.commonsWiki, (images: ImagesInCategory) => {
for (const image of images.images) {
if (image.startsWith("File:")) {
addImage(image);
}
}
})
})
}
}
private static LoadCommons(commonsData: string, addImage: ((url: string) => void)): void {
const allCommons: string[] = commonsData.split(";");
for (const commons of allCommons) {
if (commons.startsWith("Category:")) {
Wikimedia.GetCategoryFiles(commons, (images: ImagesInCategory) => {
for (const image of images.images) {
if (image.startsWith("File:")) {
addImage(image)
}
}
})
} else {
if (commons.startsWith("File:")) {
addImage(commons)
}
}
}
}
private static LoadImages(tags: any, imagePrefix: string): { key: string, url: string }[] {
const imageTag = tags[imagePrefix];
const images: { key: string, url: string }[] = [];
if (imageTag !== undefined) {
const bareImages = imageTag.split(";");
for (const bareImage of bareImages) {
images.push({key: imagePrefix, url: bareImage})
}
}
for (const key in tags) {
if (key.startsWith(imagePrefix + ":")) {
const url = tags[key]
images.push({key: key, url: url})
}
}
return images;
}
}

View file

@ -1,13 +1,15 @@
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import Loc from "../../Models/Loc";
import {Or} from "../Tags/Or"; import {Or} from "../Tags/Or";
import {Overpass} from "../Osm/Overpass"; import {Overpass} from "../Osm/Overpass";
import Bounds from "../../Models/Bounds";
import FeatureSource from "../FeatureSource/FeatureSource"; import FeatureSource from "../FeatureSource/FeatureSource";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {TagsFilter} from "../Tags/TagsFilter"; import {TagsFilter} from "../Tags/TagsFilter";
import SimpleMetaTagger from "../SimpleMetaTagger"; import SimpleMetaTagger from "../SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import RelationsTracker from "../Osm/RelationsTracker";
import {BBox} from "../BBox";
import Loc from "../../Models/Loc";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class OverpassFeatureSource implements FeatureSource { export default class OverpassFeatureSource implements FeatureSource {
@ -20,116 +22,54 @@ export default class OverpassFeatureSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]> = new UIEventSource<any[]>(undefined); public readonly features: UIEventSource<{ feature: any, freshness: Date }[]> = new UIEventSource<any[]>(undefined);
public readonly sufficientlyZoomed: UIEventSource<boolean>;
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false); public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false);
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0); public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0);
public readonly relationsTracker: RelationsTracker;
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0); private readonly retries: UIEventSource<number> = new UIEventSource<number>(0);
/**
* The previous bounds for which the query has been run at the given zoom level
*
* Note that some layers only activate on a certain zoom level.
* If the map location changes, we check for each layer if it is loaded:
* we start checking the bounds at the first zoom level the layer might operate. If in bounds - no reload needed, otherwise we continue walking down
*/
private readonly _previousBounds: Map<number, Bounds[]> = new Map<number, Bounds[]>();
private readonly _location: UIEventSource<Loc>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _leafletMap: UIEventSource<L.Map>;
private readonly _interpreterUrl: UIEventSource<string>;
private readonly _timeout: UIEventSource<number>;
/** private readonly state: {
* The most important layer should go first, as that one gets first pick for the questions readonly locationControl: UIEventSource<Loc>,
*/ readonly layoutToUse: LayoutConfig,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly currentBounds: UIEventSource<BBox>
}
private readonly _isActive: UIEventSource<boolean>;
private readonly onBboxLoaded: (bbox: BBox, date: Date, layers: LayerConfig[]) => void;
constructor( constructor(
location: UIEventSource<Loc>, state: {
layoutToUse: UIEventSource<LayoutConfig>, readonly locationControl: UIEventSource<Loc>,
leafletMap: UIEventSource<L.Map>, readonly layoutToUse: LayoutConfig,
interpreterUrl: UIEventSource<string>, readonly overpassUrl: UIEventSource<string[]>;
timeout: UIEventSource<number>, readonly overpassTimeout: UIEventSource<number>;
maxZoom = undefined) { readonly overpassMaxZoom: UIEventSource<number>,
this._location = location; readonly currentBounds: UIEventSource<BBox>
this._layoutToUse = layoutToUse; },
this._leafletMap = leafletMap; options?: {
this._interpreterUrl = interpreterUrl; isActive?: UIEventSource<boolean>,
this._timeout = timeout; relationTracker: RelationsTracker,
onBboxLoaded?: (bbox: BBox, date: Date, layers: LayerConfig[]) => void
}) {
this.state = state
this._isActive = options.isActive;
this.onBboxLoaded = options.onBboxLoaded
this.relationsTracker = options.relationTracker
const self = this; const self = this;
state.currentBounds.addCallback(_ => {
this.sufficientlyZoomed = location.map(location => {
if (location?.zoom === undefined) {
return false;
}
let minzoom = Math.min(...layoutToUse.data.layers.map(layer => layer.minzoom ?? 18));
if(location.zoom < minzoom){
return false;
}
if(maxZoom !== undefined && location.zoom > maxZoom){
return false;
}
return true;
}, [layoutToUse]
);
for (let i = 0; i < 25; i++) {
// This update removes all data on all layers -> erase the map on lower levels too
this._previousBounds.set(i, []);
}
layoutToUse.addCallback(() => {
self.update() self.update()
});
location.addCallback(() => {
self.update()
});
leafletMap.addCallbackAndRunD(_ => {
self.update();
}) })
} }
public ForceRefresh() { private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
for (let i = 0; i < 25; i++) {
this._previousBounds.set(i, []);
}
this.update();
}
private GetFilter(): Overpass {
let filters: TagsFilter[] = []; let filters: TagsFilter[] = [];
let extraScripts: string[] = []; let extraScripts: string[] = [];
for (const layer of this._layoutToUse.data.layers) { for (const layer of layersToDownload) {
if (typeof (layer) === "string") {
throw "A layer was not expanded!"
}
if (this._location.data.zoom < layer.minzoom) {
continue;
}
if (layer.doNotDownload) {
continue;
}
if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer!
continue;
}
// Check if data for this layer has already been loaded
let previouslyLoaded = false;
for (let z = layer.minzoom; z < 25 && !previouslyLoaded; z++) {
const previousLoadedBounds = this._previousBounds.get(z);
if (previousLoadedBounds === undefined) {
continue;
}
for (const previousLoadedBound of previousLoadedBounds) {
previouslyLoaded = previouslyLoaded || this.IsInBounds(previousLoadedBound);
if (previouslyLoaded) {
break;
}
}
}
if (previouslyLoaded) {
continue;
}
if (layer.source.overpassScript !== undefined) { if (layer.source.overpassScript !== undefined) {
extraScripts.push(layer.source.overpassScript) extraScripts.push(layer.source.overpassScript)
} else { } else {
@ -141,95 +81,113 @@ export default class OverpassFeatureSource implements FeatureSource {
if (filters.length + extraScripts.length === 0) { if (filters.length + extraScripts.length === 0) {
return undefined; return undefined;
} }
return new Overpass(new Or(filters), extraScripts, this._interpreterUrl, this._timeout); return new Overpass(new Or(filters), extraScripts, interpreterUrl, this.state.overpassTimeout, this.relationsTracker);
} }
private update(): void { private update() {
if (!this._isActive.data) {
return;
}
const self = this;
this.updateAsync().then(bboxDate => {
if(bboxDate === undefined || self.onBboxLoaded === undefined){
return;
}
const [bbox, date, layers] = bboxDate
self.onBboxLoaded(bbox, date, layers)
})
}
private async updateAsync(): Promise<[BBox, Date, LayerConfig[]]> {
if (this.runningQuery.data) { if (this.runningQuery.data) {
console.log("Still running a query, not updating"); console.log("Still running a query, not updating");
return; return undefined;
} }
if (this.timeout.data > 0) { if (this.timeout.data > 0) {
console.log("Still in timeout - not updating") console.log("Still in timeout - not updating")
return; return undefined;
} }
const bounds = this._leafletMap.data?.getBounds(); const bounds = this.state.currentBounds.data?.pad(this.state.layoutToUse.widenFactor)?.expandToTileBounds(14);
if (bounds === undefined) { if (bounds === undefined) {
return; return undefined;
}
const self = this;
const layersToDownload = []
for (const layer of this.state.layoutToUse.layers) {
if (typeof (layer) === "string") {
throw "A layer was not expanded!"
}
if(this.state.locationControl.data.zoom < layer.minzoom){
continue;
}
if (layer.doNotDownload) {
continue;
}
if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer!
continue;
}
layersToDownload.push(layer)
} }
const diff = this._layoutToUse.data.widenFactor; let data: any = undefined
let date: Date = undefined
const overpassUrls = self.state.overpassUrl.data
let lastUsed = 0;
const n = Math.min(90, bounds.getNorth() + diff); do {
const e = Math.min(180, bounds.getEast() + diff); try {
const s = Math.max(-90, bounds.getSouth() - diff);
const w = Math.max(-180, bounds.getWest() - diff);
const queryBounds = {north: n, east: e, south: s, west: w};
const z = Math.floor(this._location.data.zoom ?? 0); const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload);
const self = this;
const overpass = this.GetFilter();
if (overpass === undefined) { if (overpass === undefined) {
return; return undefined;
} }
this.runningQuery.setData(true); this.runningQuery.setData(true);
overpass.queryGeoJson(queryBounds,
function (data, date) {
self._previousBounds.get(z).push(queryBounds);
self.retries.setData(0);
const features = data.features.map(f => ({feature: f, freshness: date}));
SimpleMetaTagger.objectMetaInfo.addMetaTags(features)
self.features.setData(features); [data, date] = await overpass.queryGeoJson(bounds)
self.runningQuery.setData(false); console.log("Querying overpass is done", data)
}, } catch (e) {
function (reason) {
self.retries.data++; self.retries.data++;
self.ForceRefresh();
self.timeout.setData(self.retries.data * 5);
console.log(`QUERY FAILED (retrying in ${5 * self.retries.data} sec) due to ${reason}`);
self.retries.ping(); self.retries.ping();
self.runningQuery.setData(false); console.error(`QUERY FAILED due to`, e);
function countDown() { await Utils.waitFor(1000)
window?.setTimeout(
function () { if (lastUsed + 1 < overpassUrls.length) {
if (self.timeout.data > 1) { lastUsed++
self.timeout.setData(self.timeout.data - 1); console.log("Trying next time with", overpassUrls[lastUsed])
window.setTimeout(
countDown,
1000
)
} else { } else {
self.timeout.setData(0); lastUsed = 0
self.update() self.timeout.setData(self.retries.data * 5);
while (self.timeout.data > 0) {
await Utils.waitFor(1000)
console.log(self.timeout.data)
self.timeout.data--
self.timeout.ping();
} }
}, 1000
)
} }
countDown();
} }
); } while (data === undefined);
self.retries.setData(0);
} try {
data.features.forEach(feature => SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(feature, date));
private IsInBounds(bounds: Bounds): boolean { self.features.setData(data.features.map(f => ({feature: f, freshness: date})));
if (this._previousBounds === undefined) { return [bounds, date, layersToDownload];
return false; } catch (e) {
} console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally {
const b = this._leafletMap.data.getBounds(); self.runningQuery.setData(false);
return b.getSouth() >= bounds.south &&
b.getNorth() <= bounds.north &&
b.getEast() <= bounds.east &&
b.getWest() >= bounds.west;
} }
} }
}

View file

@ -1,50 +1,62 @@
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import FeatureSource from "../FeatureSource/FeatureSource";
import {OsmObject} from "../Osm/OsmObject"; import {OsmObject} from "../Osm/OsmObject";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import {ElementStorage} from "../ElementStorage";
import FeaturePipeline from "../FeatureSource/FeaturePipeline"; import FeaturePipeline from "../FeatureSource/FeaturePipeline";
import OsmApiFeatureSource from "../FeatureSource/OsmApiFeatureSource";
/** /**
* Makes sure the hash shows the selected element and vice-versa. * Makes sure the hash shows the selected element and vice-versa.
*/ */
export default class SelectedFeatureHandler { export default class SelectedFeatureHandler {
private static readonly _no_trigger_on = ["welcome", "copyright", "layers", "new"] private static readonly _no_trigger_on = new Set(["welcome", "copyright", "layers", "new", "", undefined])
private readonly _featureSource: FeatureSource; hash: UIEventSource<string>;
private readonly _hash: UIEventSource<string>; private readonly state: {
private readonly _selectedFeature: UIEventSource<any>; selectedElement: UIEventSource<any>
private readonly _osmApiSource: OsmApiFeatureSource; }
constructor(hash: UIEventSource<string>, constructor(
selectedFeature: UIEventSource<any>, hash: UIEventSource<string>,
featureSource: FeaturePipeline, state: {
osmApiSource: OsmApiFeatureSource) { selectedElement: UIEventSource<any>,
this._hash = hash; allElements: ElementStorage,
this._selectedFeature = selectedFeature; featurePipeline: FeaturePipeline
this._featureSource = featureSource; }
this._osmApiSource = osmApiSource; ) {
const self = this; this.hash = hash;
hash.addCallback(h => { this.state = state
// If the hash changes, set the selected element correctly
function setSelectedElementFromHash(h){
if (h === undefined || h === "") { if (h === undefined || h === "") {
selectedFeature.setData(undefined); // Hash has been cleared - we clear the selected element
state.selectedElement.setData(undefined);
}else{ }else{
self.selectFeature(); // we search the element to select
} const feature = state.allElements.ContainingFeatures.get(h)
})
hash.addCallbackAndRunD(h => {
try {
self.downloadFeature(h)
} catch (e) {
console.error("Could not download feature, probably a weird hash")
}
})
featureSource.features.addCallback(_ => self.selectFeature());
selectedFeature.addCallback(feature => {
if(feature === undefined){ if(feature === undefined){
if (SelectedFeatureHandler._no_trigger_on.indexOf(hash.data) < 0) { return;
}
const currentlySeleced = state.selectedElement.data
if(currentlySeleced === undefined){
state.selectedElement.setData(feature)
return;
}
if(currentlySeleced.properties?.id === feature.properties.id){
// We already have the right feature
return;
}
state.selectedElement.setData(feature)
}
}
hash.addCallback(setSelectedElementFromHash)
// IF the selected element changes, set the hash correctly
state.selectedElement.addCallback(feature => {
if (feature === undefined) {
if (!SelectedFeatureHandler._no_trigger_on.has(hash.data)) {
hash.setData("") hash.setData("")
} }
} }
@ -55,14 +67,25 @@ export default class SelectedFeatureHandler {
} }
}) })
this.selectFeature(); state.featurePipeline.newDataLoadedSignal.addCallbackAndRunD(_ => {
// New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet
if(hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)){
// This is an invalid hash anyway
return;
}
if(state.selectedElement.data !== undefined){
// We already have something selected
return;
}
setSelectedElementFromHash(hash.data)
})
} }
// If a feature is selected via the hash, zoom there // If a feature is selected via the hash, zoom there
public zoomToSelectedFeature(location: UIEventSource<Loc>) { public zoomToSelectedFeature(location: UIEventSource<Loc>) {
const hash = this._hash.data; const hash = this.hash.data;
if (hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0) { if (hash === undefined || SelectedFeatureHandler._no_trigger_on.has(hash)) {
return; // No valid feature selected return; // No valid feature selected
} }
// We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure // We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure
@ -80,42 +103,4 @@ export default class SelectedFeatureHandler {
} }
} }
private downloadFeature(hash: string) {
if (hash === undefined || hash === "") {
return;
}
if (SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0) {
return;
}
try {
this._osmApiSource.load(hash)
} catch (e) {
console.log("Could not download feature, probably a weird hash:", hash)
}
}
private selectFeature() {
const features = this._featureSource?.features?.data;
if (features === undefined) {
return;
}
if (this._selectedFeature.data?.properties?.id === this._hash.data) {
// Feature already selected
return;
}
const hash = this._hash.data;
if (hash === undefined || hash === "" || hash === "#") {
return;
}
for (const feature of features) {
const id = feature.feature?.properties?.id;
if (id === hash) {
this._selectedFeature.setData(feature.feature);
break;
}
}
}
} }

View file

@ -2,65 +2,42 @@ import {UIEventSource} from "../UIEventSource";
import Translations from "../../UI/i18n/Translations"; import Translations from "../../UI/i18n/Translations";
import Locale from "../../UI/i18n/Locale"; import Locale from "../../UI/i18n/Locale";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer"; import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import {ElementStorage} from "../ElementStorage";
import Combine from "../../UI/Base/Combine"; import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../ElementStorage";
class TitleElement extends UIEventSource<string> { export default class TitleHandler {
constructor(state : {
private readonly _layoutToUse: UIEventSource<LayoutConfig>; selectedElement: UIEventSource<any>,
private readonly _selectedFeature: UIEventSource<any>; layoutToUse: LayoutConfig,
private readonly _allElementsStorage: ElementStorage; allElements: ElementStorage
}) {
constructor(layoutToUse: UIEventSource<LayoutConfig>, const currentTitle: UIEventSource<string> = state.selectedElement.map(
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
super("MapComplete");
this._layoutToUse = layoutToUse;
this._selectedFeature = selectedFeature;
this._allElementsStorage = allElementsStorage;
this.syncWith(
this._selectedFeature.map(
selected => { selected => {
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete" const layout = state.layoutToUse
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
if (selected === undefined) { if (selected === undefined) {
return defaultTitle return defaultTitle
} }
const layout = layoutToUse.data;
const tags = selected.properties; const tags = selected.properties;
for (const layer of layout.layers) { for (const layer of layout.layers) {
if (layer.title === undefined) { if (layer.title === undefined) {
continue; continue;
} }
if (layer.source.osmTags.matchesProperties(tags)) { if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = allElementsStorage.getEventSourceById(tags.id) const tagsSource = state.allElements.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title) const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText; return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? defaultTitle;
} }
} }
return defaultTitle return defaultTitle
} }, [Locale.language]
, [Locale.language, layoutToUse]
)
) )
} currentTitle.addCallbackAndRunD(title => {
}
export default class TitleHandler {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => {
document.title = title document.title = title
}) })
} }

165
Logic/BBox.ts Normal file
View file

@ -0,0 +1,165 @@
import * as turf from "@turf/turf";
import {TileRange, Tiles} from "../Models/TileRange";
export class BBox {
readonly maxLat: number;
readonly maxLon: number;
readonly minLat: number;
readonly minLon: number;
static global: BBox = new BBox([[-180, -90], [180, 90]]);
constructor(coordinates) {
this.maxLat = -90;
this.maxLon = -180;
this.minLat = 90;
this.minLon = 180;
for (const coordinate of coordinates) {
this.maxLon = Math.max(this.maxLon, coordinate[0]);
this.maxLat = Math.max(this.maxLat, coordinate[1]);
this.minLon = Math.min(this.minLon, coordinate[0]);
this.minLat = Math.min(this.minLat, coordinate[1]);
}
this.maxLon = Math.min(this.maxLon, 180)
this.maxLat = Math.min(this.maxLat, 90)
this.minLon = Math.max(this.minLon, -180)
this.minLat = Math.max(this.minLat, -90)
this.check();
}
static fromLeafletBounds(bounds) {
return new BBox([[bounds.getWest(), bounds.getNorth()], [bounds.getEast(), bounds.getSouth()]])
}
static get(feature): BBox {
if (feature.bbox?.overlapsWith === undefined) {
const turfBbox: number[] = turf.bbox(feature)
feature.bbox = new BBox([[turfBbox[0], turfBbox[1]], [turfBbox[2], turfBbox[3]]]);
}
return feature.bbox;
}
/**
* Constructs a tilerange which fully contains this bbox (thus might be a bit larger)
* @param zoomlevel
*/
public containingTileRange(zoomlevel): TileRange {
return Tiles.TileRangeBetween(zoomlevel, this.minLat, this.minLon, this.maxLat, this.maxLon)
}
public overlapsWith(other: BBox) {
if (this.maxLon < other.minLon) {
return false;
}
if (this.maxLat < other.minLat) {
return false;
}
if (this.minLon > other.maxLon) {
return false;
}
return this.minLat <= other.maxLat;
}
public isContainedIn(other: BBox) {
if (this.maxLon > other.maxLon) {
return false;
}
if (this.maxLat > other.maxLat) {
return false;
}
if (this.minLon < other.minLon) {
return false;
}
if (this.minLat < other.minLat) {
return false
}
return true;
}
private check() {
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
console.log(this);
throw "BBOX has NAN";
}
}
static fromTile(z: number, x: number, y: number): BBox {
return new BBox(Tiles.tile_bounds_lon_lat(z, x, y))
}
static fromTileIndex(i: number): BBox {
if (i === 0) {
return BBox.global
}
return BBox.fromTile(...Tiles.tile_from_index(i))
}
getEast() {
return this.maxLon
}
getNorth() {
return this.maxLat
}
getWest() {
return this.minLon
}
getSouth() {
return this.minLat
}
pad(factor: number): BBox {
const latDiff = this.maxLat - this.minLat
const lat = (this.maxLat + this.minLat) / 2
const lonDiff = this.maxLon - this.minLon
const lon = (this.maxLon + this.minLon) / 2
return new BBox([[
lon - lonDiff * factor,
lat - latDiff * factor
], [lon + lonDiff * factor,
lat + latDiff * factor]])
}
toLeaflet() {
return [[this.minLat, this.minLon], [this.maxLat, this.maxLon]]
}
asGeoJson(properties: any): any {
return {
type: "Feature",
properties: properties,
geometry: {
type: "Polygon",
coordinates: [[
[this.minLon, this.minLat],
[this.maxLon, this.minLat],
[this.maxLon, this.maxLat],
[this.minLon, this.maxLat],
[this.minLon, this.minLat],
]]
}
}
}
/**
* Expands the BBOx so that it contains complete tiles for the given zoomlevel
* @param zoomlevel
*/
expandToTileBounds(zoomlevel: number): BBox {
const ul = Tiles.embedded_tile(this.minLat, this.minLon, zoomlevel)
const lr = Tiles.embedded_tile(this.maxLat, this.maxLon, zoomlevel)
const boundsul = Tiles.tile_bounds_lon_lat(ul.z, ul.x, ul.y)
const boundslr = Tiles.tile_bounds_lon_lat(lr.z, lr.x, lr.y)
return new BBox([].concat(boundsul, boundslr))
}
}

View file

@ -1,21 +1,47 @@
/// Given a feature source, calculates a list of OSM-contributors who mapped the latest versions /// Given a feature source, calculates a list of OSM-contributors who mapped the latest versions
import FeatureSource from "./FeatureSource/FeatureSource";
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import FeaturePipeline from "./FeatureSource/FeaturePipeline";
import Loc from "../Models/Loc";
import {BBox} from "./BBox";
export default class ContributorCount { export default class ContributorCount {
public readonly Contributors: UIEventSource<Map<string, number>>; public readonly Contributors: UIEventSource<Map<string, number>> = new UIEventSource<Map<string, number>>(new Map<string, number>());
private readonly state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource<BBox>, locationControl: UIEventSource<Loc> };
constructor(featureSource: FeatureSource) { constructor(state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource<BBox>, locationControl: UIEventSource<Loc> }) {
this.Contributors = featureSource.features.map(features => { this.state = state;
const self = this;
state.currentBounds.map(bbox => {
self.update(bbox)
})
state.featurePipeline.runningQuery.addCallbackAndRun(
_ => self.update(state.currentBounds.data)
)
}
private lastUpdate: Date = undefined;
private update(bbox: BBox) {
if(bbox === undefined){
return;
}
const now = new Date();
if (this.lastUpdate !== undefined && ((now.getTime() - this.lastUpdate.getTime()) < 1000 * 60)) {
return;
}
this.lastUpdate = now;
const featuresList = this.state.featurePipeline.GetAllFeaturesWithin(bbox)
const hist = new Map<string, number>(); const hist = new Map<string, number>();
for (const feature of features) { for (const list of featuresList) {
const contributor = feature.feature.properties["_last_edit:contributor"] for (const feature of list) {
const contributor = feature.properties["_last_edit:contributor"]
const count = hist.get(contributor) ?? 0; const count = hist.get(contributor) ?? 0;
hist.set(contributor, count + 1) hist.set(contributor, count + 1)
} }
return hist; }
}) this.Contributors.setData(hist)
} }
} }

View file

@ -39,12 +39,11 @@ export class ElementStorage {
} }
getEventSourceById(elementId): UIEventSource<any> { getEventSourceById(elementId): UIEventSource<any> {
if (this._elements.has(elementId)) { if(elementId === undefined){
return this._elements.get(elementId);
}
console.error("Can not find eventsource with id ", elementId);
return undefined; return undefined;
} }
return this._elements.get(elementId);
}
has(id) { has(id) {
return this._elements.has(id); return this._elements.has(id);

View file

@ -1,13 +1,24 @@
import {GeoOperations} from "./GeoOperations"; import {GeoOperations} from "./GeoOperations";
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine";
import {Relation} from "./Osm/ExtractRelations"; import RelationsTracker from "./Osm/RelationsTracker";
import State from "../State"; import State from "../State";
import {Utils} from "../Utils";
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement";
import List from "../UI/Base/List"; import List from "../UI/Base/List";
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title";
import {UIEventSourceTools} from "./UIEventSource"; import {UIEventSourceTools} from "./UIEventSource";
import AspectedRouting from "./Osm/aspectedRouting"; import AspectedRouting from "./Osm/aspectedRouting";
import {BBox} from "./BBox";
export interface ExtraFuncParams {
/**
* Gets all the features from the given layer within the given BBOX.
* Note that more features then requested can be given back.
* Format: [ [ geojson, geojson, geojson, ... ], [geojson, ...], ...]
*/
getFeaturesWithin: (layerId: string, bbox: BBox) => any[][],
memberships: RelationsTracker
}
export class ExtraFunction { export class ExtraFunction {
@ -45,22 +56,31 @@ export class ExtraFunction {
private static readonly OverlapFunc = new ExtraFunction( private static readonly OverlapFunc = new ExtraFunction(
{ {
name: "overlapWith", name: "overlapWith",
doc: "Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point", doc: "Gives a list of features from the specified layer which this feature (partly) overlaps with. " +
"If the current feature is a point, all features that embed the point are given. " +
"The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point.\n" +
"\n" +
"For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`",
args: ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"] args: ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"]
}, },
(params, feat) => { (params, feat) => {
return (...layerIds: string[]) => { return (...layerIds: string[]) => {
const result = [] const result = []
const bbox = BBox.get(feat)
for (const layerId of layerIds) { for (const layerId of layerIds) {
const otherLayer = params.featuresPerLayer.get(layerId); const otherLayers = params.getFeaturesWithin(layerId, bbox)
if (otherLayer === undefined) { if (otherLayers === undefined) {
continue; continue;
} }
if (otherLayer.length === 0) { if (otherLayers.length === 0) {
continue; continue;
} }
for (const otherLayer of otherLayers) {
result.push(...GeoOperations.calculateOverlap(feat, otherLayer)); result.push(...GeoOperations.calculateOverlap(feat, otherLayer));
} }
}
return result; return result;
} }
} }
@ -69,10 +89,13 @@ export class ExtraFunction {
{ {
name: "distanceTo", name: "distanceTo",
doc: "Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object", doc: "Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object",
args: ["longitude", "latitude"] args: ["feature OR featureID OR longitude", "undefined OR latitude"]
}, },
(featuresPerLayer, feature) => { (featuresPerLayer, feature) => {
return (arg0, lat) => { return (arg0, lat) => {
if (arg0 === undefined) {
return undefined;
}
if (typeof arg0 === "number") { if (typeof arg0 === "number") {
// Feature._lon and ._lat is conveniently place by one of the other metatags // Feature._lon and ._lat is conveniently place by one of the other metatags
return GeoOperations.distanceBetween([arg0, lat], [feature._lon, feature._lat]); return GeoOperations.distanceBetween([arg0, lat], [feature._lon, feature._lat]);
@ -92,59 +115,40 @@ export class ExtraFunction {
} }
} }
) )
private static readonly ClosestObjectFunc = new ExtraFunction( private static readonly ClosestObjectFunc = new ExtraFunction(
{ {
name: "closest", name: "closest",
doc: "Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.", doc: "Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. Returns a single geojson feature or undefined if nothing is found (or not yet laoded)",
args: ["list of features"] args: ["list of features"]
}, },
(params, feature) => { (params, feature) => {
return (features) => { return (features) => ExtraFunction.GetClosestNFeatures(params, feature, features)?.[0]?.feat
if (typeof features === "string") {
const name = features
features = params.featuresPerLayer.get(features)
if (features === undefined) {
var keys = Utils.NoNull(Array.from(params.featuresPerLayer.keys()));
if (keys.length > 0) {
throw `No features defined for ${name}. Defined layers are ${keys.join(", ")}`;
} else {
// This is the first pass over an external dataset
// Other data probably still has to load!
return undefined;
}
}
}
let closestFeature = undefined;
let closestDistance = undefined;
for (const otherFeature of features) {
if (otherFeature == feature || otherFeature.id == feature.id) {
continue; // We ignore self
}
let distance = undefined;
if (otherFeature._lon !== undefined && otherFeature._lat !== undefined) {
distance = GeoOperations.distanceBetween([otherFeature._lon, otherFeature._lat], [feature._lon, feature._lat]);
} else {
distance = GeoOperations.distanceBetween(
GeoOperations.centerpointCoordinates(otherFeature),
[feature._lon, feature._lat]
)
}
if (distance === undefined) {
throw "Undefined distance!"
}
if (closestFeature === undefined || distance < closestDistance) {
closestFeature = otherFeature
closestDistance = distance;
}
}
return closestFeature;
}
} }
) )
private static readonly ClosestNObjectFunc = new ExtraFunction(
{
name: "closestn",
doc: "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature (excluding the feature itself). In the case of ways/polygons, only the centerpoint is considered. " +
"Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet loaded)\n\n" +
"If a 'unique tag key' is given, the tag with this key will only appear once (e.g. if 'name' is given, all features will have a different name)",
args: ["list of features or layer name", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"]
},
(params, feature) => {
return (features, amount, uniqueTag, maxDistanceInMeters) => {
let distance : number = Number(maxDistanceInMeters)
if(isNaN(distance)){
distance = undefined
}
return ExtraFunction.GetClosestNFeatures(params, feature, features, {
maxFeatures: Number(amount),
uniqueTag: uniqueTag,
maxDistance: distance
});
}
}
)
private static readonly Memberships = new ExtraFunction( private static readonly Memberships = new ExtraFunction(
{ {
@ -154,11 +158,12 @@ export class ExtraFunction {
"For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`", "For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`",
args: [] args: []
}, },
(params, _) => { (params, feat) => {
return () => params.relations ?? []; return () =>
params.memberships.knownRelations.data.get(feat.properties.id) ?? []
} }
) )
private static readonly AspectedRouting = new ExtraFunction( private static readonly AspectedRouting = new ExtraFunction(
{ {
name: "score", name: "score",
@ -178,30 +183,30 @@ export class ExtraFunction {
} }
} }
) )
private static readonly allFuncs: ExtraFunction[] = [ private static readonly allFuncs: ExtraFunction[] = [
ExtraFunction.DistanceToFunc, ExtraFunction.DistanceToFunc,
ExtraFunction.OverlapFunc, ExtraFunction.OverlapFunc,
ExtraFunction.ClosestObjectFunc, ExtraFunction.ClosestObjectFunc,
ExtraFunction.ClosestNObjectFunc,
ExtraFunction.Memberships, ExtraFunction.Memberships,
ExtraFunction.AspectedRouting ExtraFunction.AspectedRouting
]; ];
private readonly _name: string; private readonly _name: string;
private readonly _args: string[]; private readonly _args: string[];
private readonly _doc: string; private readonly _doc: string;
private readonly _f: (params: { featuresPerLayer: Map<string, any[]>, relations: { role: string, relation: Relation }[] }, feat: any) => any; private readonly _f: (params: ExtraFuncParams, feat: any) => any;
constructor(options: { name: string, doc: string, args: string[] }, constructor(options: { name: string, doc: string, args: string[] },
f: ((params: { featuresPerLayer: Map<string, any[]>, relations: { role: string, relation: Relation }[] }, feat: any) => any)) { f: ((params: ExtraFuncParams, feat: any) => any)) {
this._name = options.name; this._name = options.name;
this._doc = options.doc; this._doc = options.doc;
this._args = options.args; this._args = options.args;
this._f = f; this._f = f;
} }
public static FullPatchFeature(featuresPerLayer: Map<string, any[]>, relations: { role: string, relation: Relation }[], feature) { public static FullPatchFeature(params: ExtraFuncParams, feature) {
for (const func of ExtraFunction.allFuncs) { for (const func of ExtraFunction.allFuncs) {
func.PatchFeature(featuresPerLayer, relations, feature); func.PatchFeature(params, feature);
} }
} }
@ -221,7 +226,134 @@ export class ExtraFunction {
]); ]);
} }
public PatchFeature(featuresPerLayer: Map<string, any[]>, relations: { role: string, relation: Relation }[], feature: any) { /**
feature[this._name] = this._f({featuresPerLayer: featuresPerLayer, relations: relations}, feature) * Gets the closes N features, sorted by ascending distance.
*
* @param params: The link to mapcomplete state
* @param feature: The central feature under consideration
* @param features: The other features
* @param options: maxFeatures: The maximum amount of features to be returned. Default: 1; uniqueTag: returned features are not allowed to have the same value for this key; maxDistance: stop searching if it is too far away (in meter). Default: 500m
* @constructor
* @private
*/
private static GetClosestNFeatures(params: ExtraFuncParams,
feature: any,
features: string | any[],
options?: { maxFeatures?: number, uniqueTag?: string | undefined, maxDistance?: number }): { feat: any, distance: number }[] {
const maxFeatures = options?.maxFeatures ?? 1
const maxDistance = options?.maxDistance ?? 500
const uniqueTag: string | undefined = options?.uniqueTag
if (typeof features === "string") {
const name = features
const bbox = GeoOperations.bbox(GeoOperations.buffer(GeoOperations.bbox(feature), maxDistance))
features = params.getFeaturesWithin(name, new BBox(bbox.geometry.coordinates))
}else{
features = [features]
}
if (features === undefined) {
return;
}
let closestFeatures: { feat: any, distance: number }[] = [];
for(const featureList of features) {
for (const otherFeature of featureList) {
if (otherFeature === feature || otherFeature.id === feature.id) {
continue; // We ignore self
}
let distance = undefined;
if (otherFeature._lon !== undefined && otherFeature._lat !== undefined) {
distance = GeoOperations.distanceBetween([otherFeature._lon, otherFeature._lat], [feature._lon, feature._lat]);
} else {
distance = GeoOperations.distanceBetween(
GeoOperations.centerpointCoordinates(otherFeature),
[feature._lon, feature._lat]
)
}
if (distance === undefined || distance === null) {
console.error("Could not calculate the distance between", feature, "and", otherFeature)
throw "Undefined distance!"
}
if (distance > maxDistance) {
continue
}
if (closestFeatures.length === 0) {
closestFeatures.push({
feat: otherFeature,
distance: distance
})
continue;
}
if (closestFeatures.length >= maxFeatures && closestFeatures[maxFeatures - 1].distance < distance) {
// The last feature of the list (and thus the furthest away is still closer
// No use for checking, as we already have plenty of features!
continue
}
let targetIndex = closestFeatures.length
for (let i = 0; i < closestFeatures.length; i++) {
const closestFeature = closestFeatures[i];
if (uniqueTag !== undefined) {
const uniqueTagsMatch = otherFeature.properties[uniqueTag] !== undefined &&
closestFeature.feat.properties[uniqueTag] === otherFeature.properties[uniqueTag]
if (uniqueTagsMatch) {
targetIndex = -1
if (closestFeature.distance > distance) {
// This is a very special situation:
// We want to see the tag `uniquetag=some_value` only once in the entire list (e.g. to prevent road segements of identical names to fill up the list of 'names of nearby roads')
// AT this point, we have found a closer segment with the same, identical tag
// so we replace directly
closestFeatures[i] = {feat: otherFeature, distance: distance}
}
break;
}
}
if (closestFeature.distance > distance) {
targetIndex = i
if (uniqueTag !== undefined) {
const uniqueValue = otherFeature.properties[uniqueTag]
// We might still have some other values later one with the same uniquetag that have to be cleaned
for (let j = i; j < closestFeatures.length; j++) {
if (closestFeatures[j].feat.properties[uniqueTag] === uniqueValue) {
closestFeatures.splice(j, 1)
}
}
}
break;
}
}
if (targetIndex == -1) {
continue; // value is already swapped by the unique tag
}
if (targetIndex < maxFeatures) {
// insert and drop one
closestFeatures.splice(targetIndex, 0, {
feat: otherFeature,
distance: distance
})
if (closestFeatures.length >= maxFeatures) {
closestFeatures.splice(maxFeatures, 1)
}
} else {
// Overwrite the last element
closestFeatures[targetIndex] = {
feat: otherFeature,
distance: distance
}
}
}
}
return closestFeatures;
}
public PatchFeature(params: ExtraFuncParams, feature: any) {
feature[this._name] = this._f(params, feature)
} }
} }

View file

@ -1,8 +1,8 @@
import FeatureSource from "./FeatureSource"; import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import State from "../../State"; import State from "../../../State";
export default class RegisteringFeatureSource implements FeatureSource { export default class RegisteringAllFromFeatureSourceActor {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name; public readonly name;

View file

@ -0,0 +1,36 @@
/***
* Saves all the features that are passed in to localstorage, so they can be retrieved on the next run
*
* Technically, more an Actor then a featuresource, but it fits more neatly this ay
*/
import {FeatureSourceForLayer} from "../FeatureSource";
export default class SaveTileToLocalStorageActor {
public static readonly storageKey: string = "cached-features";
public static readonly formatVersion: string = "1"
constructor(source: FeatureSourceForLayer, tileIndex: number) {
source.features.addCallbackAndRunD(features => {
const key = `${SaveTileToLocalStorageActor.storageKey}-${source.layer.layerDef.id}-${tileIndex}`
const now = new Date()
try {
if (features.length > 0) {
localStorage.setItem(key, JSON.stringify(features));
}
// We _still_ write the time to know that this tile is empty!
SaveTileToLocalStorageActor.MarkVisited(source.layer.layerDef.id, tileIndex, now)
} catch (e) {
console.warn("Could not save the features to local storage:", e)
}
})
}
public static MarkVisited(layerId: string, tileId: number, freshness: Date){
const key = `${SaveTileToLocalStorageActor.storageKey}-${layerId}-${tileId}`
localStorage.setItem(key + "-time", JSON.stringify(freshness.getTime()))
localStorage.setItem(key + "-format", SaveTileToLocalStorageActor.formatVersion)
}
}

View file

@ -1,162 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import {Changes} from "../Osm/Changes";
import {ChangeDescription} from "../Osm/Actions/ChangeDescription";
import {Utils} from "../../Utils";
import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject";
/**
* Applies changes from 'Changes' onto a featureSource
*/
export default class ChangeApplicator implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name: string;
constructor(source: FeatureSource, changes: Changes, mode?: {
generateNewGeometries: boolean
}) {
this.name = "ChangesApplied(" + source.name + ")"
this.features = source.features
const seenChanges = new Set<ChangeDescription>();
const self = this;
let runningUpdate = false;
source.features.addCallbackAndRunD(features => {
if (runningUpdate) {
return; // No need to ping again
}
ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data, mode)
seenChanges.clear()
})
changes.pendingChanges.addCallbackAndRunD(changes => {
runningUpdate = true;
changes = changes.filter(ch => !seenChanges.has(ch))
changes.forEach(c => seenChanges.add(c))
ChangeApplicator.ApplyChanges(self.features.data, changes, mode)
source.features.ping()
runningUpdate = false;
})
}
/**
* Returns true if the geometry is changed and the source should be pinged
*/
private static ApplyChanges(features: { feature: any; freshness: Date }[], cs: ChangeDescription[], mode: { generateNewGeometries: boolean }): boolean {
if (cs.length === 0 || features === undefined) {
return;
}
console.log("Applying changes ", this.name, cs)
let geometryChanged = false;
const changesPerId: Map<string, ChangeDescription[]> = new Map<string, ChangeDescription[]>()
for (const c of cs) {
const id = c.type + "/" + c.id
if (!changesPerId.has(id)) {
changesPerId.set(id, [])
}
changesPerId.get(id).push(c)
}
const now = new Date()
function add(feature) {
feature.id = feature.properties.id
features.push({
feature: feature,
freshness: now
})
console.log("Added a new feature: ", feature)
geometryChanged = true;
}
// First, create the new features - they have a negative ID
// We don't set the properties yet though
if (mode?.generateNewGeometries) {
changesPerId.forEach(cs => {
cs
.forEach(change => {
if (change.id >= 0) {
return; // Nothing to do here, already created
}
if (change.changes === undefined) {
// An update to the object - not the actual created
return;
}
try {
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break;
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
add(w.asGeoJson())
break;
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
add(r.asGeoJson())
break;
}
} catch (e) {
console.error(e)
}
})
})
}
for (const feature of features) {
const f = feature.feature;
const id = f.properties.id;
if (!changesPerId.has(id)) {
continue;
}
const changed = {}
// Copy all the properties
Utils.Merge(f, changed)
// play the changes onto the copied object
for (const change of changesPerId.get(id)) {
for (const kv of change.tags ?? []) {
// Apply tag changes and ping the consumers
f.properties[kv.k] = kv.v;
}
// Apply other changes to the object
if (change.changes !== undefined) {
geometryChanged = true;
switch (change.type) {
case "node":
// @ts-ignore
const coor: { lat, lon } = change.changes;
f.geometry.coordinates = [coor.lon, coor.lat]
break;
case "way":
f.geometry.coordinates = change.changes["locations"]
break;
case "relation":
console.error("Changes to relations are not yet supported")
break;
}
}
}
}
return geometryChanged
}
}

View file

@ -1,64 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import FilteredLayer from "../../Models/FilteredLayer";
/**
* In some rare cases, some elements are shown on multiple layers (when 'passthrough' is enabled)
* If this is the case, multiple objects with a different _matching_layer_id are generated.
* In any case, this featureSource marks the objects with _matching_layer_id
*/
export default class FeatureDuplicatorPerLayer implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name;
constructor(layers: UIEventSource<FilteredLayer[]>, upstream: FeatureSource) {
this.name = "FeatureDuplicator of " + upstream.name;
this.features = upstream.features.map(features => {
const newFeatures: { feature: any, freshness: Date }[] = [];
if (features === undefined) {
return newFeatures;
}
for (const f of features) {
if (f.feature._matching_layer_id) {
// Already matched previously
// We simply add it
newFeatures.push(f);
continue;
}
let foundALayer = false;
for (const layer of layers.data) {
if (layer.layerDef.source.osmTags.matchesProperties(f.feature.properties)) {
foundALayer = true;
if (layer.layerDef.passAllFeatures) {
// We copy the feature; the "properties" field is kept identical though!
// Keeping "properties" identical is needed, as it might break the 'allElementStorage' otherwise
const newFeature = {
geometry: f.feature.geometry,
id: f.feature.id,
type: f.feature.type,
properties: f.feature.properties,
_matching_layer_id: layer.layerDef.id
}
newFeatures.push({feature: newFeature, freshness: f.freshness});
} else {
// If not 'passAllFeatures', we are done
f.feature._matching_layer_id = layer.layerDef.id;
newFeatures.push(f);
break;
}
}
}
}
return newFeatures;
})
}
}

View file

@ -1,95 +1,415 @@
import FilteringFeatureSource from "../FeatureSource/FilteringFeatureSource";
import FeatureSourceMerger from "../FeatureSource/FeatureSourceMerger";
import RememberingSource from "../FeatureSource/RememberingSource";
import WayHandlingApplyingFeatureSource from "../FeatureSource/WayHandlingApplyingFeatureSource";
import FeatureDuplicatorPerLayer from "../FeatureSource/FeatureDuplicatorPerLayer";
import FeatureSource from "../FeatureSource/FeatureSource";
import {UIEventSource} from "../UIEventSource";
import LocalStorageSaver from "./LocalStorageSaver";
import LocalStorageSource from "./LocalStorageSource";
import Loc from "../../Models/Loc";
import GeoJsonSource from "./GeoJsonSource";
import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource";
import RegisteringFeatureSource from "./RegisteringFeatureSource";
import FilteredLayer from "../../Models/FilteredLayer";
import {Changes} from "../Osm/Changes";
import ChangeApplicator from "./ChangeApplicator";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "./FeatureSource";
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource";
import {UIEventSource} from "../UIEventSource";
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
import FilteredLayer from "../../Models/FilteredLayer";
import MetaTagging from "../MetaTagging";
import RememberingSource from "./Sources/RememberingSource";
import OverpassFeatureSource from "../Actors/OverpassFeatureSource";
import {Changes} from "../Osm/Changes";
import GeoJsonSource from "./Sources/GeoJsonSource";
import Loc from "../../Models/Loc";
import WayHandlingApplyingFeatureSource from "./Sources/WayHandlingApplyingFeatureSource";
import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor";
import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource";
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor";
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource";
import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger";
import RelationsTracker from "../Osm/RelationsTracker";
import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource";
import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator";
import {BBox} from "../BBox";
import OsmFeatureSource from "./TiledFeatureSource/OsmFeatureSource";
import {OsmConnection} from "../Osm/OsmConnection";
import {Tiles} from "../../Models/TileRange";
import TileFreshnessCalculator from "./TileFreshnessCalculator";
export default class FeaturePipeline implements FeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]>; export default class FeaturePipeline {
public readonly name = "FeaturePipeline" public readonly sufficientlyZoomed: UIEventSource<boolean>;
constructor(flayers: UIEventSource<FilteredLayer[]>, public readonly runningQuery: UIEventSource<boolean>;
changes: Changes, public readonly timeout: UIEventSource<number>;
updater: FeatureSource,
fromOsmApi: FeatureSource,
layout: UIEventSource<LayoutConfig>,
locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>) {
const allLoadedFeatures = new UIEventSource<{ feature: any; freshness: Date }[]>([]) public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
// first we metatag, then we save to get the metatags into storage too private readonly overpassUpdater: OverpassFeatureSource
// Note that we need to register before we do metatagging (as it expects the event sources) private state: {
readonly filteredLayers: UIEventSource<FilteredLayer[]>,
readonly locationControl: UIEventSource<Loc>,
readonly selectedElement: UIEventSource<any>,
readonly changes: Changes,
readonly layoutToUse: LayoutConfig,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>;
readonly osmConnection: OsmConnection
readonly currentBounds: UIEventSource<BBox>
};
private readonly relationTracker: RelationsTracker
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>;
// AT last, the metaTagging also needs to be run _after_ the duplicatorPerLayer private readonly freshnesses = new Map<string, TileFreshnessCalculator>();
const amendedOverpassSource =
new RememberingSource(
new LocalStorageSaver(
new MetaTaggingFeatureSource(allLoadedFeatures,
new FeatureDuplicatorPerLayer(flayers,
new RegisteringFeatureSource(
new ChangeApplicator(
updater, changes
))
)), layout));
const geojsonSources: FeatureSource [] = GeoJsonSource private readonly oldestAllowedDate: Date = new Date(new Date().getTime() - 60 * 60 * 24 * 30 * 1000);
.ConstructMultiSource(flayers.data, locationControl) private readonly osmSourceZoomLevel = 14
.map(geojsonSource => {
let source = new RegisteringFeatureSource( constructor(
new FeatureDuplicatorPerLayer(flayers, handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
new ChangeApplicator(geojsonSource, changes))); state: {
if (!geojsonSource.isOsmCache) { readonly filteredLayers: UIEventSource<FilteredLayer[]>,
source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features); readonly locationControl: UIEventSource<Loc>,
readonly selectedElement: UIEventSource<any>,
readonly changes: Changes,
readonly layoutToUse: LayoutConfig,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>;
readonly osmConnection: OsmConnection
readonly currentBounds: UIEventSource<BBox>
}) {
this.state = state;
const self = this
// milliseconds
const useOsmApi = state.locationControl.map(l => l.zoom > (state.overpassMaxZoom.data ?? 12))
this.relationTracker = new RelationsTracker()
this.sufficientlyZoomed = state.locationControl.map(location => {
if (location?.zoom === undefined) {
return false;
} }
return source let minzoom = Math.min(...state.layoutToUse.layers.map(layer => layer.minzoom ?? 18));
return location.zoom >= minzoom;
}
);
const neededTilesFromOsm = this.getNeededTilesFromOsm(this.sufficientlyZoomed)
const perLayerHierarchy = new Map<string, TileHierarchyMerger>()
this.perLayerHierarchy = perLayerHierarchy
const patchedHandleFeatureSource = function (src: FeatureSourceForLayer & IndexedFeatureSource & Tiled) {
// This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile
const srcFiltered =
new FilteringFeatureSource(state, src.tileIndex,
new WayHandlingApplyingFeatureSource(
new ChangeGeometryApplicator(src, state.changes)
)
)
handleFeatureSource(srcFiltered)
self.somethingLoaded.setData(true)
self.freshnesses.get(src.layer.layerDef.id).addTileLoad(src.tileIndex, new Date())
};
for (const filteredLayer of state.filteredLayers.data) {
const id = filteredLayer.layerDef.id
const source = filteredLayer.layerDef.source
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile))
perLayerHierarchy.set(id, hierarchy)
this.freshnesses.set(id, new TileFreshnessCalculator())
if (source.geojsonSource === undefined) {
// This is an OSM layer
// We load the cached values and register them
// Getting data from upstream happens a bit lower
new TiledFromLocalStorageSource(filteredLayer,
(src) => {
new RegisteringAllFromFeatureSourceActor(src)
hierarchy.registerTile(src);
src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src))
}, state)
TiledFromLocalStorageSource.GetFreshnesses(id).forEach((value, key) => {
self.freshnesses.get(id).addTileLoad(key, value)
})
continue
}
if (source.geojsonZoomLevel === undefined) {
// This is a 'load everything at once' geojson layer
const src = new GeoJsonSource(filteredLayer)
if (source.isOsmCacheLayer) {
// We split them up into tiles anyway as it is an OSM source
TiledFeatureSource.createHierarchy(src, {
layer: src.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
})
}else{
new RegisteringAllFromFeatureSourceActor(src)
perLayerHierarchy.get(id).registerTile(src)
src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src))
}
} else {
new DynamicGeoJsonTileSource(
filteredLayer,
tile => {
new RegisteringAllFromFeatureSourceActor(tile)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
},
state
)
}
}
const osmFeatureSource = new OsmFeatureSource({
isActive: useOsmApi,
neededTiles: neededTilesFromOsm,
handleTile: tile => {
new RegisteringAllFromFeatureSourceActor(tile)
new SaveTileToLocalStorageActor(tile, tile.tileIndex)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
},
state: state,
markTileVisited: (tileId) =>
state.filteredLayers.data.forEach(flayer => {
SaveTileToLocalStorageActor.MarkVisited(flayer.layerDef.id, tileId, new Date())
})
})
const updater = this.initOverpassUpdater(state, useOsmApi)
this.overpassUpdater = updater;
this.timeout = updater.timeout
// Actually load data from the overpass source
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(source) => TiledFeatureSource.createHierarchy(source, {
layer: source.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
maxFeatureCount: state.layoutToUse.clustering.minNeededElements,
maxZoomLevel: state.layoutToUse.clustering.maxZoom,
registerTile: (tile) => {
// We save the tile data for the given layer to local storage
new SaveTileToLocalStorageActor(tile, tile.tileIndex)
perLayerHierarchy.get(source.layer.layerDef.id).registerTile(new RememberingSource(tile))
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
}),
updater)
// Also load points/lines that are newly added.
const newGeometry = new NewGeometryFromChangesFeatureSource(state.changes)
new RegisteringAllFromFeatureSourceActor(newGeometry)
// A NewGeometryFromChangesFeatureSource does not split per layer, so we do this next
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(perLayer) => {
// We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer)
// AT last, we always apply the metatags whenever possible
perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer))
perLayer.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(perLayer))
},
newGeometry
)
// Whenever fresh data comes in, we need to update the metatagging
self.newDataLoadedSignal.stabilized(1000).addCallback(src => {
self.updateAllMetaTagging()
})
this.runningQuery = updater.runningQuery.map(
overpass => overpass || osmFeatureSource.isRunning.data, [osmFeatureSource.isRunning]
)
}
private freshnessForVisibleLayers(z: number, x: number, y: number): Date {
let oldestDate = undefined;
for (const flayer of this.state.filteredLayers.data) {
if (!flayer.isDisplayed.data) {
continue
}
if (this.state.locationControl.data.zoom < flayer.layerDef.minzoom) {
continue;
}
const freshness = this.freshnesses.get(flayer.layerDef.id).freshnessFor(z, x, y)
if (freshness === undefined) {
// SOmething is undefined --> we return undefined as we have to download
return undefined
}
if (oldestDate === undefined || oldestDate > freshness) {
oldestDate = freshness
}
}
return oldestDate
}
private getNeededTilesFromOsm(isSufficientlyZoomed: UIEventSource<boolean>): UIEventSource<number[]> {
const self = this
return this.state.currentBounds.map(bbox => {
if (bbox === undefined) {
return
}
if (!isSufficientlyZoomed.data) {
return;
}
const osmSourceZoomLevel = self.osmSourceZoomLevel
const range = bbox.containingTileRange(osmSourceZoomLevel)
const tileIndexes = []
if (range.total > 100) {
// Too much tiles!
return []
}
Tiles.MapRange(range, (x, y) => {
const i = Tiles.tile_index(osmSourceZoomLevel, x, y);
const oldestDate = self.freshnessForVisibleLayers(osmSourceZoomLevel, x, y)
if (oldestDate !== undefined && oldestDate > this.oldestAllowedDate) {
console.debug("Skipping tile", osmSourceZoomLevel, x, y, "as a decently fresh one is available")
// The cached tiles contain decently fresh data
return;
}
tileIndexes.push(i)
})
return tileIndexes
})
}
private initOverpassUpdater(state: {
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>,
locationControl: UIEventSource<Loc>,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>,
}, useOsmApi: UIEventSource<boolean>): OverpassFeatureSource {
const minzoom = Math.min(...state.layoutToUse.layers.map(layer => layer.minzoom))
const overpassIsActive = state.currentBounds.map(bbox => {
if (bbox === undefined) {
return false
}
let zoom = state.locationControl.data.zoom
if (zoom < minzoom) {
return false;
}
if (zoom > 16) {
zoom = 16
}
if (zoom < 8) {
zoom = zoom + 2
}
const range = bbox.containingTileRange(zoom)
if (range.total > 100) {
return false
}
const self = this;
const allFreshnesses = Tiles.MapRange(range, (x, y) => self.freshnessForVisibleLayers(zoom, x, y))
return allFreshnesses.some(freshness => freshness === undefined || freshness < this.oldestAllowedDate)
}, [state.locationControl])
const self = this;
const updater = new OverpassFeatureSource(state,
{
relationTracker: this.relationTracker,
isActive: useOsmApi.map(b => !b && overpassIsActive.data, [overpassIsActive]),
onBboxLoaded: ((bbox, date, downloadedLayers) => {
Tiles.MapRange(bbox.containingTileRange(self.osmSourceZoomLevel), (x, y) => {
downloadedLayers.forEach(layer => {
SaveTileToLocalStorageActor.MarkVisited(layer.id, Tiles.tile_index(this.osmSourceZoomLevel, x, y), date)
})
})
})
}); });
const amendedLocalStorageSource =
new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, new ChangeApplicator(new LocalStorageSource(layout), changes))
));
const amendedOsmApiSource = new RememberingSource( // Register everything in the state' 'AllElements'
new MetaTaggingFeatureSource(allLoadedFeatures, new RegisteringAllFromFeatureSourceActor(updater)
new FeatureDuplicatorPerLayer(flayers, return updater;
new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes, }
private applyMetaTags(src: FeatureSourceForLayer) {
const self = this
window.setTimeout(
() => {
console.debug("Applying metatagging onto ", src.name)
const layerDef = src.layer.layerDef;
MetaTagging.addMetatags(
src.features.data,
{ {
// We lump in the new points here memberships: this.relationTracker,
generateNewGeometries: true getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox)
},
layerDef,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: layerDef.source.geojsonSource === undefined || !layerDef.source.isOsmCacheLayer
} }
))))); )
},
15
)
const merged = }
new FeatureSourceMerger([
amendedOverpassSource,
amendedOsmApiSource,
amendedLocalStorageSource,
...geojsonSources
]);
merged.features.syncWith(allLoadedFeatures) private updateAllMetaTagging() {
const self = this;
console.log("Reupdating all metatagging")
this.perLayerHierarchy.forEach(hierarchy => {
hierarchy.loadedTiles.forEach(src => {
self.applyMetaTags(src)
})
})
this.features = new WayHandlingApplyingFeatureSource(flayers, }
new FilteringFeatureSource(
flayers, public GetAllFeaturesWithin(bbox: BBox): any[][] {
locationControl, const self = this
selectedElement, const tiles = []
merged Array.from(this.perLayerHierarchy.keys())
)).features; .forEach(key => tiles.push(...self.GetFeaturesWithin(key, bbox)))
return tiles;
}
public GetFeaturesWithin(layerId: string, bbox: BBox): any[][] {
const requestedHierarchy = this.perLayerHierarchy.get(layerId)
if (requestedHierarchy === undefined) {
console.warn("Layer ", layerId, "is not defined. Try one of ", Array.from(this.perLayerHierarchy.keys()))
return undefined;
}
return TileHierarchyTools.getTiles(requestedHierarchy, bbox)
.filter(featureSource => featureSource.features?.data !== undefined)
.map(featureSource => featureSource.features.data.map(fs => fs.feature))
}
public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void) {
Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => {
TileHierarchyTools.getTiles(hierarchy, bbox).forEach(handleTile)
})
} }
} }

View file

@ -1,5 +1,7 @@
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../BBox";
export default interface FeatureSource { export default interface FeatureSource {
features: UIEventSource<{ feature: any, freshness: Date }[]>; features: UIEventSource<{ feature: any, freshness: Date }[]>;
@ -9,38 +11,30 @@ export default interface FeatureSource {
name: string; name: string;
} }
export class FeatureSourceUtils { export interface Tiled {
tileIndex: number,
bbox: BBox
}
/** /**
* Exports given featurePipeline as a geojson FeatureLists (downloads as a json) * A feature source which only contains features for the defined layer
* @param featurePipeline The FeaturePipeline you want to export
* @param options The options object
* @param options.metadata True if you want to include the MapComplete metadata, false otherwise
*/ */
public static extractGeoJson(featurePipeline: FeatureSource, options: { metadata?: boolean } = {}) { export interface FeatureSourceForLayer extends FeatureSource{
let defaults = { readonly layer: FilteredLayer
metadata: false,
}
options = Utils.setDefaults(options, defaults);
// Select all features, ignore the freshness and other data
let featureList: any[] = featurePipeline.features.data.map((feature) =>
JSON.parse(JSON.stringify((feature.feature)))); // Make a deep copy!
if (!options.metadata) {
for (let i = 0; i < featureList.length; i++) {
let feature = featureList[i];
for (let property in feature.properties) {
if (property[0] == "_" && property !== "_lat" && property !== "_lon") {
delete featureList[i]["properties"][property];
}
}
}
}
return {type: "FeatureCollection", features: featureList}
} }
/**
* A feature source which is aware of the indexes it contains
*/
export interface IndexedFeatureSource extends FeatureSource {
readonly containedIds: UIEventSource<Set<string>>
}
/**
* A feature source which has some extra data about it's state
*/
export interface FeatureSourceState {
readonly sufficientlyZoomed: UIEventSource<boolean>;
readonly runningQuery: UIEventSource<boolean>;
readonly timeout: UIEventSource<number>;
} }

View file

@ -1,74 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
/**
* Merges features from different featureSources
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
*/
export default class FeatureSourceMerger implements FeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
private readonly _sources: FeatureSource[];
constructor(sources: FeatureSource[]) {
this._sources = sources;
this.name = "SourceMerger of (" + sources.map(s => s.name).join(", ") + ")"
const self = this;
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
source.features.addCallback(() => {
self.Update();
});
}
this.Update();
}
private Update() {
let somethingChanged = false;
const all: Map<string, { feature: any, freshness: Date }> = new Map<string, { feature: any; freshness: Date }>();
// We seed the dictionary with the previously loaded features
const oldValues = this.features.data ?? [];
for (const oldValue of oldValues) {
all.set(oldValue.feature.id + oldValue.feature._matching_layer_id, oldValue)
}
for (const source of this._sources) {
if (source?.features?.data === undefined) {
continue;
}
for (const f of source.features.data) {
const id = f.feature.properties.id + f.feature._matching_layer_id;
if (!all.has(id)) {
// This is a new feature
somethingChanged = true;
all.set(id, f);
continue;
}
// This value has been seen already, either in a previous run or by a previous datasource
// Let's figure out if something changed
const oldV = all.get(id);
if (oldV.freshness < f.freshness) {
// Jup, this feature is fresher
all.set(id, f);
somethingChanged = true;
}
}
}
if (!somethingChanged) {
// We don't bother triggering an update
return;
}
const newList = [];
all.forEach((value, key) => {
newList.push(value)
})
this.features.setData(newList);
}
}

View file

@ -1,162 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import Loc from "../../Models/Loc";
import Hash from "../Web/Hash";
import {TagsFilter} from "../Tags/TagsFilter";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class FilteringFeatureSource implements FeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name = "FilteringFeatureSource";
constructor(
layers: UIEventSource<{
isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig;
appliedFilters: UIEventSource<TagsFilter>;
}[]>,
location: UIEventSource<Loc>,
selectedElement: UIEventSource<any>,
upstream: FeatureSource
) {
const self = this;
function update() {
const layerDict = {};
if (layers.data.length == 0) {
console.warn("No layers defined!");
return;
}
for (const layer of layers.data) {
const prev = layerDict[layer.layerDef.id]
if (prev !== undefined) {
// We have seen this layer before!
// We prefer the one which has a name
if (layer.layerDef.name === undefined) {
// This one is hidden, so we skip it
console.log("Ignoring layer selection from ", layer)
continue;
}
}
layerDict[layer.layerDef.id] = layer;
}
const features: { feature: any; freshness: Date }[] =
upstream.features.data;
const missingLayers = new Set<string>();
const newFeatures = features.filter((f) => {
const layerId = f.feature._matching_layer_id;
if (
selectedElement.data?.id === f.feature.id ||
f.feature.id === Hash.hash.data) {
// This is the selected object - it gets a free pass even if zoom is not sufficient or it is filtered away
return true;
}
if (layerId === undefined) {
return false;
}
const layer: {
isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig;
appliedFilters: UIEventSource<TagsFilter>;
} = layerDict[layerId];
if (layer === undefined) {
missingLayers.add(layerId);
return false;
}
const isShown = layer.layerDef.isShown;
const tags = f.feature.properties;
if (isShown.IsKnown(tags)) {
const result = layer.layerDef.isShown.GetRenderValue(
f.feature.properties
).txt;
if (result !== "yes") {
return false;
}
}
const tagsFilter = layer.appliedFilters.data;
if (tagsFilter) {
if (!tagsFilter.matchesProperties(f.feature.properties)) {
// Hidden by the filter on the layer itself - we want to hide it no matter wat
return false;
}
}
if (!FilteringFeatureSource.showLayer(layer, location)) {
// The layer itself is either disabled or hidden due to zoom constraints
// We should return true, but it might still match some other layer
return false;
}
return true;
});
self.features.setData(newFeatures);
if (missingLayers.size > 0) {
console.error(
"Some layers were not found: ",
Array.from(missingLayers)
);
}
}
upstream.features.addCallback(() => {
update();
});
location
.map((l) => {
// We want something that is stable for the shown layers
const displayedLayerIndexes = [];
for (let i = 0; i < layers.data.length; i++) {
const layer = layers.data[i];
if (l.zoom < layer.layerDef.minzoom) {
continue;
}
if (!layer.isDisplayed.data) {
continue;
}
displayedLayerIndexes.push(i);
}
return displayedLayerIndexes.join(",");
})
.addCallback(() => {
update();
});
layers.addCallback(update);
const registered = new Set<UIEventSource<boolean>>();
layers.addCallbackAndRun((layers) => {
for (const layer of layers) {
if (registered.has(layer.isDisplayed)) {
continue;
}
registered.add(layer.isDisplayed);
layer.isDisplayed.addCallback(() => update());
layer.appliedFilters.addCallback(() => update());
}
});
update();
}
private static showLayer(
layer: {
isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig;
},
location: UIEventSource<Loc>
) {
return (
layer.isDisplayed.data &&
layer.layerDef.minzoomVisible <= location.data.zoom
);
}
}

View file

@ -1,200 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import Loc from "../../Models/Loc";
import State from "../../State";
import {Utils} from "../../Utils";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
/**
* Fetches a geojson file somewhere and passes it along
*/
export default class GeoJsonSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name;
public readonly isOsmCache: boolean
private onFail: ((errorMsg: any, url: string) => void) = undefined;
private readonly layerId: string;
private readonly seenids: Set<string> = new Set<string>()
private constructor(locationControl: UIEventSource<Loc>,
flayer: { isDisplayed: UIEventSource<boolean>, layerDef: LayerConfig },
onFail?: ((errorMsg: any) => void)) {
this.layerId = flayer.layerDef.id;
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
this.name = "GeoJsonSource of " + url;
const zoomLevel = flayer.layerDef.source.geojsonZoomLevel;
this.isOsmCache = flayer.layerDef.source.isOsmCacheLayer;
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
if (zoomLevel === undefined) {
// This is a classic, static geojson layer
if (onFail === undefined) {
onFail = _ => {
}
}
this.onFail = onFail;
this.LoadJSONFrom(url)
} else {
this.ConfigureDynamicLayer(url, zoomLevel, locationControl, flayer)
}
}
/**
* Merges together the layers which have the same source
* @param flayers
* @param locationControl
* @constructor
*/
public static ConstructMultiSource(flayers: { isDisplayed: UIEventSource<boolean>, layerDef: LayerConfig }[], locationControl: UIEventSource<Loc>): GeoJsonSource[] {
const flayersPerSource = new Map<string, { isDisplayed: UIEventSource<boolean>, layerDef: LayerConfig }[]>();
for (const flayer of flayers) {
const url = flayer.layerDef.source.geojsonSource?.replace(/{layer}/g, flayer.layerDef.id)
if (url === undefined) {
continue;
}
if (!flayersPerSource.has(url)) {
flayersPerSource.set(url, [])
}
flayersPerSource.get(url).push(flayer)
}
const sources: GeoJsonSource[] = []
flayersPerSource.forEach((flayers, key) => {
if (flayers.length == 1) {
sources.push(new GeoJsonSource(locationControl, flayers[0]));
return;
}
const zoomlevels = Utils.Dedup(flayers.map(flayer => "" + (flayer.layerDef.source.geojsonZoomLevel ?? "")))
if (zoomlevels.length > 1) {
throw "Multiple zoomlevels defined for same geojson source " + key
}
let isShown = new UIEventSource<boolean>(true, "IsShown for multiple layers: or of multiple values");
for (const flayer of flayers) {
flayer.isDisplayed.addCallbackAndRun(() => {
let value = false;
for (const flayer of flayers) {
value = flayer.isDisplayed.data || value;
}
isShown.setData(value);
});
}
const source = new GeoJsonSource(locationControl, {
isDisplayed: isShown,
layerDef: flayers[0].layerDef // We only care about the source info here
})
sources.push(source)
})
return sources;
}
private ConfigureDynamicLayer(url: string, zoomLevel: number, locationControl: UIEventSource<Loc>, flayer: { isDisplayed: UIEventSource<boolean>, layerDef: LayerConfig }) {
// This is a dynamic template with a fixed zoom level
url = url.replace("{z}", "" + zoomLevel)
const loadedTiles = new Set<string>();
const self = this;
this.onFail = (msg, url) => {
console.warn(`Could not load geojson layer from`, url, "due to", msg)
loadedTiles.add(url); // We add the url to the 'loadedTiles' in order to not reload it in the future
}
const neededTiles = locationControl.map(
location => {
if (!flayer.isDisplayed.data) {
// No need to download! - the layer is disabled
return undefined;
}
if (location.zoom < flayer.layerDef.minzoom) {
// No need to download! - the layer is disabled
return undefined;
}
// Yup, this is cheating to just get the bounds here
const bounds = State.state.leafletMap.data?.getBounds()
if(bounds === undefined){
// We'll retry later
return undefined
}
const tileRange = Utils.TileRangeBetween(zoomLevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const needed = Utils.MapRange(tileRange, (x, y) => {
return url.replace("{x}", "" + x).replace("{y}", "" + y);
})
return new Set<string>(needed);
}
, [flayer.isDisplayed, State.state.leafletMap]);
neededTiles.stabilized(250).addCallback((needed: Set<string>) => {
if (needed === undefined) {
return;
}
needed.forEach(neededTile => {
if (loadedTiles.has(neededTile)) {
return;
}
loadedTiles.add(neededTile)
self.LoadJSONFrom(neededTile)
})
})
}
private LoadJSONFrom(url: string) {
const eventSource = this.features;
const self = this;
Utils.downloadJson(url)
.then(json => {
if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) {
self.onFail("Runtime error (timeout)", url)
return;
}
const time = new Date();
const newFeatures: { feature: any, freshness: Date } [] = []
let i = 0;
let skipped = 0;
for (const feature of json.features) {
if (feature.properties.id === undefined) {
feature.properties.id = url + "/" + i;
feature.id = url + "/" + i;
i++;
}
if (self.seenids.has(feature.properties.id)) {
skipped++;
continue;
}
self.seenids.add(feature.properties.id)
let freshness: Date = time;
if (feature.properties["_last_edit:timestamp"] !== undefined) {
freshness = new Date(feature.properties["_last_edit:timestamp"])
}
newFeatures.push({feature: feature, freshness: freshness})
}
console.debug("Downloaded " + newFeatures.length + " new features and " + skipped + " already seen features from " + url);
if (newFeatures.length == 0) {
return;
}
eventSource.setData(eventSource.data.concat(newFeatures))
}).catch(msg => self.onFail(msg, url))
}
}

View file

@ -1,41 +0,0 @@
/***
* Saves all the features that are passed in to localstorage, so they can be retrieved on the next run
*
* Technically, more an Actor then a featuresource, but it fits more neatly this ay
*/
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
export default class LocalStorageSaver implements FeatureSource {
public static readonly storageKey: string = "cached-features";
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name = "LocalStorageSaver";
constructor(source: FeatureSource, layout: UIEventSource<LayoutConfig>) {
this.features = source.features;
this.features.addCallbackAndRunD(features => {
const now = new Date().getTime()
features = features.filter(f => layout.data.cacheTimeout > Math.abs(now - f.freshness.getTime()) / 1000)
if (features.length == 0) {
return;
}
try {
const key = LocalStorageSaver.storageKey + layout.data.id
localStorage.setItem(key, JSON.stringify(features));
console.log("Saved ", features.length, "elements to", key)
} catch (e) {
console.warn("Could not save the features to local storage:", e)
}
})
}
}

View file

@ -1,35 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import LocalStorageSaver from "./LocalStorageSaver";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
export default class LocalStorageSource implements FeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name = "LocalStorageSource";
constructor(layout: UIEventSource<LayoutConfig>) {
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
const key = LocalStorageSaver.storageKey + layout.data.id
layout.addCallbackAndRun(_ => {
try {
const fromStorage = localStorage.getItem(key);
if (fromStorage == null) {
return;
}
const loaded: { feature: any; freshness: Date | string }[] =
JSON.parse(fromStorage);
const parsed: { feature: any; freshness: Date }[] = loaded.map(ff => ({
feature: ff.feature,
freshness: typeof ff.freshness == "string" ? new Date(ff.freshness) : ff.freshness
}))
this.features.setData(parsed);
console.log("Loaded ", loaded.length, " features from localstorage as cache")
} catch (e) {
console.log("Could not load features from localStorage:", e)
localStorage.removeItem(key)
}
})
}
}

View file

@ -1,52 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import State from "../../State";
import Hash from "../Web/Hash";
import MetaTagging from "../MetaTagging";
export default class MetaTaggingFeatureSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>(undefined);
public readonly name;
/***
* Constructs a new metatagger which'll calculate various tags
* @param allFeaturesSource: A source where all the currently known features can be found - used to calculate overlaps etc
* @param source: the source of features that should get their metatag and which should be exported again
* @param updateTrigger
*/
constructor(allFeaturesSource: UIEventSource<{ feature: any; freshness: Date }[]>, source: FeatureSource, updateTrigger?: UIEventSource<any>) {
const self = this;
this.name = "MetaTagging of " + source.name
if (allFeaturesSource === undefined) {
throw ("UIEVentSource is undefined")
}
function update() {
const featuresFreshness = source.features.data
if (featuresFreshness === undefined) {
return;
}
featuresFreshness.forEach(featureFresh => {
const feature = featureFresh.feature;
if (Hash.hash.data === feature.properties.id) {
State.state.selectedElement.setData(feature);
}
})
MetaTagging.addMetatags(featuresFreshness,
allFeaturesSource,
State.state.knownRelations.data, State.state.layoutToUse.data.layers);
self.features.setData(featuresFreshness);
}
source.features.addCallbackAndRun(_ => update());
updateTrigger?.addCallback(_ => {
console.debug("Updating because of external call")
update();
})
}
}

View file

@ -1,111 +0,0 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import {OsmObject} from "../Osm/OsmObject";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import FilteredLayer from "../../Models/FilteredLayer";
import Constants from "../../Models/Constants";
export default class OsmApiFeatureSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "OsmApiFeatureSource";
private readonly loadedTiles: Set<string> = new Set<string>();
private readonly _state: {
leafletMap: UIEventSource<any>;
locationControl: UIEventSource<Loc>, filteredLayers: UIEventSource<FilteredLayer[]>};
constructor(minZoom = undefined, state: {locationControl: UIEventSource<Loc>, filteredLayers: UIEventSource<FilteredLayer[]>, leafletMap: UIEventSource<any>}) {
this._state = state;
if(minZoom !== undefined){
if(minZoom < 14){
throw "MinZoom should be at least 14 or higher, OSM-api won't work otherwise"
}
const self = this;
state.locationControl.addCallbackAndRunD(location => {
if(location.zoom > minZoom){
return;
}
self.loadArea()
})
}
}
public load(id: string) {
if (id.indexOf("-") >= 0) {
// Newly added point - not yet in OSM
return;
}
console.debug("Downloading", id, "from the OSM-API")
OsmObject.DownloadObject(id).addCallbackAndRunD(element => {
try {
const geojson = element.asGeoJson();
geojson.id = geojson.properties.id;
this.features.setData([{feature: geojson, freshness: element.timestamp}])
} catch (e) {
console.error(e)
}
})
}
/**
* Loads the current inview-area
*/
public loadArea(z: number = 14): boolean {
const layers = this._state.filteredLayers.data;
const disabledLayers = layers.filter(layer => layer.layerDef.source.overpassScript !== undefined || layer.layerDef.source.geojsonSource !== undefined)
if (disabledLayers.length > 0) {
return false;
}
const loc = this._state.locationControl.data;
if (loc.zoom < Constants.useOsmApiAt) {
return false;
}
if (this._state.leafletMap.data === undefined) {
return false; // Not yet inited
}
const bounds = this._state.leafletMap.data.getBounds()
const tileRange = Utils.TileRangeBetween(z, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const self = this;
Utils.MapRange(tileRange, (x, y) => {
const key = x + "/" + y;
if (self.loadedTiles.has(key)) {
return;
}
self.loadedTiles.add(key);
const bounds = Utils.tile_bounds(z, x, y);
console.log("Loading OSM data tile", z, x, y, " with bounds", bounds)
OsmObject.LoadArea(bounds, objects => {
const keptGeoJson: { feature: any, freshness: Date }[] = []
// Which layer does the object match?
for (const object of objects) {
for (const flayer of layers) {
const layer = flayer.layerDef;
const tags = object.tags
const doesMatch = layer.source.osmTags.matchesProperties(tags);
if (doesMatch) {
const geoJson = object.asGeoJson();
geoJson._matching_layer_id = layer.id
keptGeoJson.push({feature: geoJson, freshness: object.timestamp})
break;
}
}
}
self.features.setData(keptGeoJson)
});
});
return true;
}
}

View file

@ -0,0 +1,93 @@
import FeatureSource, {FeatureSourceForLayer, Tiled} from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import FilteredLayer from "../../Models/FilteredLayer";
import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
/**
* In some rare cases, some elements are shown on multiple layers (when 'passthrough' is enabled)
* If this is the case, multiple objects with a different _matching_layer_id are generated.
* In any case, this featureSource marks the objects with _matching_layer_id
*/
export default class PerLayerFeatureSourceSplitter {
constructor(layers: UIEventSource<FilteredLayer[]>,
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
upstream: FeatureSource,
options?:{
tileIndex?: number,
handleLeftovers?: (featuresWithoutLayer: any[]) => void
}) {
const knownLayers = new Map<string, FeatureSourceForLayer & Tiled>()
function update() {
const features = upstream.features.data;
if (features === undefined) {
return;
}
if (layers.data === undefined) {
return;
}
// We try to figure out (for each feature) in which feature store it should be saved.
// Note that this splitter is only run when it is invoked by the overpass feature source, so we can't be sure in which layer it should go
const featuresPerLayer = new Map<string, { feature, freshness } []>();
const noLayerFound = []
function addTo(layer: FilteredLayer, feature: { feature, freshness }) {
const id = layer.layerDef.id
const list = featuresPerLayer.get(id)
if (list !== undefined) {
list.push(feature)
} else {
featuresPerLayer.set(id, [feature])
}
}
for (const f of features) {
for (const layer of layers.data) {
if (layer.layerDef.source.osmTags.matchesProperties(f.feature.properties)) {
// We have found our matching layer!
addTo(layer, f)
if (!layer.layerDef.passAllFeatures) {
// If not 'passAllFeatures', we are done for this feature
break;
}
}
noLayerFound.push(f)
}
}
// At this point, we have our features per layer as a list
// We assign them to the correct featureSources
for (const layer of layers.data) {
const id = layer.layerDef.id;
const features = featuresPerLayer.get(id)
if (features === undefined) {
// No such features for this layer
continue;
}
let featureSource = knownLayers.get(id)
if (featureSource === undefined) {
// Not yet initialized - now is a good time
featureSource = new SimpleFeatureSource(layer, options?.tileIndex)
featureSource.features.setData(features)
knownLayers.set(id, featureSource)
handleLayerData(featureSource)
} else {
featureSource.features.setData(features)
}
}
// AT last, the leftovers are handled
if(options?.handleLeftovers !== undefined && noLayerFound.length > 0){
options.handleLeftovers(noLayerFound)
}
}
layers.addCallback(_ => update())
upstream.features.addCallbackAndRunD(_ => update())
}
}

View file

@ -0,0 +1,84 @@
/**
* Applies geometry changes from 'Changes' onto every feature of a featureSource
*/
import {Changes} from "../../Osm/Changes";
import {UIEventSource} from "../../UIEventSource";
import {FeatureSourceForLayer, IndexedFeatureSource} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {ChangeDescription, ChangeDescriptionTools} from "../../Osm/Actions/ChangeDescription";
export default class ChangeGeometryApplicator implements FeatureSourceForLayer {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string;
private readonly source: IndexedFeatureSource;
private readonly changes: Changes;
public readonly layer: FilteredLayer
constructor(source: (IndexedFeatureSource & FeatureSourceForLayer), changes: Changes) {
this.source = source;
this.changes = changes;
this.layer = source.layer
this.name = "ChangesApplied(" + source.name + ")"
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>(undefined)
const self = this;
source.features.addCallbackAndRunD(_ => self.update())
changes.allChanges.addCallbackAndRunD(_ => self.update())
}
private update() {
const upstreamFeatures = this.source.features.data
const upstreamIds = this.source.containedIds.data
const changesToApply = this.changes.allChanges.data
?.filter(ch =>
// Does upsteram have this element? If not, we skip
upstreamIds.has(ch.type + "/" + ch.id) &&
// Are any (geometry) changes defined?
ch.changes !== undefined &&
// Ignore new elements, they are handled by the NewGeometryFromChangesFeatureSource
ch.id > 0)
if (changesToApply === undefined || changesToApply.length === 0) {
// No changes to apply!
// Pass the original feature and lets continue our day
this.features.setData(upstreamFeatures);
return;
}
const changesPerId = new Map<string, ChangeDescription[]>()
for (const ch of changesToApply) {
const key = ch.type + "/" + ch.id
if(changesPerId.has(key)){
changesPerId.get(key).push(ch)
}else{
changesPerId.set(key, [ch])
}
}
const newFeatures: { feature: any, freshness: Date }[] = []
for (const feature of upstreamFeatures) {
const changesForFeature = changesPerId.get(feature.feature.properties.id)
if (changesForFeature === undefined) {
// No changes for this element
newFeatures.push(feature)
continue;
}
// Allright! We have a feature to rewrite!
const copy = {
...feature
}
// We only apply the last change as that one'll have the latest geometry
const change = changesForFeature[changesForFeature.length - 1]
copy.feature.geometry = ChangeDescriptionTools.getGeojsonGeometry(change)
console.log("Applying a geometry change onto ", feature, change, copy)
newFeatures.push(copy)
}
this.features.setData(newFeatures)
}
}

View file

@ -0,0 +1,100 @@
/**
* Merges features from different featureSources for a single layer
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
*/
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
public readonly layer: FilteredLayer
private readonly _sources: UIEventSource<FeatureSource[]>;
public readonly tileIndex: number;
public readonly bbox: BBox;
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(new Set())
constructor(layer: FilteredLayer, tileIndex: number, bbox: BBox, sources: UIEventSource<FeatureSource[]>) {
this.tileIndex = tileIndex;
this.bbox = bbox;
this._sources = sources;
this.layer = layer;
this.name = "FeatureSourceMerger("+layer.layerDef.id+", "+Tiles.tile_from_index(tileIndex).join(",")+")"
const self = this;
const handledSources = new Set<FeatureSource>();
sources.addCallbackAndRunD(sources => {
let newSourceRegistered = false;
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
if (handledSources.has(source)) {
continue
}
handledSources.add(source)
newSourceRegistered = true
source.features.addCallback(() => {
self.Update();
});
if (newSourceRegistered) {
self.Update();
}
}
})
}
private Update() {
let somethingChanged = false;
const all: Map<string, { feature: any, freshness: Date }> = new Map<string, { feature: any; freshness: Date }>();
// We seed the dictionary with the previously loaded features
const oldValues = this.features.data ?? [];
for (const oldValue of oldValues) {
all.set(oldValue.feature.id, oldValue)
}
for (const source of this._sources.data) {
if (source?.features?.data === undefined) {
continue;
}
for (const f of source.features.data) {
const id = f.feature.properties.id;
if (!all.has(id)) {
// This is a new feature
somethingChanged = true;
all.set(id, f);
continue;
}
// This value has been seen already, either in a previous run or by a previous datasource
// Let's figure out if something changed
const oldV = all.get(id);
if (oldV.freshness < f.freshness) {
// Jup, this feature is fresher
all.set(id, f);
somethingChanged = true;
}
}
}
if (!somethingChanged) {
// We don't bother triggering an update
return;
}
const newList = [];
all.forEach((value, _) => {
newList.push(value)
})
this.containedIds.setData(new Set(all.keys()))
this.features.setData(newList);
}
}

View file

@ -0,0 +1,90 @@
import {UIEventSource} from "../../UIEventSource";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import Hash from "../../Web/Hash";
import {BBox} from "../../BBox";
export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled {
public features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
public readonly layer: FilteredLayer;
public readonly tileIndex: number
public readonly bbox: BBox
constructor(
state: {
locationControl: UIEventSource<{ zoom: number }>,
selectedElement: UIEventSource<any>,
},
tileIndex,
upstream: FeatureSourceForLayer
) {
const self = this;
this.name = "FilteringFeatureSource(" + upstream.name + ")"
this.tileIndex = tileIndex
this.bbox = BBox.fromTileIndex(tileIndex)
this.layer = upstream.layer;
const layer = upstream.layer;
function update() {
const features: { feature: any; freshness: Date }[] = upstream.features.data;
const newFeatures = features.filter((f) => {
if (
state.selectedElement.data?.id === f.feature.id ||
f.feature.id === Hash.hash.data) {
// This is the selected object - it gets a free pass even if zoom is not sufficient or it is filtered away
return true;
}
const isShown = layer.layerDef.isShown;
const tags = f.feature.properties;
if (isShown.IsKnown(tags)) {
const result = layer.layerDef.isShown.GetRenderValue(
f.feature.properties
).txt;
if (result !== "yes") {
return false;
}
}
const tagsFilter = layer.appliedFilters.data;
for (const filter of tagsFilter ?? []) {
const neededTags = filter.filter.options[filter.selected].osmTags
if (!neededTags.matchesProperties(f.feature.properties)) {
// Hidden by the filter on the layer itself - we want to hide it no matter wat
return false;
}
}
return true;
});
self.features.setData(newFeatures);
}
upstream.features.addCallback(() => {
update();
});
layer.appliedFilters.addCallback(_ => {
update()
})
update();
}
private static showLayer(
layer: {
isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig;
}) {
return layer.isDisplayed.data;
}
}

View file

@ -0,0 +1,120 @@
/**
* Fetches a geojson file somewhere and passes it along
*/
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name;
public readonly isOsmCache: boolean
private onFail: ((errorMsg: any, url: string) => void) = undefined;
private readonly seenids: Set<string> = new Set<string>()
public readonly layer: FilteredLayer;
public readonly tileIndex
public readonly bbox;
/**
* Only used if the actual source is a tiled geojson.
* A big feature might be contained in multiple tiles.
* However, we only want to load them once. The blacklist thus contains all ids of all features previously seen
* @private
*/
private readonly featureIdBlacklist?: UIEventSource<Set<string>>
public constructor(flayer: FilteredLayer,
zxy?: [number, number, number],
options?: {
featureIdBlacklist?: UIEventSource<Set<string>>
}) {
if (flayer.layerDef.source.geojsonZoomLevel !== undefined && zxy === undefined) {
throw "Dynamic layers are not supported. Use 'DynamicGeoJsonTileSource instead"
}
this.layer = flayer;
this.featureIdBlacklist = options?.featureIdBlacklist
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
if (zxy !== undefined) {
const [z, x, y] = zxy;
url = url
.replace('{z}', "" + z)
.replace('{x}', "" + x)
.replace('{y}', "" + y)
this.tileIndex = Tiles.tile_index(z, x, y)
this.bbox = BBox.fromTile(z, x, y)
} else {
this.tileIndex = Tiles.tile_index(0, 0, 0)
this.bbox = BBox.global;
}
this.name = "GeoJsonSource of " + url;
this.isOsmCache = flayer.layerDef.source.isOsmCacheLayer;
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
this.LoadJSONFrom(url)
}
private LoadJSONFrom(url: string) {
const eventSource = this.features;
const self = this;
Utils.downloadJson(url)
.then(json => {
if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) {
self.onFail("Runtime error (timeout)", url)
return;
}
const time = new Date();
const newFeatures: { feature: any, freshness: Date } [] = []
let i = 0;
let skipped = 0;
for (const feature of json.features) {
const props = feature.properties
for (const key in props) {
if (typeof props[key] !== "string") {
// Make sure all the values are string, it crashes stuff otherwise
props[key] = "" + props[key]
}
}
if (props.id === undefined) {
props.id = url + "/" + i;
feature.id = url + "/" + i;
i++;
}
if (self.seenids.has(props.id)) {
skipped++;
continue;
}
self.seenids.add(props.id)
if(self.featureIdBlacklist?.data?.has(props.id)){
continue;
}
let freshness: Date = time;
if (feature.properties["_last_edit:timestamp"] !== undefined) {
freshness = new Date(props["_last_edit:timestamp"])
}
newFeatures.push({feature: feature, freshness: freshness})
}
if (newFeatures.length == 0) {
return;
}
eventSource.setData(eventSource.data.concat(newFeatures))
}).catch(msg => console.error("Could not load geojon layer", url, "due to", msg))
}
}

View file

@ -0,0 +1,94 @@
import {Changes} from "../../Osm/Changes";
import {OsmNode, OsmRelation, OsmWay} from "../../Osm/OsmObject";
import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {ChangeDescription} from "../../Osm/Actions/ChangeDescription";
import State from "../../../State";
export class NewGeometryFromChangesFeatureSource implements FeatureSource {
// This class name truly puts the 'Java' into 'Javascript'
/**
* A feature source containing exclusively new elements
*/
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "newFeatures";
constructor(changes: Changes) {
const seenChanges = new Set<ChangeDescription>();
const features = this.features.data;
const self = this;
changes.pendingChanges
.map(changes => changes.filter(ch =>
// only new objects allowed
ch.id < 0 &&
// The change is an update to the object (e.g. tags or geometry) - not the actual create
ch.changes !== undefined &&
// If tags is undefined, this is probably a new point that is part of a split road
ch.tags !== undefined &&
// Already handled
!seenChanges.has(ch)))
.addCallbackAndRunD(changes => {
if (changes.length === 0) {
return;
}
const now = new Date();
function add(feature) {
feature.id = feature.properties.id
features.push({
feature: feature,
freshness: now
})
console.warn("Added a new feature: ", JSON.stringify(feature))
}
for (const change of changes) {
seenChanges.add(change)
try {
const tags = {}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
tags["id"] = change.type+"/"+change.id
tags["_backend"] = State.state.osmConnection._oauth_config.url
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break;
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
add(w.asGeoJson())
break;
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
add(r.asGeoJson())
break;
}
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
self.features.ping()
})
}
}

View file

@ -1,17 +1,24 @@
/** /**
* Every previously added point is remembered, but new points are added * Every previously added point is remembered, but new points are added.
* Data coming from upstream will always overwrite a previous value
*/ */
import FeatureSource from "./FeatureSource"; import FeatureSource, {Tiled} from "../FeatureSource";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import {BBox} from "../../BBox";
export default class RememberingSource implements FeatureSource , Tiled{
export default class RememberingSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>;
public readonly name; public readonly name;
public readonly tileIndex : number
public readonly bbox : BBox
constructor(source: FeatureSource) { constructor(source: FeatureSource & Tiled) {
const self = this; const self = this;
this.name = "RememberingSource of " + source.name; this.name = "RememberingSource of " + source.name;
this.tileIndex= source.tileIndex
this.bbox = source.bbox;
const empty = []; const empty = [];
this.features = source.features.map(features => { this.features = source.features.map(features => {
const oldFeatures = self.features?.data ?? empty; const oldFeatures = self.features?.data ?? empty;
@ -20,9 +27,9 @@ export default class RememberingSource implements FeatureSource {
} }
// Then new ids // Then new ids
const ids = new Set<string>(features.map(f => f.feature.properties.id + f.feature.geometry.type + f.feature._matching_layer_id)); const ids = new Set<string>(features.map(f => f.feature.properties.id + f.feature.geometry.type));
// the old data // the old data
const oldData = oldFeatures.filter(old => !ids.has(old.feature.properties.id + old.feature.geometry.type + old.feature._matching_layer_id)) const oldData = oldFeatures.filter(old => !ids.has(old.feature.properties.id + old.feature.geometry.type))
return [...features, ...oldData]; return [...features, ...oldData];
}) })
} }

View file

@ -0,0 +1,22 @@
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Utils} from "../../../Utils";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "SimpleFeatureSource";
public readonly layer: FilteredLayer;
public readonly bbox: BBox = BBox.global;
public readonly tileIndex: number;
constructor(layer: FilteredLayer, tileIndex: number) {
this.name = "SimpleFeatureSource(" + layer.layerDef.id + ")"
this.layer = layer
this.tileIndex = tileIndex ?? 0;
this.bbox = BBox.fromTileIndex(this.tileIndex)
}
}

View file

@ -0,0 +1,28 @@
import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
/**
* A simple dummy implementation for whenever it is needed
*/
export default class StaticFeatureSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name: string = "StaticFeatureSource"
constructor(features: any[] | UIEventSource<any[] | UIEventSource<{ feature: any, freshness: Date }>>, useFeaturesDirectly) {
const now = new Date();
if (useFeaturesDirectly) {
// @ts-ignore
this.features = features
} else if (features instanceof UIEventSource) {
// @ts-ignore
this.features = features.map(features => features.map(f => ({feature: f, freshness: now})))
} else {
this.features = new UIEventSource(features.map(f => ({
feature: f,
freshness: now
})))
}
}
}

View file

@ -1,44 +1,37 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import {GeoOperations} from "../GeoOperations";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
/** /**
* This is the part of the pipeline which introduces extra points at the center of an area (but only if this is demanded by the wayhandling) * This is the part of the pipeline which introduces extra points at the center of an area (but only if this is demanded by the wayhandling)
*/ */
export default class WayHandlingApplyingFeatureSource implements FeatureSource { import {UIEventSource} from "../../UIEventSource";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import {GeoOperations} from "../../GeoOperations";
import {FeatureSourceForLayer} from "../FeatureSource";
export default class WayHandlingApplyingFeatureSource implements FeatureSourceForLayer {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name; public readonly name;
public readonly layer;
constructor(upstream: FeatureSourceForLayer) {
this.name = "Wayhandling(" + upstream.name + ")";
this.layer = upstream.layer
const layer = upstream.layer.layerDef;
if (layer.wayHandling === LayerConfig.WAYHANDLING_DEFAULT) {
// We don't have to do anything fancy
// lets just wire up the upstream
this.features = upstream.features;
return;
}
constructor(layers: UIEventSource<{
layerDef: LayerConfig
}[]>,
upstream: FeatureSource) {
this.name = "Wayhandling of " + upstream.name;
this.features = upstream.features.map( this.features = upstream.features.map(
features => { features => {
if (features === undefined) { if (features === undefined) {
return; return;
} }
const layerDict = {};
let allDefaultWayHandling = true;
for (const layer of layers.data) {
layerDict[layer.layerDef.id] = layer;
if (layer.layerDef.wayHandling !== LayerConfig.WAYHANDLING_DEFAULT) {
allDefaultWayHandling = false;
}
}
const newFeatures: { feature: any, freshness: Date }[] = []; const newFeatures: { feature: any, freshness: Date }[] = [];
for (const f of features) { for (const f of features) {
const feat = f.feature; const feat = f.feature;
const layerId = feat._matching_layer_id;
const layer: LayerConfig = layerDict[layerId].layerDef;
if (layer === undefined) {
console.error("No layer found with id " + layerId);
continue;
}
if (layer.wayHandling === LayerConfig.WAYHANDLING_DEFAULT) { if (layer.wayHandling === LayerConfig.WAYHANDLING_DEFAULT) {
newFeatures.push(f); newFeatures.push(f);
@ -47,19 +40,17 @@ export default class WayHandlingApplyingFeatureSource implements FeatureSource {
if (feat.geometry.type === "Point") { if (feat.geometry.type === "Point") {
newFeatures.push(f); newFeatures.push(f);
// it is a point, nothing to do here // feature is a point, nothing to do here
continue; continue;
} }
// Create the copy // Create the copy
const centerPoint = GeoOperations.centerpoint(feat); const centerPoint = GeoOperations.centerpoint(feat);
centerPoint["_matching_layer_id"] = feat._matching_layer_id;
newFeatures.push({feature: centerPoint, freshness: f.freshness}); newFeatures.push({feature: centerPoint, freshness: f.freshness});
if (layer.wayHandling === LayerConfig.WAYHANDLING_CENTER_AND_WAY) { if (layer.wayHandling === LayerConfig.WAYHANDLING_CENTER_AND_WAY) {
newFeatures.push(f); newFeatures.push(f);
} }
} }
return newFeatures; return newFeatures;
} }

View file

@ -0,0 +1,72 @@
import {Tiles} from "../../Models/TileRange";
export default class TileFreshnessCalculator {
/**
* All the freshnesses per tile index
* @private
*/
private readonly freshnesses = new Map<number, Date>();
/**
* Marks that some data got loaded for this layer
* @param tileId
* @param freshness
*/
public addTileLoad(tileId: number, freshness: Date){
const existingFreshness = this.freshnessFor(...Tiles.tile_from_index(tileId))
if(existingFreshness >= freshness){
return;
}
this.freshnesses.set(tileId, freshness)
// Do we have freshness for the neighbouring tiles? If so, we can mark the tile above as loaded too!
let [z, x, y] = Tiles.tile_from_index(tileId)
if(z === 0){
return;
}
x = x - (x % 2) // Make the tiles always even
y = y - (y % 2)
const ul = this.freshnessFor(z, x, y)?.getTime()
if(ul === undefined){
return
}
const ur = this.freshnessFor(z, x + 1, y)?.getTime()
if(ur === undefined){
return
}
const ll = this.freshnessFor(z, x, y + 1)?.getTime()
if(ll === undefined){
return
}
const lr = this.freshnessFor(z, x + 1, y + 1)?.getTime()
if(lr === undefined){
return
}
const leastFresh = Math.min(ul, ur, ll, lr)
const date = new Date()
date.setTime(leastFresh)
this.addTileLoad(
Tiles.tile_index(z - 1, Math.floor(x / 2), Math.floor(y / 2)),
date
)
}
public freshnessFor(z: number, x: number, y:number): Date {
if(z < 0){
return undefined
}
const tileId = Tiles.tile_index(z, x, y)
if(this.freshnesses.has(tileId)) {
return this.freshnesses.get(tileId)
}
// recurse up
return this.freshnessFor(z - 1, Math.floor(x /2), Math.floor(y / 2))
}
}

View file

@ -0,0 +1,72 @@
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc";
import DynamicTileSource from "./DynamicTileSource";
import {Utils} from "../../../Utils";
import GeoJsonSource from "../Sources/GeoJsonSource";
export default class DynamicGeoJsonTileSource extends DynamicTileSource {
constructor(layer: FilteredLayer,
registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
state: {
locationControl: UIEventSource<Loc>
leafletMap: any
}) {
const source = layer.layerDef.source
if (source.geojsonZoomLevel === undefined) {
throw "Invalid layer: geojsonZoomLevel expected"
}
if (source.geojsonSource === undefined) {
throw "Invalid layer: geojsonSource expected"
}
const whitelistUrl = source.geojsonSource.replace("{z}_{x}_{y}.geojson", "overview.json")
.replace("{layer}",layer.layerDef.id)
let whitelist = undefined
Utils.downloadJson(whitelistUrl).then(
json => {
const data = new Map<number, Set<number>>();
for (const x in json) {
data.set(Number(x), new Set(json[x]))
}
whitelist = data
}
).catch(err => {
console.warn("No whitelist found for ", layer.layerDef.id, err)
})
const seenIds = new Set<string>();
const blackList = new UIEventSource(seenIds)
super(
layer,
source.geojsonZoomLevel,
(zxy) => {
if(whitelist !== undefined){
const isWhiteListed = whitelist.get(zxy[1])?.has(zxy[2])
if(!isWhiteListed){
return undefined;
}
}
const src = new GeoJsonSource(
layer,
zxy,
{
featureIdBlacklist: blackList
}
)
src.features.addCallbackAndRunD(feats => {
feats.forEach(feat => seenIds.add(feat.feature.properties.id))
blackList.ping();
})
registerLayer(src)
return src
},
state
);
}
}

View file

@ -0,0 +1,79 @@
import State from "../../../State";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Utils} from "../../../Utils";
import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
/***
* A tiled source which dynamically loads the required tiles at a fixed zoom level
*/
export default class DynamicTileSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
private readonly _loadedTiles = new Set<number>();
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled>;
constructor(
layer: FilteredLayer,
zoomlevel: number,
constructTile: (zxy: [number, number, number]) => (FeatureSourceForLayer & Tiled),
state: {
locationControl: UIEventSource<Loc>
leafletMap: any
}
) {
state = State.state
const self = this;
this.loadedTiles = new Map<number,FeatureSourceForLayer & Tiled>()
const neededTiles = state.locationControl.map(
location => {
if (!layer.isDisplayed.data) {
// No need to download! - the layer is disabled
return undefined;
}
if (location.zoom < layer.layerDef.minzoom) {
// No need to download! - the layer is disabled
return undefined;
}
// Yup, this is cheating to just get the bounds here
const bounds = state.leafletMap.data?.getBounds()
if (bounds === undefined) {
// We'll retry later
return undefined
}
const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const needed = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i))
if (needed.length === 0) {
return undefined
}
return needed
}
, [layer.isDisplayed, state.leafletMap]).stabilized(250);
neededTiles.addCallbackAndRunD(neededIndexes => {
console.log("Tiled geojson source ",layer.layerDef.id," needs", neededIndexes)
if (neededIndexes === undefined) {
return;
}
for (const neededIndex of neededIndexes) {
self._loadedTiles.add(neededIndex)
const src = constructTile(Tiles.tile_from_index(neededIndex))
if(src !== undefined){
self.loadedTiles.set(neededIndex, src)
}
}
})
}
}

View file

@ -0,0 +1,118 @@
import {Utils} from "../../../Utils";
import * as OsmToGeoJson from "osmtogeojson";
import StaticFeatureSource from "../Sources/StaticFeatureSource";
import PerLayerFeatureSourceSplitter from "../PerLayerFeatureSourceSplitter";
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import {OsmConnection} from "../../Osm/OsmConnection";
export default class OsmFeatureSource {
private readonly _backend: string;
public readonly isRunning: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly filteredLayers: UIEventSource<FilteredLayer[]>;
private readonly handleTile: (fs: (FeatureSourceForLayer & Tiled)) => void;
private isActive: UIEventSource<boolean>;
private options: {
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
isActive: UIEventSource<boolean>,
neededTiles: UIEventSource<number[]>,
state: {
readonly osmConnection: OsmConnection;
},
markTileVisited?: (tileId: number) => void
};
private readonly downloadedTiles = new Set<number>()
constructor(options: {
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
isActive: UIEventSource<boolean>,
neededTiles: UIEventSource<number[]>,
state: {
readonly filteredLayers: UIEventSource<FilteredLayer[]>;
readonly osmConnection: OsmConnection;
},
markTileVisited?: (tileId: number) => void
}) {
this.options = options;
this._backend = options.state.osmConnection._oauth_config.url;
this.filteredLayers = options.state.filteredLayers.map(layers => layers.filter(layer => layer.layerDef.source.geojsonSource === undefined))
this.handleTile = options.handleTile
this.isActive = options.isActive
const self = this
options.neededTiles.addCallbackAndRunD(neededTiles => {
if (options.isActive?.data === false) {
return;
}
self.isRunning.setData(true)
try {
for (const neededTile of neededTiles) {
if (self.downloadedTiles.has(neededTile)) {
return;
}
self.downloadedTiles.add(neededTile)
Promise.resolve(self.LoadTile(...Tiles.tile_from_index(neededTile)).then(_ => {
}))
}
} catch (e) {
console.error(e)
}
self.isRunning.setData(false)
})
}
private async LoadTile(z, x, y): Promise<void> {
if (z > 20) {
throw "This is an absurd high zoom level"
}
const bbox = BBox.fromTile(z, x, y)
const url = `${this._backend}/api/0.6/map?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
try {
console.log("Attempting to get tile", z, x, y, "from the osm api")
const osmXml = await Utils.download(url, {"accept": "application/xml"})
try {
const parsed = new DOMParser().parseFromString(osmXml, "text/xml");
console.log("Got tile", z, x, y, "from the osm api")
const geojson = OsmToGeoJson.default(parsed,
// @ts-ignore
{
flatProperties: true
});
console.log("Tile geojson:", z, x, y, "is", geojson)
const index = Tiles.tile_index(z, x, y);
new PerLayerFeatureSourceSplitter(this.filteredLayers,
this.handleTile,
new StaticFeatureSource(geojson.features, false),
{
tileIndex:index
}
);
if(this.options.markTileVisited){
this.options.markTileVisited(index)
}
} catch (e) {
console.error("Weird error: ", e)
}
} catch (e) {
console.error("Could not download tile", z, x, y, "due to", e, "; retrying with smaller bounds")
if (e === "rate limited") {
return;
}
await this.LoadTile(z + 1, x * 2, y * 2)
await this.LoadTile(z + 1, 1 + x * 2, y * 2)
await this.LoadTile(z + 1, x * 2, 1 + y * 2)
await this.LoadTile(z + 1, 1 + x * 2, 1 + y * 2)
return;
}
}
}

View file

@ -0,0 +1,27 @@
Data in MapComplete can come from multiple sources.
Currently, they are:
- The Overpass-API
- The OSM-API
- One or more GeoJSON files. This can be a single file or a set of tiled geojson files
- LocalStorage, containing features from a previous visit
- Changes made by the user introducing new features
When the data enters from Overpass or from the OSM-API, they are first distributed per layer:
OVERPASS | ---PerLayerFeatureSource---> FeatureSourceForLayer[]
OSM |
The GeoJSon files (not tiled) are then added to this list
A single FeatureSourcePerLayer is then further handled by splitting it into a tile hierarchy.
In order to keep thins snappy, they are distributed over a tiled database per layer.
## Notes
`cached-featuresbookcases` is the old key used `cahced-features{themeid}` and should be cleaned up

View file

@ -0,0 +1,25 @@
import FeatureSource, {Tiled} from "../FeatureSource";
import {BBox} from "../../BBox";
export default interface TileHierarchy<T extends FeatureSource & Tiled> {
/**
* A mapping from 'tile_index' to the actual tile featrues
*/
loadedTiles: Map<number, T>
}
export class TileHierarchyTools {
public static getTiles<T extends FeatureSource & Tiled>(hierarchy: TileHierarchy<T>, bbox: BBox): T[] {
const result = []
hierarchy.loadedTiles.forEach((tile) => {
if (tile.bbox.overlapsWith(bbox)) {
result.push(tile)
}
})
return result;
}
}

View file

@ -0,0 +1,46 @@
import TileHierarchy from "./TileHierarchy";
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import FeatureSourceMerger from "../Sources/FeatureSourceMerger";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, FeatureSourceForLayer & Tiled>();
private readonly sources: Map<number, UIEventSource<FeatureSource[]>> = new Map<number, UIEventSource<FeatureSource[]>>();
public readonly layer: FilteredLayer;
private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void;
constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource & Tiled, index: number) => void) {
this.layer = layer;
this._handleTile = handleTile;
}
/**
* Add another feature source for the given tile.
* Entries for this tile will be merged
* @param src
* @param index
*/
public registerTile(src: FeatureSource & Tiled) {
const index = src.tileIndex
if (this.sources.has(index)) {
const sources = this.sources.get(index)
sources.data.push(src)
sources.ping()
return;
}
// We have to setup
const sources = new UIEventSource<FeatureSource[]>([src])
this.sources.set(index, sources)
const merger = new FeatureSourceMerger(this.layer, index, BBox.fromTile(...Tiles.tile_from_index(index)), sources)
this.loadedTiles.set(index, merger)
this._handleTile(merger, index)
}
}

View file

@ -0,0 +1,206 @@
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {Utils} from "../../../Utils";
import FilteredLayer from "../../../Models/FilteredLayer";
import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
/**
* Contains all features in a tiled fashion.
* The data will be automatically broken down into subtiles when there are too much features in a single tile or if the zoomlevel is too high
*/
export default class TiledFeatureSource implements Tiled, IndexedFeatureSource, FeatureSourceForLayer, TileHierarchy<IndexedFeatureSource & FeatureSourceForLayer & Tiled> {
public readonly z: number;
public readonly x: number;
public readonly y: number;
public readonly parent: TiledFeatureSource;
public readonly root: TiledFeatureSource
public readonly layer: FilteredLayer;
/* An index of all known tiles. allTiles[z][x][y].get('layerid') will yield the corresponding tile.
* Only defined on the root element!
*/
public readonly loadedTiles: Map<number, TiledFeatureSource & FeatureSourceForLayer> = undefined;
public readonly maxFeatureCount: number;
public readonly name;
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>
public readonly containedIds: UIEventSource<Set<string>>
public readonly bbox: BBox;
private upper_left: TiledFeatureSource
private upper_right: TiledFeatureSource
private lower_left: TiledFeatureSource
private lower_right: TiledFeatureSource
private readonly maxzoom: number;
private readonly options: TiledFeatureSourceOptions
public readonly tileIndex: number;
private constructor(z: number, x: number, y: number, parent: TiledFeatureSource, options?: TiledFeatureSourceOptions) {
this.z = z;
this.x = x;
this.y = y;
this.bbox = BBox.fromTile(z, x, y)
this.tileIndex = Tiles.tile_index(z, x, y)
this.name = `TiledFeatureSource(${z},${x},${y})`
this.parent = parent;
this.layer = options.layer
options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 250;
this.maxzoom = options.maxZoomLevel ?? 18
this.options = options;
if (parent === undefined) {
throw "Parent is not allowed to be undefined. Use null instead"
}
if (parent === null && z !== 0 && x !== 0 && y !== 0) {
throw "Invalid root tile: z, x and y should all be null"
}
if (parent === null) {
this.root = this;
this.loadedTiles = new Map()
} else {
this.root = this.parent.root;
this.loadedTiles = this.root.loadedTiles;
const i = Tiles.tile_index(z, x, y)
this.root.loadedTiles.set(i, this)
}
this.features = new UIEventSource<any[]>([])
this.containedIds = this.features.map(features => {
if (features === undefined) {
return undefined;
}
return new Set(features.map(f => f.feature.properties.id))
})
// We register this tile, but only when there is some data in it
if (this.options.registerTile !== undefined) {
this.features.addCallbackAndRunD(features => {
if (features.length === 0) {
return;
}
this.options.registerTile(this)
return true;
})
}
}
public static createHierarchy(features: FeatureSource, options?: TiledFeatureSourceOptions): TiledFeatureSource {
const root = new TiledFeatureSource(0, 0, 0, null, options)
features.features?.addCallbackAndRunD(feats => root.addFeatures(feats))
return root;
}
private isSplitNeeded(featureCount: number){
if(this.upper_left !== undefined){
// This tile has been split previously, so we keep on splitting
return true;
}
if(this.z >= this.maxzoom){
// We are not allowed to split any further
return false
}
if(this.options.minZoomLevel !== undefined && this.z < this.options.minZoomLevel){
// We must have at least this zoom level before we are allowed to start splitting
return true
}
// To much features - we split
return featureCount > this.maxFeatureCount
}
/***
* Adds the list of features to this hierarchy.
* If there are too much features, the list will be broken down and distributed over the subtiles (only retaining features that don't fit a subtile on this level)
* @param features
* @private
*/
private addFeatures(features: { feature: any, freshness: Date }[]) {
if (features === undefined || features.length === 0) {
return;
}
if (!this.isSplitNeeded(features.length)) {
this.features.setData(features)
return;
}
if (this.upper_left === undefined) {
this.upper_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2, this, this.options)
this.upper_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2, this, this.options)
this.lower_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2 + 1, this, this.options)
this.lower_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2 + 1, this, this.options)
}
const ulf = []
const urf = []
const llf = []
const lrf = []
const overlapsboundary = []
for (const feature of features) {
const bbox = BBox.get(feature.feature)
if (this.options.dontEnforceMinZoom) {
if (bbox.overlapsWith(this.upper_left.bbox)) {
ulf.push(feature)
} else if (bbox.overlapsWith(this.upper_right.bbox)) {
urf.push(feature)
} else if (bbox.overlapsWith(this.lower_left.bbox)) {
llf.push(feature)
} else if (bbox.overlapsWith(this.lower_right.bbox)) {
lrf.push(feature)
} else {
overlapsboundary.push(feature)
}
}else if (this.options.minZoomLevel === undefined) {
if (bbox.isContainedIn(this.upper_left.bbox)) {
ulf.push(feature)
} else if (bbox.isContainedIn(this.upper_right.bbox)) {
urf.push(feature)
} else if (bbox.isContainedIn(this.lower_left.bbox)) {
llf.push(feature)
} else if (bbox.isContainedIn(this.lower_right.bbox)) {
lrf.push(feature)
} else {
overlapsboundary.push(feature)
}
} else {
// We duplicate a feature on a boundary into every tile as we need to get to the minZoomLevel
if (bbox.overlapsWith(this.upper_left.bbox)) {
ulf.push(feature)
}
if (bbox.overlapsWith(this.upper_right.bbox)) {
urf.push(feature)
}
if (bbox.overlapsWith(this.lower_left.bbox)) {
llf.push(feature)
}
if (bbox.overlapsWith(this.lower_right.bbox)) {
lrf.push(feature)
}
}
}
this.upper_left.addFeatures(ulf)
this.upper_right.addFeatures(urf)
this.lower_left.addFeatures(llf)
this.lower_right.addFeatures(lrf)
this.features.setData(overlapsboundary)
}
}
export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number,
readonly maxZoomLevel?: number,
readonly minZoomLevel?: number,
/**
* IF minZoomLevel is set, and if a feature runs through a tile boundary, it would normally be duplicated.
* Setting 'dontEnforceMinZoomLevel' will still allow bigger zoom levels for those features
*/
readonly dontEnforceMinZoom?: boolean,
readonly registerTile?: (tile: TiledFeatureSource & Tiled) => void,
readonly layer?: FilteredLayer
}

View file

@ -0,0 +1,132 @@
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy";
import SaveTileToLocalStorageActor from "../Actors/SaveTileToLocalStorageActor";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class TiledFromLocalStorageSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, FeatureSourceForLayer & Tiled>();
public static GetFreshnesses(layerId: string): Map<number, Date> {
const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layerId + "-"
const freshnesses = new Map<number, Date>()
for (const key of Object.keys(localStorage)) {
if(!(key.startsWith(prefix) && key.endsWith("-time"))){
continue
}
const index = Number(key.substring(prefix.length, key.length - "-time".length))
const time = Number(localStorage.getItem(key))
const freshness = new Date()
freshness.setTime(time)
freshnesses.set(index, freshness)
}
return freshnesses
}
constructor(layer: FilteredLayer,
handleFeatureSource: (src: FeatureSourceForLayer & Tiled, index: number) => void,
state: {
locationControl: UIEventSource<Loc>
leafletMap: any
}) {
const undefinedTiles = new Set<number>()
const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-"
// @ts-ignore
const indexes: number[] = Object.keys(localStorage)
.filter(key => {
return key.startsWith(prefix) && !key.endsWith("-time") && !key.endsWith("-format");
})
.map(key => {
return Number(key.substring(prefix.length));
})
.filter(i => !isNaN(i))
console.debug("Layer", layer.layerDef.id, "has following tiles in available in localstorage", indexes.map(i => Tiles.tile_from_index(i).join("/")).join(", "))
for (const index of indexes) {
const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-" + index;
const version = localStorage.getItem(prefix + "-format")
if (version === undefined || version !== SaveTileToLocalStorageActor.formatVersion) {
// Invalid version! Remove this tile from local storage
localStorage.removeItem(prefix)
localStorage.removeItem(prefix+"-time")
localStorage.removeItem(prefix+"-format")
undefinedTiles.add(index)
console.log("Dropped old format tile", prefix)
}
}
const zLevels = indexes.map(i => i % 100)
const indexesSet = new Set(indexes)
const maxZoom = Math.max(...zLevels)
const minZoom = Math.min(...zLevels)
const self = this;
const neededTiles = state.locationControl.map(
location => {
if (!layer.isDisplayed.data) {
// No need to download! - the layer is disabled
return undefined;
}
if (location.zoom < layer.layerDef.minzoom) {
// No need to download! - the layer is disabled
return undefined;
}
// Yup, this is cheating to just get the bounds here
const bounds = state.leafletMap.data?.getBounds()
if (bounds === undefined) {
// We'll retry later
return undefined
}
const needed = []
for (let z = minZoom; z <= maxZoom; z++) {
const tileRange = Tiles.TileRangeBetween(z, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const neededZ = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(z, x, y))
.filter(i => !self.loadedTiles.has(i) && !undefinedTiles.has(i) && indexesSet.has(i))
needed.push(...neededZ)
}
if (needed.length === 0) {
return undefined
}
return needed
}
, [layer.isDisplayed, state.leafletMap]).stabilized(50);
neededTiles.addCallbackAndRunD(neededIndexes => {
for (const neededIndex of neededIndexes) {
// We load the features from localStorage
try {
const key = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-" + neededIndex
const data = localStorage.getItem(key)
const features = JSON.parse(data)
const src = {
layer: layer,
features: new UIEventSource<{ feature: any; freshness: Date }[]>(features),
name: "FromLocalStorage(" + key + ")",
tileIndex: neededIndex,
bbox: BBox.fromTileIndex(neededIndex)
}
handleFeatureSource(src, neededIndex)
self.loadedTiles.set(neededIndex, src)
} catch (e) {
console.error("Could not load data tile from local storage due to", e)
undefinedTiles.add(neededIndex)
}
}
})
}
}

View file

@ -1,4 +1,5 @@
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
import {BBox} from "./BBox";
export class GeoOperations { export class GeoOperations {
@ -7,7 +8,7 @@ export class GeoOperations {
} }
/** /**
* Converts a GeoJSon feature to a point feature * Converts a GeoJson feature to a point GeoJson feature
* @param feature * @param feature
*/ */
static centerpoint(feature: any) { static centerpoint(feature: any) {
@ -185,8 +186,51 @@ export class GeoOperations {
return turf.length(feature) * 1000 return turf.length(feature) * 1000
} }
static buffer(feature: any, bufferSizeInMeter: number) {
return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: 'kilometers'
})
}
static bbox(feature: any) {
const [lon, lat, lon0, lat0] = turf.bbox(feature)
return {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
lon,
lat
],
[
lon0,
lat
],
[
lon0,
lat0
],
[
lon,
lat0
],
[
lon,
lat
],
]
}
}
}
/** /**
* Generates the closest point on a way from a given point * Generates the closest point on a way from a given point
*
* The properties object will contain three values:
// - `index`: closest point was found on nth line part,
// - `dist`: distance between pt and the closest point (in kilometer),
// `location`: distance along the line between start and the closest point.
* @param way The road on which you want to find a point * @param way The road on which you want to find a point
* @param point Point defined as [lon, lat] * @param point Point defined as [lon, lat]
*/ */
@ -334,77 +378,3 @@ export class GeoOperations {
} }
export class BBox {
readonly maxLat: number;
readonly maxLon: number;
readonly minLat: number;
readonly minLon: number;
constructor(coordinates) {
this.maxLat = Number.MIN_VALUE;
this.maxLon = Number.MIN_VALUE;
this.minLat = Number.MAX_VALUE;
this.minLon = Number.MAX_VALUE;
for (const coordinate of coordinates) {
this.maxLon = Math.max(this.maxLon, coordinate[0]);
this.maxLat = Math.max(this.maxLat, coordinate[1]);
this.minLon = Math.min(this.minLon, coordinate[0]);
this.minLat = Math.min(this.minLat, coordinate[1]);
}
this.check();
}
static fromLeafletBounds(bounds) {
return new BBox([[bounds.getWest(), bounds.getNorth()], [bounds.getEast(), bounds.getSouth()]])
}
static get(feature) {
if (feature.bbox?.overlapsWith === undefined) {
const turfBbox: number[] = turf.bbox(feature)
feature.bbox = new BBox([[turfBbox[0], turfBbox[1]], [turfBbox[2], turfBbox[3]]]);
}
return feature.bbox;
}
public overlapsWith(other: BBox) {
if (this.maxLon < other.minLon) {
return false;
}
if (this.maxLat < other.minLat) {
return false;
}
if (this.minLon > other.maxLon) {
return false;
}
return this.minLat <= other.maxLat;
}
public isContainedIn(other: BBox) {
if (this.maxLon > other.maxLon) {
return false;
}
if (this.maxLat > other.maxLat) {
return false;
}
if (this.minLon < other.minLon) {
return false;
}
if (this.minLat < other.minLat) {
return false
}
return true;
}
private check() {
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
console.log(this);
throw "BBOX has NAN";
}
}
}

View file

@ -1,9 +1,71 @@
import {Mapillary} from "./Mapillary"; import {Mapillary} from "./Mapillary";
import {Wikimedia} from "./Wikimedia"; import {WikimediaImageProvider} from "./WikimediaImageProvider";
import {Imgur} from "./Imgur"; import {Imgur} from "./Imgur";
import GenericImageProvider from "./GenericImageProvider";
import {UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import {WikidataImageProvider} from "./WikidataImageProvider";
/**
* A generic 'from the interwebz' image picker, without attribution
*/
export default class AllImageProviders { export default class AllImageProviders {
public static ImageAttributionSource = [Imgur.singleton, Mapillary.singleton, Wikimedia.singleton] public static ImageAttributionSource: ImageProvider[] = [
Imgur.singleton,
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
new GenericImageProvider([].concat(...Imgur.defaultValuePrefix, WikimediaImageProvider.commonsPrefix, ...Mapillary.valuePrefixes))]
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<string, UIEventSource<ProvidedImage[]>>()
public static LoadImagesFor(tags: UIEventSource<any>, imagePrefix?: string): UIEventSource<ProvidedImage[]> {
const id = tags.data.id
if (id === undefined) {
return undefined;
}
const cached = this._cache.get(tags.data.id)
if (cached !== undefined) {
return cached
}
const source = new UIEventSource([])
this._cache.set(id, source)
const allSources = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) {
let prefixes = imageProvider.defaultKeyPrefixes
if(imagePrefix !== undefined){
prefixes = [...prefixes]
if(prefixes.indexOf("image") >= 0){
prefixes.splice(prefixes.indexOf("image"), 1)
}
prefixes.push(imagePrefix)
}
const singleSource = imageProvider.GetRelevantUrls(tags, {
prefixes: prefixes
})
allSources.push(singleSource)
singleSource.addCallbackAndRunD(_ => {
const all : ProvidedImage[] = [].concat(...allSources.map(source => source.data))
const uniq = []
const seen = new Set<string>()
for (const img of all) {
if(seen.has(img.url)){
continue
}
seen.add(img.url)
uniq.push(img)
}
source.setData(uniq)
})
}
return source;
}
} }

View file

@ -0,0 +1,36 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
export default class GenericImageProvider extends ImageProvider {
public defaultKeyPrefixes: string[] = ["image"];
private readonly _valuePrefixBlacklist: string[];
public constructor(valuePrefixBlacklist: string[]) {
super();
this._valuePrefixBlacklist = valuePrefixBlacklist;
}
protected DownloadAttribution(url: string) {
return undefined
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (this._valuePrefixBlacklist.some(prefix => value.startsWith(prefix))) {
return []
}
return [Promise.resolve({
key: key,
url: value,
provider: this
})]
}
SourceIcon(backlinkSource?: string) {
return undefined;
}
}

View file

@ -1,30 +0,0 @@
import {UIEventSource} from "../UIEventSource";
import {LicenseInfo} from "./Wikimedia";
import BaseUIElement from "../../UI/BaseUIElement";
export default abstract class ImageAttributionSource {
private _cache = new Map<string, UIEventSource<LicenseInfo>>()
GetAttributionFor(url: string): UIEventSource<LicenseInfo> {
const cached = this._cache.get(url);
if (cached !== undefined) {
return cached;
}
const src = this.DownloadAttribution(url)
this._cache.set(url, src)
return src;
}
public abstract SourceIcon(backlinkSource?: string): BaseUIElement;
/*Converts a value to a URL. Can return null if not applicable*/
public PrepareUrl(value: string): string | UIEventSource<string>{
return value;
}
protected abstract DownloadAttribution(url: string): UIEventSource<LicenseInfo>;
}

View file

@ -0,0 +1,72 @@
import {UIEventSource} from "../UIEventSource";
import BaseUIElement from "../../UI/BaseUIElement";
import {LicenseInfo} from "./LicenseInfo";
export interface ProvidedImage {
url: string, key: string, provider: ImageProvider
}
export default abstract class ImageProvider {
public abstract readonly defaultKeyPrefixes : string[] = ["mapillary", "image"]
private _cache = new Map<string, UIEventSource<LicenseInfo>>()
GetAttributionFor(url: string): UIEventSource<LicenseInfo> {
const cached = this._cache.get(url);
if (cached !== undefined) {
return cached;
}
const src =UIEventSource.FromPromise(this.DownloadAttribution(url))
this._cache.set(url, src)
return src;
}
public abstract SourceIcon(backlinkSource?: string): BaseUIElement;
protected abstract DownloadAttribution(url: string): Promise<LicenseInfo>;
/**
* Given a properies object, maps it onto _all_ the available pictures for this imageProvider
*/
public GetRelevantUrls(allTags: UIEventSource<any>, options?: {
prefixes?: string[]
}):UIEventSource<ProvidedImage[]> {
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
if(prefixes === undefined){
throw "The image provider"+this.constructor.name+" doesn't define `defaultKeyPrefixes`"
}
const relevantUrls = new UIEventSource<{ url: string; key: string; provider: ImageProvider }[]>([])
const seenValues = new Set<string>()
allTags.addCallbackAndRunD(tags => {
for (const key in tags) {
if(!prefixes.some(prefix => key.startsWith(prefix))){
continue
}
const value = tags[key]
if(seenValues.has(value)){
continue
}
seenValues.add(value)
this.ExtractUrls(key, value).then(promises => {
for (const promise of promises ?? []) {
if(promise === undefined){
continue
}
promise.then(providedImage => {
if(providedImage === undefined){
return
}
relevantUrls.data.push(providedImage)
relevantUrls.ping()
})
}
})
}
})
return relevantUrls
}
public abstract ExtractUrls(key: string, value: string) : Promise<Promise<ProvidedImage>[]>;
}

View file

@ -1,11 +1,14 @@
// @ts-ignore
import $ from "jquery" import $ from "jquery"
import {LicenseInfo} from "./Wikimedia"; import ImageProvider, {ProvidedImage} from "./ImageProvider";
import ImageAttributionSource from "./ImageAttributionSource";
import {UIEventSource} from "../UIEventSource";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import {Utils} from "../../Utils";
import Constants from "../../Models/Constants";
import {LicenseInfo} from "./LicenseInfo";
export class Imgur extends ImageAttributionSource { export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public readonly defaultKeyPrefixes: string[] = ["image"];
public static readonly singleton = new Imgur(); public static readonly singleton = new Imgur();
@ -86,35 +89,18 @@ export class Imgur extends ImageAttributionSource {
return undefined; return undefined;
} }
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> { protected DownloadAttribution: (url: string) => Promise<LicenseInfo> = async (url: string) => {
const src = new UIEventSource<LicenseInfo>(undefined)
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]; const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
const apiUrl = 'https://api.imgur.com/3/image/' + hash; const apiUrl = 'https://api.imgur.com/3/image/' + hash;
const apiKey = '7070e7167f0a25a'; const response = await Utils.downloadJson(apiUrl, {Authorization: 'Client-ID ' + Constants.ImgurApiKey})
const settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'GET',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
};
// @ts-ignore
$.ajax(settings).done(function (response) {
const descr: string = response.data.description ?? ""; const descr: string = response.data.description ?? "";
const data: any = {}; const data: any = {};
for (const tag of descr.split("\n")) { for (const tag of descr.split("\n")) {
const kv = tag.split(":"); const kv = tag.split(":");
const k = kv[0]; const k = kv[0];
data[k] = kv[1].replace("\r", ""); data[k] = kv[1]?.replace("\r", "");
} }
@ -123,13 +109,18 @@ export class Imgur extends ImageAttributionSource {
licenseInfo.licenseShortName = data.license; licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author; licenseInfo.artist = data.author;
src.setData(licenseInfo) return licenseInfo
}
}).fail((reason) => { public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
console.log("Getting metadata from to IMGUR failed", reason) if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) {
}); return [Promise.resolve({
url: value,
return src; key: key,
provider: this
})]
}
return []
} }

View file

@ -0,0 +1,10 @@
export class LicenseInfo {
artist: string = "";
license: string = "";
licenseShortName: string = "";
usageTerms: string = "";
attributionRequired: boolean = false;
copyrighted: boolean = false;
credit: string = "";
description: string = "";
}

View file

@ -1,28 +1,29 @@
import {LicenseInfo} from "./Wikimedia"; import ImageProvider, {ProvidedImage} from "./ImageProvider";
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg"; import Svg from "../../Svg";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
import Constants from "../../Models/Constants";
import {fail} from "assert";
export class Mapillary extends ImageAttributionSource { export class Mapillary extends ImageProvider {
defaultKeyPrefixes = ["mapillary","image"]
public static readonly singleton = new Mapillary(); public static readonly singleton = new Mapillary();
private static readonly valuePrefix = "https://a.mapillary.com"
private static readonly v4_cached_urls = new Map<string, UIEventSource<string>>(); public static readonly valuePrefixes = [Mapillary.valuePrefix, "http://mapillary.com","https://mapillary.com"]
private static readonly client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2'
private static readonly client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
private constructor() { private constructor() {
super(); super();
} }
private static ExtractKeyFromURL(value: string): { private static ExtractKeyFromURL(value: string, failIfNoMath = false): {
key: string, key: string,
isApiv4?: boolean isApiv4?: boolean
} { } {
if (value.startsWith("https://a.mapillary.com")) { if (value.startsWith(Mapillary.valuePrefix)) {
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1) const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
return {key: key, isApiv4: !isNaN(Number(key))}; return {key: key, isApiv4: !isNaN(Number(key))};
} }
@ -32,7 +33,6 @@ export class Mapillary extends ImageAttributionSource {
} }
const mapview = value.match(/https?:\/\/www.mapillary.com\/map\/im\/(.*)/) const mapview = value.match(/https?:\/\/www.mapillary.com\/map\/im\/(.*)/)
console.log("Mapview matched ", value, mapview)
if (mapview !== null) { if (mapview !== null) {
const key = mapview[1] const key = mapview[1]
return {key: key, isApiv4: !isNaN(Number(key))}; return {key: key, isApiv4: !isNaN(Number(key))};
@ -49,6 +49,9 @@ export class Mapillary extends ImageAttributionSource {
return {key: matchApi[1]}; return {key: matchApi[1]};
} }
if(failIfNoMath){
return undefined;
}
return {key: value, isApiv4: !isNaN(Number(value))}; return {key: value, isApiv4: !isNaN(Number(value))};
} }
@ -57,30 +60,38 @@ export class Mapillary extends ImageAttributionSource {
return Svg.mapillary_svg(); return Svg.mapillary_svg();
} }
PrepareUrl(value: string): string | UIEventSource<string> { async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
const keyV = Mapillary.ExtractKeyFromURL(value) return [this.PrepareUrlAsync(key, value)]
}
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
const failIfNoMatch = key.indexOf("mapillary") < 0
const keyV = Mapillary.ExtractKeyFromURL(value, failIfNoMatch)
if(keyV === undefined){
return undefined;
}
if (!keyV.isApiv4) { if (!keyV.isApiv4) {
return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}` const url = `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Constants.mapillary_client_token_v3}`
return {
url: url,
provider: this,
key: key
}
} else { } else {
const key = keyV.key; const mapillaryId = keyV.key;
if(Mapillary.v4_cached_urls.has(key)){ const metadataUrl = 'https://graph.mapillary.com/' + mapillaryId + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
return Mapillary.v4_cached_urls.get(key) const response = await Utils.downloadJson(metadataUrl)
const url = <string> response["thumb_1024_url"];
return {
url: url,
provider: this,
key: key
} }
const metadataUrl ='https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Mapillary.client_token_v4;
const source = new UIEventSource<string>(undefined)
Mapillary.v4_cached_urls.set(key, source)
Utils.downloadJson(metadataUrl).then(
json => {
console.warn("Got response on mapillary image", json, json["thumb_1024_url"])
return source.setData(json["thumb_1024_url"]);
}
)
return source
} }
} }
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> { protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
const keyV = Mapillary.ExtractKeyFromURL(url) const keyV = Mapillary.ExtractKeyFromURL(url)
if (keyV.isApiv4) { if (keyV.isApiv4) {
@ -89,22 +100,19 @@ export class Mapillary extends ImageAttributionSource {
license.license = "CC BY-SA 4.0"; license.license = "CC BY-SA 4.0";
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; // license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true; license.attributionRequired = true;
return new UIEventSource<LicenseInfo>(license) return license
} }
const key = keyV.key const key = keyV.key
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined) const data = await Utils.downloadJson(metadataURL)
Utils.downloadJson(metadataURL).then(data => {
const license = new LicenseInfo(); const license = new LicenseInfo();
license.artist = data.properties?.username; license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0"; license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true; license.attributionRequired = true;
source.setData(license);
})
return source return license
} }
} }

View file

@ -0,0 +1,46 @@
import {Utils} from "../../Utils";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
import Wikidata from "../Web/Wikidata";
export class WikidataImageProvider extends ImageProvider {
public SourceIcon(backlinkSource?: string): BaseUIElement {
throw Svg.wikidata_svg();
}
public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"]
private constructor() {
super()
}
protected DownloadAttribution(url: string): Promise<any> {
throw new Error("Method not implemented; shouldn't be needed!");
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
const entity = await Wikidata.LoadWikidataEntryAsync(value)
if(entity === undefined){
return []
}
const allImages : Promise<ProvidedImage>[] = []
// P18 is the claim 'depicted in this image'
for (const img of Array.from(entity.claims.get("P18") ?? [])) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, img)
allImages.push(...promises)
}
const commons = entity.commons
if (commons !== undefined) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined , commons)
allImages.push(...promises)
}
return allImages
}
}

View file

@ -1,195 +0,0 @@
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {UIEventSource} from "../UIEventSource";
import Link from "../../UI/Base/Link";
import {Utils} from "../../Utils";
/**
* This module provides endpoints for wikipedia/wikimedia and others
*/
export class Wikimedia extends ImageAttributionSource {
public static readonly singleton = new Wikimedia();
private constructor() {
super();
}
static ImageNameToUrl(filename: string, width: number = 500, height: number = 200): string {
filename = encodeURIComponent(filename);
return "https://commons.wikimedia.org/wiki/Special:FilePath/" + filename + "?width=" + width + "&height=" + height;
}
static GetCategoryFiles(categoryName: string, handleCategory: ((ImagesInCategory: ImagesInCategory) => void),
alreadyLoaded = 0,
continueParameter: { k: string, param: string } = undefined) {
if (categoryName === undefined || categoryName === null || categoryName === "") {
return;
}
// @ts-ignore
if (!categoryName.startsWith("Category:")) {
categoryName = "Category:" + categoryName;
}
let url = "https://commons.wikimedia.org/w/api.php?" +
"action=query&list=categorymembers&format=json&" +
"&origin=*" +
"&cmtitle=" + encodeURIComponent(categoryName);
if (continueParameter !== undefined) {
url = url + "&" + continueParameter.k + "=" + continueParameter.param;
}
const self = this;
console.log("Loading a wikimedia category: ", url)
Utils.downloadJson(url).then((response) => {
let imageOverview = new ImagesInCategory();
let members = response.query?.categorymembers;
if (members === undefined) {
members = [];
}
for (const member of members) {
imageOverview.images.push(member.title);
}
console.log("Got images! ", imageOverview)
if (response.continue === undefined) {
handleCategory(imageOverview);
return;
}
if (alreadyLoaded > 10) {
console.log(`Recursive wikimedia category load stopped for ${categoryName} - got already enough images now (${alreadyLoaded})`)
handleCategory(imageOverview)
return;
}
self.GetCategoryFiles(categoryName,
(recursiveImages) => {
recursiveImages.images.push(...imageOverview.images);
handleCategory(recursiveImages);
},
alreadyLoaded + 10,
{k: "cmcontinue", param: response.continue.cmcontinue})
});
}
static GetWikiData(id: number, handleWikidata: ((Wikidata) => void)) {
const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json";
Utils.downloadJson(url).then(response => {
const entity = response.entities["Q" + id];
const commons = entity.sitelinks.commonswiki;
const wd = new Wikidata();
wd.commonsWiki = commons?.title;
// P18 is the claim 'depicted in this image'
const image = entity.claims.P18?.[0]?.mainsnak?.datavalue?.value;
if (image) {
wd.image = "File:" + image;
}
handleWikidata(wd);
});
}
private static ExtractFileName(url: string) {
if (!url.startsWith("http")) {
return url;
}
const path = new URL(url).pathname
return path.substring(path.lastIndexOf("/") + 1);
}
SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg()
.SetStyle("width:2em;height: 2em");
if (backlink === undefined) {
return img
}
return new Link(Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`, true)
}
PrepareUrl(value: string): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value;
}
return Wikimedia.ImageNameToUrl(value, 500, 400)
.replace(/'/g, '%27');
}
protected DownloadAttribution(filename: string): UIEventSource<LicenseInfo> {
const source = new UIEventSource<LicenseInfo>(undefined);
filename = Wikimedia.ExtractFileName(filename)
if (filename === "") {
return source;
}
const url = "https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
Utils.downloadJson(url).then(
data => {
const licenseInfo = new LicenseInfo();
const license = (data.query.pages[-1].imageinfo ?? [])[0]?.extmetadata;
if (license === undefined) {
console.error("This file has no usable metedata or license attached... Please fix the license info file yourself!")
source.setData(null)
return;
}
licenseInfo.artist = license.Artist?.value;
licenseInfo.license = license.License?.value;
licenseInfo.copyrighted = license.Copyrighted?.value;
licenseInfo.attributionRequired = license.AttributionRequired?.value;
licenseInfo.usageTerms = license.UsageTerms?.value;
licenseInfo.licenseShortName = license.LicenseShortName?.value;
licenseInfo.credit = license.Credit?.value;
licenseInfo.description = license.ImageDescription?.value;
source.setData(licenseInfo);
}
)
return source;
}
}
export class Wikidata {
commonsWiki: string;
image: string;
}
export class ImagesInCategory {
// Filenames of relevant images
images: string[] = [];
}
export class LicenseInfo {
artist: string = "";
license: string = "";
licenseShortName: string = "";
usageTerms: string = "";
attributionRequired: boolean = false;
copyrighted: boolean = false;
credit: string = "";
description: string = "";
}

View file

@ -0,0 +1,168 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import Link from "../../UI/Base/Link";
import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
/**
* This module provides endpoints for wikimedia and others
*/
export class WikimediaImageProvider extends ImageProvider {
private readonly commons_key = "wikimedia_commons"
public readonly defaultKeyPrefixes = [this.commons_key,"image"]
public static readonly singleton = new WikimediaImageProvider();
public static readonly commonsPrefix = "https://commons.wikimedia.org/wiki/"
private constructor() {
super();
}
/**
* Recursively walks a wikimedia commons category in order to search for (image) files
* Returns (a promise of) a list of URLS
* @param categoryName The name of the wikimedia category
* @param maxLoad: the maximum amount of images to return
* @param continueParameter: if the page indicates that more pages should be loaded, this uses a token to continue. Provided by wikimedia
*/
private static async GetImagesInCategory(categoryName: string,
maxLoad = 10,
continueParameter: string = undefined): Promise<string[]> {
if (categoryName === undefined || categoryName === null || categoryName === "") {
return [];
}
if (!categoryName.startsWith("Category:")) {
categoryName = "Category:" + categoryName;
}
let url = "https://commons.wikimedia.org/w/api.php?" +
"action=query&list=categorymembers&format=json&" +
"&origin=*" +
"&cmtitle=" + encodeURIComponent(categoryName);
if (continueParameter !== undefined) {
url = `${url}&cmcontinue=${continueParameter}`;
}
const response = await Utils.downloadJson(url)
const members = response.query?.categorymembers ?? [];
const imageOverview: string[] = members.map(member => member.title);
if (response.continue === undefined) {
// We are done crawling through the category - no continuation in sight
return imageOverview;
}
if (maxLoad - imageOverview.length <= 0) {
console.debug(`Recursive wikimedia category load stopped for ${categoryName}`)
return imageOverview;
}
// We do have a continue token - let's load the next page
const recursive = await this.GetImagesInCategory(categoryName, maxLoad - imageOverview.length, response.continue.cmcontinue)
imageOverview.push(...recursive)
return imageOverview
}
private static ExtractFileName(url: string) {
if (!url.startsWith("http")) {
return url;
}
const path = new URL(url).pathname
return path.substring(path.lastIndexOf("/") + 1);
}
SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg()
.SetStyle("width:2em;height: 2em");
if (backlink === undefined) {
return img
}
return new Link(Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`, true)
}
private PrepareUrl(value: string): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value;
}
return (`https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(value)}?width=500&height=400`)
}
protected async DownloadAttribution(filename: string): Promise<LicenseInfo> {
filename = WikimediaImageProvider.ExtractFileName(filename)
if (filename === "") {
return undefined;
}
const url = "https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
const data = await Utils.downloadJson(url)
const licenseInfo = new LicenseInfo();
const license = (data.query.pages[-1].imageinfo ?? [])[0]?.extmetadata;
if (license === undefined) {
console.error("This file has no usable metedata or license attached... Please fix the license info file yourself!")
return undefined;
}
licenseInfo.artist = license.Artist?.value;
licenseInfo.license = license.License?.value;
licenseInfo.copyrighted = license.Copyrighted?.value;
licenseInfo.attributionRequired = license.AttributionRequired?.value;
licenseInfo.usageTerms = license.UsageTerms?.value;
licenseInfo.licenseShortName = license.LicenseShortName?.value;
licenseInfo.credit = license.Credit?.value;
licenseInfo.description = license.ImageDescription?.value;
return licenseInfo;
}
private async UrlForImage(image: string): Promise<ProvidedImage> {
if (!image.startsWith("File:")) {
image = "File:" + image
}
return {url: this.PrepareUrl(image), key: undefined, provider: this}
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if(key !== undefined && key !== this.commons_key && !value.startsWith(WikimediaImageProvider.commonsPrefix)){
return []
}
if (value.startsWith(WikimediaImageProvider.commonsPrefix)) {
value = value.substring(WikimediaImageProvider.commonsPrefix.length)
} else if (value.startsWith("https://upload.wikimedia.org")) {
const result: ProvidedImage = {
key: undefined,
url: value,
provider: this
}
return [Promise.resolve(result)]
}
if (value.startsWith("Category:")) {
const urls = await WikimediaImageProvider.GetImagesInCategory(value)
return urls.map(image => this.UrlForImage(image))
}
if (value.startsWith("File:")) {
return [this.UrlForImage(value)]
}
if (value.startsWith("http")) {
// PRobably an error
return []
}
// We do a last effort and assume this is a file
return [this.UrlForImage("File:" + value)]
}
}

View file

@ -1,15 +1,10 @@
import SimpleMetaTagger from "./SimpleMetaTagger"; import SimpleMetaTagger from "./SimpleMetaTagger";
import {ExtraFunction} from "./ExtraFunction"; import {ExtraFuncParams, ExtraFunction} from "./ExtraFunction";
import {Relation} from "./Osm/ExtractRelations";
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import State from "../State";
interface Params {
featuresPerLayer: Map<string, any[]>,
memberships: Map<string, { role: string, relation: Relation }[]>
}
/** /**
* Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ... * Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ...
* *
@ -22,92 +17,98 @@ export default class MetaTagging {
private static readonly stopErrorOutputAt = 10; private static readonly stopErrorOutputAt = 10;
/** /**
* An actor which adds metatags on every feature in the given object * This method (re)calculates all metatags and calculated tags on every given object.
* The features are a list of geojson-features, with a "properties"-field and geometry * The given features should be part of the given layer
*/ */
static addMetatags(features: { feature: any; freshness: Date }[], public static addMetatags(features: { feature: any; freshness: Date }[],
allKnownFeatures: UIEventSource<{ feature: any; freshness: Date }[]>, params: ExtraFuncParams,
relations: Map<string, { role: string, relation: Relation }[]>, layer: LayerConfig,
layers: LayerConfig[], options?: {
includeDates = true) { includeDates?: true | boolean,
includeNonDates?: true | boolean
}) {
if (features === undefined || features.length === 0) { if (features === undefined || features.length === 0) {
return; return;
} }
const metatagsToApply: SimpleMetaTagger [] = []
for (const metatag of SimpleMetaTagger.metatags) { for (const metatag of SimpleMetaTagger.metatags) {
if (metatag.includesDates && !includeDates) { if (metatag.includesDates) {
// We do not add dated entries if (options.includeDates ?? true) {
continue; metatagsToApply.push(metatag)
} }
} else {
if (options.includeNonDates ?? true) {
metatagsToApply.push(metatag)
}
}
}
// The calculated functions - per layer - which add the new keys
const layerFuncs = this.createRetaggingFunc(layer)
for (let i = 0; i < features.length; i++) {
const ff = features[i];
const feature = ff.feature
const freshness = ff.freshness
let somethingChanged = false
for (const metatag of metatagsToApply) {
try { try {
metatag.addMetaTags(features); if(!metatag.keys.some(key => feature.properties[key] === undefined)){
// All keys are already defined, we probably already ran this one
continue
}
const newValueAdded = metatag.applyMetaTagsOnFeature(feature, freshness)
/* Note that the expression:
* `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)`
* Is WRONG
*
* IF something changed is `true` due to an earlier run, it will short-circuit and _not_ evaluate the right hand of the OR,
* thus not running an update!
*/
somethingChanged = newValueAdded || somethingChanged
} catch (e) { } catch (e) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e) console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e, e.stack)
} }
} }
// The functions - per layer - which add the new keys if(layerFuncs !== undefined){
const layerFuncs = new Map<string, ((params: Params, feature: any) => void)>();
for (const layer of layers) {
layerFuncs.set(layer.id, this.createRetaggingFunc(layer));
}
allKnownFeatures.addCallbackAndRunD(newFeatures => {
const featuresPerLayer = new Map<string, any[]>();
const allFeatures = Array.from(new Set(features.concat(newFeatures)))
for (const feature of allFeatures) {
const key = feature.feature._matching_layer_id;
if (!featuresPerLayer.has(key)) {
featuresPerLayer.set(key, [])
}
featuresPerLayer.get(key).push(feature.feature)
}
for (const feature of features) {
// @ts-ignore
const key = feature.feature._matching_layer_id;
const f = layerFuncs.get(key);
if (f === undefined) {
continue;
}
try { try {
f({featuresPerLayer: featuresPerLayer, memberships: relations}, feature.feature) layerFuncs(params, feature)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
somethingChanged = true
} }
if(somethingChanged){
}) State.state?.allElements?.getEventSourceById(feature.properties.id)?.ping()
} }
}
}
private static createRetaggingFunc(layer: LayerConfig): private static createRetaggingFunc(layer: LayerConfig):
((params: Params, feature: any) => void) { ((params: ExtraFuncParams, feature: any) => void) {
const calculatedTags: [string, string][] = layer.calculatedTags; const calculatedTags: [string, string][] = layer.calculatedTags;
if (calculatedTags === undefined) { if (calculatedTags === undefined) {
return undefined; return undefined;
} }
const functions: ((params: Params, feature: any) => void)[] = []; const functions: ((params: ExtraFuncParams, feature: any) => void)[] = [];
for (const entry of calculatedTags) { for (const entry of calculatedTags) {
const key = entry[0] const key = entry[0]
const code = entry[1]; const code = entry[1];
if (code === undefined) { if (code === undefined) {
continue; continue;
} }
const func = new Function("feat", "return " + code + ";"); const func = new Function("feat", "return " + code + ";");
try { try {
const f = (featuresPerLayer, feature: any) => { const f = (featuresPerLayer, feature: any) => {
try { try {
let result = func(feature); let result = func(feature);
@ -132,7 +133,7 @@ export default class MetaTagging {
feature.properties[key] = result; feature.properties[key] = result;
} catch (e) { } catch (e) {
if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) { if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) {
console.warn("Could not calculate a calculated tag defined by " + code + " due to " + e + ". This is code defined in the theme. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features", e) console.warn("Could not calculate a calculated tag defined by " + code + " due to " + e + ". This is code defined in the theme. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features", e,e.stack)
MetaTagging.errorPrintCount++; MetaTagging.errorPrintCount++;
if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) { if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) {
console.error("Got ", MetaTagging.stopErrorOutputAt, " errors calculating this metatagging - stopping output now") console.error("Got ", MetaTagging.stopErrorOutputAt, " errors calculating this metatagging - stopping output now")
@ -146,18 +147,18 @@ export default class MetaTagging {
console.error("Could not create a dynamic function: ", e) console.error("Could not create a dynamic function: ", e)
} }
} }
return (params: Params, feature) => { return (params: ExtraFuncParams, feature) => {
const tags = feature.properties const tags = feature.properties
if (tags === undefined) { if (tags === undefined) {
return; return;
} }
const relations = params.memberships?.get(feature.properties.id) ?? [] ExtraFunction.FullPatchFeature(params, feature);
ExtraFunction.FullPatchFeature(params.featuresPerLayer, relations, feature);
try { try {
for (const f of functions) { for (const f of functions) {
f(params, feature); f(params, feature);
} }
State.state?.allElements?.getEventSourceById(feature.properties.id)?.ping();
} catch (e) { } catch (e) {
console.error("While calculating a tag value: ", e) console.error("While calculating a tag value: ", e)
} }

View file

@ -1,21 +1,38 @@
import {OsmNode, OsmRelation, OsmWay} from "../OsmObject";
/**
* Represents a single change to an object
*/
export interface ChangeDescription { export interface ChangeDescription {
/**
* Identifier of the object
*/
type: "node" | "way" | "relation", type: "node" | "way" | "relation",
/** /**
* Negative for a new objects * Identifier of the object
* Negative for new objects
*/ */
id: number, id: number,
/*
v = "" or v = undefined to erase this tag /**
* All changes to tags
* v = "" or v = undefined to erase this tag
*/ */
tags?: { k: string, v: string }[], tags?: { k: string, v: string }[],
/**
* A change to the geometry:
* 1) Change of node location
* 2) Change of way geometry
* 3) Change of relation members (untested)
*/
changes?: { changes?: {
lat: number, lat: number,
lon: number lon: number
} | { } | {
// Coordinates are only used for rendering. They should be lon, lat // Coordinates are only used for rendering. They should be LAT, LON
locations: [number, number][] coordinates: [number, number][]
nodes: number[], nodes: number[],
} | { } | {
members: { type: "node" | "way" | "relation", ref: number, role: string }[] members: { type: "node" | "way" | "relation", ref: number, role: string }[]
@ -25,6 +42,26 @@ export interface ChangeDescription {
Set to delete the object Set to delete the object
*/ */
doDelete?: boolean doDelete?: boolean
}
export class ChangeDescriptionTools{
public static getGeojsonGeometry(change: ChangeDescription): any{
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
return n.asGeoJson().geometry
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
return w.asGeoJson().geometry
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
return r.asGeoJson().geometry
}
}
} }

View file

@ -37,7 +37,7 @@ export default class ChangeTagAction extends OsmChangeAction {
return {k: key.trim(), v: value.trim()}; return {k: key.trim(), v: value.trim()};
} }
CreateChangeDescriptions(changes: Changes): ChangeDescription [] { async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange) const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange)
const typeId = this._elementId.split("/") const typeId = this._elementId.split("/")
const type = typeId[0] const type = typeId[0]

View file

@ -24,10 +24,10 @@ export default class CreateNewNodeAction extends OsmChangeAction {
throw "Lat or lon are undefined!" throw "Lat or lon are undefined!"
} }
this._snapOnto = options?.snapOnto; this._snapOnto = options?.snapOnto;
this._reusePointDistance = options.reusePointWithinMeters ?? 1 this._reusePointDistance = options?.reusePointWithinMeters ?? 1
} }
CreateChangeDescriptions(changes: Changes): ChangeDescription[] { async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const id = changes.getNewID() const id = changes.getNewID()
const properties = { const properties = {
id: "node/" + id id: "node/" + id
@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
type: "way", type: "way",
id: this._snapOnto.id, id: this._snapOnto.id,
changes: { changes: {
locations: locations, coordinates: locations,
nodes: ids nodes: ids
} }
} }

View file

@ -62,7 +62,7 @@ export default class DeleteAction {
} }
State.state.osmConnection.changesetHandler.DeleteElement( State.state.osmConnection.changesetHandler.DeleteElement(
obj, obj,
State.state.layoutToUse.data, State.state.layoutToUse,
reason, reason,
State.state.allElements, State.state.allElements,
() => { () => {
@ -159,7 +159,7 @@ export default class DeleteAction {
canBeDeleted: false, canBeDeleted: false,
reason: t.notEnoughExperience reason: t.notEnoughExperience
}) })
return; return true; // unregister this caller!
} }
if (!useTheInternet) { if (!useTheInternet) {
@ -167,13 +167,14 @@ export default class DeleteAction {
} }
// All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations // All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations
OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => { OsmObject.DownloadReferencingRelations(id).then(rels => {
hasRelations.setData(rels.length > 0) hasRelations.setData(rels.length > 0)
}) })
OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => { OsmObject.DownloadReferencingWays(id).then(ways => {
hasWays.setData(ways.length > 0) hasWays.setData(ways.length > 0)
}) })
return true; // unregister to only run once
}) })

View file

@ -17,7 +17,7 @@ export default abstract class OsmChangeAction {
return this.CreateChangeDescriptions(changes) return this.CreateChangeDescriptions(changes)
} }
protected abstract CreateChangeDescriptions(changes: Changes): ChangeDescription[] protected abstract CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]>
} }

View file

@ -0,0 +1,144 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {OsmObject, OsmRelation, OsmWay} from "../OsmObject";
export interface RelationSplitInput {
relation: OsmRelation,
originalWayId: number,
allWayIdsInOrder: number[],
originalNodes: number[],
allWaysNodesInOrder: number[][]
}
/**
* When a way is split and this way is part of a relation, the relation should be updated too to have the new segment if relevant.
*/
export default class RelationSplitHandler extends OsmChangeAction {
private readonly _input: RelationSplitInput;
constructor(input: RelationSplitInput) {
super()
this._input = input;
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
return new InPlaceReplacedmentRTSH(this._input).CreateChangeDescriptions(changes)
}
}
/**
* A simple strategy to split relations:
* -> Download the way members just before and just after the original way
* -> Make sure they are still aligned
*
* Note that the feature might appear multiple times.
*/
export class InPlaceReplacedmentRTSH extends OsmChangeAction {
private readonly _input: RelationSplitInput;
constructor(input: RelationSplitInput) {
super();
this._input = input;
}
/**
* Returns which node should border the member at the given index
*/
private async targetNodeAt(i: number, first: boolean) {
const member = this._input.relation.members[i]
if (member === undefined) {
return undefined
}
if (member.type === "node") {
return member.ref
}
if (member.type === "way") {
const osmWay = <OsmWay>await OsmObject.DownloadObjectAsync("way/" + member.ref)
const nodes = osmWay.nodes
if (first) {
return nodes[0]
} else {
return nodes[nodes.length - 1]
}
}
if (member.type === "relation") {
return undefined
}
return undefined;
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const wayId = this._input.originalWayId
const relation = this._input.relation
const members = relation.members
const originalNodes = this._input.originalNodes;
const firstNode = originalNodes[0]
const lastNode = originalNodes[originalNodes.length - 1]
const newMembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = []
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member.type !== "way" || member.ref !== wayId) {
newMembers.push(member)
continue;
}
const nodeIdBefore = await this.targetNodeAt(i - 1, false)
const nodeIdAfter = await this.targetNodeAt(i + 1, true)
const firstNodeMatches = nodeIdBefore === undefined || nodeIdBefore === firstNode
const lastNodeMatches =nodeIdAfter === undefined || nodeIdAfter === lastNode
if (firstNodeMatches && lastNodeMatches) {
// We have a classic situation, forward situation
for (const wId of this._input.allWayIdsInOrder) {
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
continue;
}
const firstNodeMatchesRev = nodeIdBefore === undefined || nodeIdBefore === lastNode
const lastNodeMatchesRev =nodeIdAfter === undefined || nodeIdAfter === firstNode
if (firstNodeMatchesRev || lastNodeMatchesRev) {
// We (probably) have a reversed situation, backward situation
for (let i1 = this._input.allWayIdsInOrder.length - 1; i1 >= 0; i1--){
// Iterate BACKWARDS
const wId = this._input.allWayIdsInOrder[i1];
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
continue;
}
// Euhm, allright... Something weird is going on, but let's not care too much
// Lets pretend this is forward going
for (const wId of this._input.allWayIdsInOrder) {
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
}
return [{
id: relation.id,
type: "relation",
changes: {members: newMembers}
}];
}
}

View file

@ -1,20 +0,0 @@
/**
* The logic to handle relations after a way within
*/
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {OsmRelation} from "../OsmObject";
export default class RelationSplitlHandler extends OsmChangeAction {
constructor(partOf: OsmRelation[], newWayIds: number[], originalNodes: number[]) {
super()
}
CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
return [];
}
}

View file

@ -1,9 +1,9 @@
import {OsmRelation, OsmWay} from "../OsmObject"; import {OsmObject, OsmWay} from "../OsmObject";
import {Changes} from "../Changes"; import {Changes} from "../Changes";
import {GeoOperations} from "../../GeoOperations"; import {GeoOperations} from "../../GeoOperations";
import OsmChangeAction from "./OsmChangeAction"; import OsmChangeAction from "./OsmChangeAction";
import {ChangeDescription} from "./ChangeDescription"; import {ChangeDescription} from "./ChangeDescription";
import RelationSplitlHandler from "./RelationSplitlHandler"; import RelationSplitHandler from "./RelationSplitHandler";
interface SplitInfo { interface SplitInfo {
originalIndex?: number, // or negative for new elements originalIndex?: number, // or negative for new elements
@ -12,17 +12,18 @@ interface SplitInfo {
} }
export default class SplitAction extends OsmChangeAction { export default class SplitAction extends OsmChangeAction {
private readonly roadObject: any; private readonly wayId: string;
private readonly osmWay: OsmWay; private readonly _splitPointsCoordinates: [number, number] []// lon, lat
private _partOf: OsmRelation[];
private readonly _splitPoints: any[];
constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) { /**
*
* @param wayId
* @param splitPointCoordinates: lon, lat
*/
constructor(wayId: string, splitPointCoordinates: [number, number][]) {
super() super()
this.osmWay = osmWay; this.wayId = wayId;
this.roadObject = wayGeoJson; this._splitPointsCoordinates = splitPointCoordinates
this._partOf = partOf;
this._splitPoints = splitPoints;
} }
private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] {
@ -42,26 +43,16 @@ export default class SplitAction extends OsmChangeAction {
return wayParts.filter(wp => wp.length > 0) return wayParts.filter(wp => wp.length > 0)
} }
CreateChangeDescriptions(changes: Changes): ChangeDescription[] { async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const splitPoints = this._splitPoints const originalElement = <OsmWay> await OsmObject.DownloadObjectAsync(this.wayId)
// We mark the new split points with a new id const originalNodes = originalElement.nodes;
console.log(splitPoints)
for (const splitPoint of splitPoints) {
splitPoint.properties["_is_split_point"] = true
}
const self = this;
const partOf = this._partOf
const originalElement = this.osmWay
const originalNodes = this.osmWay.nodes;
// First, calculate splitpoints and remove points close to one another // First, calculate splitpoints and remove points close to one another
const splitInfo = self.CalculateSplitCoordinates(splitPoints) const splitInfo = this.CalculateSplitCoordinates(originalElement)
// Now we have a list with e.g. // Now we have a list with e.g.
// [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}]
// Lets change 'originalIndex' to the actual node id first: // Lets change 'originalIndex' to the actual node id first (or assign a new id if needed):
for (const element of splitInfo) { for (const element of splitInfo) {
if (element.originalIndex >= 0) { if (element.originalIndex >= 0) {
element.originalIndex = originalElement.nodes[element.originalIndex] element.originalIndex = originalElement.nodes[element.originalIndex]
@ -102,25 +93,30 @@ export default class SplitAction extends OsmChangeAction {
}) })
} }
const newWayIds: number[] = [] // The ids of all the ways (including the original)
const allWayIdsInOrder: number[] = []
const allWaysNodesInOrder: number[][] = []
// Lets create OsmWays based on them // Lets create OsmWays based on them
for (const wayPart of wayParts) { for (const wayPart of wayParts) {
let isOriginal = wayPart === longest let isOriginal = wayPart === longest
if (isOriginal) { if (isOriginal) {
// We change the actual element! // We change the actual element!
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({ changeDescription.push({
type: "way", type: "way",
id: originalElement.id, id: originalElement.id,
changes: { changes: {
locations: wayPart.map(p => p.lngLat), coordinates: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex) nodes: nodeIds
} }
}) })
allWayIdsInOrder.push(originalElement.id)
allWaysNodesInOrder.push(nodeIds)
} else { } else {
let id = changes.getNewID(); let id = changes.getNewID();
newWayIds.push(id) // Copy the tags from the original object onto the new
const kv = [] const kv = []
for (const k in originalElement.tags) { for (const k in originalElement.tags) {
if (!originalElement.tags.hasOwnProperty(k)) { if (!originalElement.tags.hasOwnProperty(k)) {
@ -131,22 +127,35 @@ export default class SplitAction extends OsmChangeAction {
} }
kv.push({k: k, v: originalElement.tags[k]}) kv.push({k: k, v: originalElement.tags[k]})
} }
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({ changeDescription.push({
type: "way", type: "way",
id: id, id: id,
tags: kv, tags: kv,
changes: { changes: {
locations: wayPart.map(p => p.lngLat), coordinates: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex) nodes: nodeIds
} }
}) })
}
allWayIdsInOrder.push(id)
allWaysNodesInOrder.push(nodeIds)
}
} }
// At last, we still have to check that we aren't part of a relation... // At last, we still have to check that we aren't part of a relation...
// At least, the order of the ways is identical, so we can keep the same roles // At least, the order of the ways is identical, so we can keep the same roles
changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).CreateChangeDescriptions(changes)) const relations = await OsmObject.DownloadReferencingRelations(this.wayId)
for (const relation of relations) {
const changDescrs = await new RelationSplitHandler({
relation: relation,
allWayIdsInOrder: allWayIdsInOrder,
originalNodes: originalNodes,
allWaysNodesInOrder: allWaysNodesInOrder,
originalWayId: originalElement.id
}).CreateChangeDescriptions(changes)
changeDescription.push(...changDescrs)
}
// And we have our objects! // And we have our objects!
// Time to upload // Time to upload
@ -158,75 +167,102 @@ export default class SplitAction extends OsmChangeAction {
* Calculates the actual points to split * Calculates the actual points to split
* If another point is closer then ~5m, we reuse that point * If another point is closer then ~5m, we reuse that point
*/ */
private CalculateSplitCoordinates( private CalculateSplitCoordinates(osmWay: OsmWay, toleranceInM = 5): SplitInfo[] {
splitPoints: any[], const wayGeoJson = osmWay.asGeoJson()
toleranceInM = 5): SplitInfo[] { // Should be [lon, lat][]
const originalPoints : [number, number][] = osmWay.coordinates.map(c => [c[1], c[0]])
const allPoints: {
// lon, lat
coordinates: [number, number],
isSplitPoint: boolean,
originalIndex?: number, // Original index
dist: number, // Distance from the nearest point on the original line
location: number // Distance from the start of the way
}[] = this._splitPointsCoordinates.map(c => {
// From the turf.js docs:
// The properties object will contain three values:
// - `index`: closest point was found on nth line part,
// - `dist`: distance between pt and the closest point,
// `location`: distance along the line between start and the closest point.
let projected = GeoOperations.nearestPoint(wayGeoJson, c)
// c is lon lat
return ({
coordinates: c,
isSplitPoint: true,
dist: projected.properties.dist,
location: projected.properties.location
});
})
const allPoints = [...splitPoints]; // We have a bunch of coordinates here: [ [lon, lon], [lat, lon], ...] ...
// We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... // We project them onto the line (which should yield pretty much the same point and add them to allPoints
const originalPoints: [number, number][] = this.roadObject.geometry.coordinates
// We project them onto the line (which should yield pretty much the same point
for (let i = 0; i < originalPoints.length; i++) { for (let i = 0; i < originalPoints.length; i++) {
let originalPoint = originalPoints[i]; let originalPoint = originalPoints[i];
let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) let projected = GeoOperations.nearestPoint(wayGeoJson, originalPoint)
projected.properties["_is_split_point"] = false allPoints.push({
projected.properties["_original_index"] = i coordinates: originalPoint,
allPoints.push(projected) isSplitPoint: false,
location: projected.properties.location,
originalIndex: i,
dist: projected.properties.dist
})
} }
// At this point, we have a list of both the split point and the old points, with some properties to discriminate between them // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them
// We sort this list so that the new points are at the same location // We sort this list so that the new points are at the same location
allPoints.sort((a, b) => a.properties.location - b.properties.location) allPoints.sort((a, b) => a.location - b.location)
// When this is done, we check that no now point is too close to an already existing point and no very small segments get created
/* for (let i = allPoints.length - 1; i > 0; i--) { for (let i = allPoints.length - 2; i >= 1; i--) {
// We 'merge' points with already existing nodes if they are close enough to avoid closeby elements
const point = allPoints[i]; // Note the loop bounds: we skip the first two and last two elements:
if (point.properties._original_index !== undefined) { // The first and last element are always part of the original way and should be kept
// This point is already in OSM - we have to keep it! // Furthermore, we run in reverse order as we'll delete elements on the go
continue;
const point = allPoints[i]
if (point.originalIndex !== undefined) {
// We keep the original points
continue
}
if (point.dist * 1000 >= toleranceInM) {
// No need to remove this one
continue
} }
if (i != allPoints.length - 1) { // At this point, 'dist' told us the point is pretty close to an already existing point.
const prevPoint = allPoints[i + 1] // Lets see which (already existing) point is closer and mark it as splitpoint
const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 const nextPoint = allPoints[i + 1]
if (diff <= toleranceInM) { const prevPoint = allPoints[i - 1]
// To close to the previous point! We delete this point... const distToNext = nextPoint.location - point.location
const distToPrev = prevPoint.location - point.location
let closest = nextPoint
if (distToNext > distToPrev) {
closest = prevPoint
}
// Ok, we have a closest point!
if(closest.originalIndex === 0 || closest.originalIndex === originalPoints.length){
// We can not split on the first or last points...
continue
}
closest.isSplitPoint = true;
allPoints.splice(i, 1) allPoints.splice(i, 1)
// ... and mark the previous point as a split point
prevPoint.properties._is_split_point = true
continue;
}
}
if (i > 0) {
const nextPoint = allPoints[i - 1]
const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000
if (diff <= toleranceInM) {
// To close to the next point! We delete this point...
allPoints.splice(i, 1)
// ... and mark the next point as a split point
nextPoint.properties._is_split_point = true
// noinspection UnnecessaryContinueJS
continue;
} }
}
// We don't have to remove this point...
}*/
const splitInfo: SplitInfo[] = [] const splitInfo: SplitInfo[] = []
let nextId = -1 let nextId = -1 // Note: these IDs are overwritten later on, no need to use a global counter here
for (const p of allPoints) { for (const p of allPoints) {
let index = p.properties._original_index let index = p.originalIndex
if (index === undefined) { if (index === undefined) {
index = nextId; index = nextId;
nextId--; nextId--;
} }
const splitInfoElement = { const splitInfoElement = {
originalIndex: index, originalIndex: index,
lngLat: p.geometry.coordinates, lngLat: p.coordinates,
doSplit: p.properties._is_split_point doSplit: p.isSplitPoint
} }
splitInfo.push(splitInfoElement) splitInfo.push(splitInfoElement)
} }

View file

@ -14,19 +14,27 @@ import {LocalStorageSource} from "../Web/LocalStorageSource";
export class Changes { export class Changes {
private static _nextId = -1; // Newly assigned ID's are negative private _nextId: number = -1; // Newly assigned ID's are negative
public readonly name = "Newly added features" public readonly name = "Newly added features"
/** /**
* All the newly created features as featureSource + all the modified features * All the newly created features as featureSource + all the modified features
*/ */
public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
public readonly pendingChanges = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", []) public readonly pendingChanges: UIEventSource<ChangeDescription[]> = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
private readonly isUploading = new UIEventSource(false); private readonly isUploading = new UIEventSource(false);
private readonly previouslyCreated: OsmObject[] = [] private readonly previouslyCreated: OsmObject[] = []
constructor() { constructor() {
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? [])
// Note: a changeset might be reused which was opened just before and might have already used some ids
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
} }
private static createChangesetFor(csId: string, private static createChangesetFor(csId: string,
@ -74,7 +82,7 @@ export class Changes {
* Returns a new ID and updates the value for the next ID * Returns a new ID and updates the value for the next ID
*/ */
public getNewID() { public getNewID() {
return Changes._nextId--; return this._nextId--;
} }
/** /**
@ -85,71 +93,69 @@ export class Changes {
if (this.pendingChanges.data.length === 0) { if (this.pendingChanges.data.length === 0) {
return; return;
} }
if (this.isUploading.data) { if (this.isUploading.data) {
console.log("Is already uploading... Abort") console.log("Is already uploading... Abort")
return; return;
} }
this.isUploading.setData(true) this.isUploading.setData(true)
this.flushChangesAsync(flushreason)
.then(_ => {
this.isUploading.setData(false)
console.log("Changes flushed!");
})
.catch(e => {
this.isUploading.setData(false)
console.error("Flushing changes failed due to", e);
})
}
private async flushChangesAsync(flushreason: string = undefined): Promise<void> {
const self = this;
try {
console.log("Beginning upload... " + flushreason ?? ""); console.log("Beginning upload... " + flushreason ?? "");
// At last, we build the changeset and upload // At last, we build the changeset and upload
const self = this;
const pending = self.pendingChanges.data; const pending = self.pendingChanges.data;
const neededIds = Changes.GetNeededIds(pending) const neededIds = Changes.GetNeededIds(pending)
console.log("Needed ids", neededIds) const osmObjects = await Promise.all(neededIds.map(id => OsmObject.DownloadObjectAsync(id)));
OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => {
console.log("Got the fresh objects!", osmObjects, "pending: ", pending) console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
try {
const changes: { const changes: {
newObjects: OsmObject[], newObjects: OsmObject[],
modifiedObjects: OsmObject[] modifiedObjects: OsmObject[]
deletedObjects: OsmObject[] deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects) } = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made") console.log("No changes to be made")
self.pendingChanges.setData([]) self.pendingChanges.setData([])
self.isUploading.setData(false) self.isUploading.setData(false)
return true; // Unregister the callback
} }
await State.state.osmConnection.UploadChangeset(
State.state.osmConnection.UploadChangeset( State.state.layoutToUse,
State.state.layoutToUse.data,
State.state.allElements, State.state.allElements,
(csId) => Changes.createChangesetFor(csId, changes), (csId) => Changes.createChangesetFor(csId, changes),
() => {
console.log("Upload successfull!")
self.pendingChanges.setData([]);
self.isUploading.setData(false)
},
() => {
console.log("Upload failed - trying again later")
return self.isUploading.setData(false);
} // Failed - mark to try again
) )
console.log("Upload successfull!")
this.pendingChanges.setData([]);
this.isUploading.setData(false)
} catch (e) { } catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e) console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
self.pendingChanges.setData([]) self.pendingChanges.setData([])
self.isUploading.setData(false) self.isUploading.setData(false)
} }
return true;
});
} }
public applyAction(action: OsmChangeAction) { public async applyAction(action: OsmChangeAction): Promise<void> {
const changes = action.Perform(this) const changes = await action.Perform(this)
console.log("Received changes:", changes) console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes); this.pendingChanges.data.push(...changes);
this.pendingChanges.ping(); this.pendingChanges.ping();
this.allChanges.data.push(...changes)
this.allChanges.ping()
} }
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
@ -303,4 +309,8 @@ export class Changes {
return result return result
} }
public registerIdRewrites(mappings: Map<string, string>): void {
}
} }

View file

@ -8,15 +8,23 @@ import Locale from "../../UI/i18n/Locale";
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants";
import {OsmObject} from "./OsmObject"; import {OsmObject} from "./OsmObject";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "./Changes";
export class ChangesetHandler { export class ChangesetHandler {
public readonly currentChangeset: UIEventSource<string>; public readonly currentChangeset: UIEventSource<string>;
private readonly allElements: ElementStorage;
private readonly changes: Changes;
private readonly _dryRun: boolean; private readonly _dryRun: boolean;
private readonly userDetails: UIEventSource<UserDetails>; private readonly userDetails: UIEventSource<UserDetails>;
private readonly auth: any; private readonly auth: any;
constructor(layoutName: string, dryRun: boolean, osmConnection: OsmConnection, auth) { constructor(layoutName: string, dryRun: boolean, osmConnection: OsmConnection,
allElements: ElementStorage,
changes: Changes,
auth) {
this.allElements = allElements;
this.changes = changes;
this._dryRun = dryRun; this._dryRun = dryRun;
this.userDetails = osmConnection.userDetails; this.userDetails = osmConnection.userDetails;
this.auth = auth; this.auth = auth;
@ -27,37 +35,57 @@ export class ChangesetHandler {
} }
} }
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage): void { private handleIdRewrite(node: any, type: string): [string, string] {
const nodes = response.getElementsByTagName("node");
// @ts-ignore
for (const node of nodes) {
const oldId = parseInt(node.attributes.old_id.value); const oldId = parseInt(node.attributes.old_id.value);
if (node.attributes.new_id === undefined) { if (node.attributes.new_id === undefined) {
// We just removed this point! // We just removed this point!
const element = allElements.getEventSourceById("node/" + oldId); const element =this. allElements.getEventSourceById("node/" + oldId);
element.data._deleted = "yes" element.data._deleted = "yes"
element.ping(); element.ping();
continue; return;
} }
const newId = parseInt(node.attributes.new_id.value); const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined && const result: [string, string] = [type + "/" + oldId, type + "/" + newId]
!isNaN(oldId) && !isNaN(newId)) { if (!(oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId))) {
return undefined;
}
if (oldId == newId) { if (oldId == newId) {
continue; return undefined;
} }
console.log("Rewriting id: ", oldId, "-->", newId); console.log("Rewriting id: ", type + "/" + oldId, "-->", type + "/" + newId);
const element = allElements.getEventSourceById("node/" + oldId); const element = this.allElements.getEventSourceById("node/" + oldId);
element.data.id = "node/" + newId; element.data.id = type + "/" + newId;
allElements.addElementById("node/" + newId, element); this.allElements.addElementById(type + "/" + newId, element);
this.allElements.ContainingFeatures.set(type + "/" + newId, this.allElements.ContainingFeatures.get(type + "/" + oldId))
element.ping(); element.ping();
return result;
} }
private parseUploadChangesetResponse(response: XMLDocument): void {
const nodes = response.getElementsByTagName("node");
const mappings = new Map<string, string>()
// @ts-ignore
for (const node of nodes) {
const mapping = this.handleIdRewrite(node, "node")
if (mapping !== undefined) {
mappings.set(mapping[0], mapping[1])
} }
} }
const ways = response.getElementsByTagName("way");
// @ts-ignore
for (const way of ways) {
const mapping = this.handleIdRewrite(way, "way")
if (mapping !== undefined) {
mappings.set(mapping[0], mapping[1])
}
}
this.changes.registerIdRewrites(mappings)
}
/** /**
* The full logic to upload a change to one or more elements. * The full logic to upload a change to one or more elements.
* *
@ -68,13 +96,9 @@ export class ChangesetHandler {
* If 'dryrun' is specified, the changeset XML will be printed to console instead of being uploaded * If 'dryrun' is specified, the changeset XML will be printed to console instead of being uploaded
* *
*/ */
public UploadChangeset( public async UploadChangeset(
layout: LayoutConfig, layout: LayoutConfig,
allElements: ElementStorage, generateChangeXML: (csid: string) => string): Promise<void> {
generateChangeXML: (csid: string) => string,
whenDone: (csId: string) => void,
onFail: () => void) {
if (this.userDetails.data.csCount == 0) { if (this.userDetails.data.csCount == 0) {
// The user became a contributor! // The user became a contributor!
this.userDetails.data.csCount = 1; this.userDetails.data.csCount = 1;
@ -84,46 +108,36 @@ export class ChangesetHandler {
if (this._dryRun) { if (this._dryRun) {
const changesetXML = generateChangeXML("123456"); const changesetXML = generateChangeXML("123456");
console.log(changesetXML); console.log(changesetXML);
whenDone("123456")
return; return;
} }
const self = this;
if (this.currentChangeset.data === undefined || this.currentChangeset.data === "") { if (this.currentChangeset.data === undefined || this.currentChangeset.data === "") {
// We have to open a new changeset // We have to open a new changeset
this.OpenChangeset(layout, (csId) => { try {
const csId = await this.OpenChangeset(layout)
this.currentChangeset.setData(csId); this.currentChangeset.setData(csId);
const changeset = generateChangeXML(csId); const changeset = generateChangeXML(csId);
console.log(changeset); console.log("Current changeset is:", changeset);
self.AddChange(csId, changeset, await this.AddChange(csId, changeset)
allElements, } catch (e) {
whenDone, console.error("Could not open/upload changeset due to ", e)
(e) => { this.currentChangeset.setData("")
console.error("UPLOADING FAILED!", e)
onFail()
} }
)
}, {
onFail: onFail
})
} else { } else {
// There still exists an open changeset (or at least we hope so) // There still exists an open changeset (or at least we hope so)
const csId = this.currentChangeset.data; const csId = this.currentChangeset.data;
self.AddChange( try {
await this.AddChange(
csId, csId,
generateChangeXML(csId), generateChangeXML(csId))
allElements, } catch (e) {
whenDone,
(e) => {
console.warn("Could not upload, changeset is probably closed: ", e); console.warn("Could not upload, changeset is probably closed: ", e);
// Mark the CS as closed... // Mark the CS as closed...
this.currentChangeset.setData(""); this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist // ... and try again. As the cs is closed, no recursive loop can exist
self.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail); await this.UploadChangeset(layout, generateChangeXML)
} }
)
} }
} }
@ -143,6 +157,13 @@ export class ChangesetHandler {
reason: string, reason: string,
allElements: ElementStorage, allElements: ElementStorage,
continuation: () => void) { continuation: () => void) {
return this.DeleteElementAsync(object, layout, reason, allElements).then(continuation)
}
public async DeleteElementAsync(object: OsmObject,
layout: LayoutConfig,
reason: string,
allElements: ElementStorage): Promise<void> {
function generateChangeXML(csId: string) { function generateChangeXML(csId: string) {
let [lat, lon] = object.centerpoint(); let [lat, lon] = object.centerpoint();
@ -151,9 +172,7 @@ export class ChangesetHandler {
changes += changes +=
`<delete><${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" /></delete>`; `<delete><${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" /></delete>`;
changes += "</osmChange>"; changes += "</osmChange>";
continuation()
return changes; return changes;
} }
@ -163,38 +182,28 @@ export class ChangesetHandler {
return; return;
} }
const self = this; const csId = await this.OpenChangeset(layout, {
this.OpenChangeset(layout, (csId: string) => {
// The cs is open - let us actually upload!
const changes = generateChangeXML(csId)
self.AddChange(csId, changes, allElements, (csId) => {
console.log("Successfully deleted ", object.id)
self.CloseChangeset(csId, continuation)
}, (csId) => {
alert("Deletion failed... Should not happend")
// FAILED
self.CloseChangeset(csId, continuation)
})
}, {
isDeletionCS: true, isDeletionCS: true,
deletionReason: reason deletionReason: reason
} })
) // The cs is open - let us actually upload!
const changes = generateChangeXML(csId)
await this.AddChange(csId, changes)
await this.CloseChangeset(csId)
} }
private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { private async CloseChangeset(changesetId: string = undefined): Promise<void> {
}) { const self = this
return new Promise<void>(function (resolve, reject) {
if (changesetId === undefined) { if (changesetId === undefined) {
changesetId = this.currentChangeset.data; changesetId = self.currentChangeset.data;
} }
if (changesetId === undefined) { if (changesetId === undefined) {
return; return;
} }
console.log("closing changeset", changesetId); console.log("closing changeset", changesetId);
this.currentChangeset.setData(""); self.currentChangeset.setData("");
this.auth.xhr({ self.auth.xhr({
method: 'PUT', method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close', path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) { }, function (err, response) {
@ -203,22 +212,20 @@ export class ChangesetHandler {
console.log("err", err); console.log("err", err);
} }
console.log("Closed changeset ", changesetId) console.log("Closed changeset ", changesetId)
resolve()
if (continuation !== undefined) {
continuation();
}
}); });
})
} }
private OpenChangeset( private OpenChangeset(
layout: LayoutConfig, layout: LayoutConfig,
continuation: (changesetId: string) => void,
options?: { options?: {
isDeletionCS?: boolean, isDeletionCS?: boolean,
deletionReason?: string, deletionReason?: string,
onFail?: () => void
} }
) { ): Promise<string> {
const self = this;
return new Promise<string>(function (resolve, reject) {
options = options ?? {} options = options ?? {}
options.isDeletionCS = options.isDeletionCS ?? false options.isDeletionCS = options.isDeletionCS ?? false
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : ""; const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
@ -248,7 +255,8 @@ export class ChangesetHandler {
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`) .map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
.join("\n") .join("\n")
this.auth.xhr({
self.auth.xhr({
method: 'PUT', method: 'PUT',
path: '/api/0.6/changeset/create', path: '/api/0.6/changeset/create',
options: {header: {'Content-Type': 'text/xml'}}, options: {header: {'Content-Type': 'text/xml'}},
@ -258,32 +266,23 @@ export class ChangesetHandler {
}, function (err, response) { }, function (err, response) {
if (response === undefined) { if (response === undefined) {
console.log("err", err); console.log("err", err);
if (options.onFail) { reject(err)
options.onFail()
}
return;
} else { } else {
continuation(response); resolve(response);
} }
}); });
})
} }
/** /**
* Upload a changesetXML * Upload a changesetXML
* @param changesetId
* @param changesetXML
* @param allElements
* @param continuation
* @param onFail
* @constructor
* @private
*/ */
private AddChange(changesetId: string, private AddChange(changesetId: string,
changesetXML: string, changesetXML: string): Promise<string> {
allElements: ElementStorage, const self = this;
continuation: ((changesetId: string) => void), return new Promise(function (resolve, reject) {
onFail: ((changesetId: string, reason: string) => void) = undefined) { self.auth.xhr({
this.auth.xhr({
method: 'POST', method: 'POST',
options: {header: {'Content-Type': 'text/xml'}}, options: {header: {'Content-Type': 'text/xml'}},
path: '/api/0.6/changeset/' + changesetId + '/upload', path: '/api/0.6/changeset/' + changesetId + '/upload',
@ -291,15 +290,14 @@ export class ChangesetHandler {
}, function (err, response) { }, function (err, response) {
if (response == null) { if (response == null) {
console.log("err", err); console.log("err", err);
if (onFail) { reject(err);
onFail(changesetId, err);
} }
return; self.parseUploadChangesetResponse(response);
}
ChangesetHandler.parseUploadChangesetResponse(response, allElements);
console.log("Uploaded changeset ", changesetId); console.log("Uploaded changeset ", changesetId);
continuation(changesetId); resolve(changesetId);
}); });
})
} }

View file

@ -11,7 +11,7 @@ export class Geocoding {
osm_type: string, osm_id: string osm_type: string, osm_id: string
}[]) => void), }[]) => void),
onFail: (() => void)) { onFail: (() => void)) {
const b = State.state.leafletMap.data.getBounds(); const b = State.state.currentBounds.data;
const url = Geocoding.host + "format=json&limit=1&viewbox=" + const url = Geocoding.host + "format=json&limit=1&viewbox=" +
`${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}` + `${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}` +
"&accept-language=nl&q=" + query; "&accept-language=nl&q=" + query;

View file

@ -9,6 +9,7 @@ import Img from "../../UI/Base/Img";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {OsmObject} from "./OsmObject"; import {OsmObject} from "./OsmObject";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "./Changes";
export default class UserDetails { export default class UserDetails {
@ -54,31 +55,33 @@ export class OsmConnection {
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []; private _onLoggedIn: ((userDetails: UserDetails) => void)[] = [];
private readonly _iframeMode: Boolean | boolean; private readonly _iframeMode: Boolean | boolean;
private readonly _singlePage: boolean; private readonly _singlePage: boolean;
private readonly _oauth_config: { public readonly _oauth_config: {
oauth_consumer_key: string, oauth_consumer_key: string,
oauth_secret: string, oauth_secret: string,
url: string url: string
}; };
private isChecking = false; private isChecking = false;
constructor(dryRun: boolean, constructor(options:{dryRun?: false | boolean,
fakeUser: boolean, fakeUser?: false | boolean,
oauth_token: UIEventSource<string>, allElements: ElementStorage,
changes: Changes,
oauth_token?: UIEventSource<string>,
// Used to keep multiple changesets open and to write to the correct changeset // Used to keep multiple changesets open and to write to the correct changeset
layoutName: string, layoutName: string,
singlePage: boolean = true, singlePage?: boolean,
osmConfiguration: "osm" | "osm-test" = 'osm' osmConfiguration?: "osm" | "osm-test" }
) { ) {
this.fakeUser = fakeUser; this.fakeUser = options.fakeUser ?? false;
this._singlePage = singlePage; this._singlePage = options.singlePage ?? true;
this._oauth_config = OsmConnection.oauth_configs[osmConfiguration] ?? OsmConnection.oauth_configs.osm; this._oauth_config = OsmConnection.oauth_configs[options.osmConfiguration ?? 'osm'] ?? OsmConnection.oauth_configs.osm;
console.debug("Using backend", this._oauth_config.url) console.debug("Using backend", this._oauth_config.url)
OsmObject.SetBackendUrl(this._oauth_config.url + "/") OsmObject.SetBackendUrl(this._oauth_config.url + "/")
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top; this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails"); this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails.data.dryRun = dryRun || fakeUser; this.userDetails.data.dryRun = (options.dryRun ?? false) || (options.fakeUser ?? false) ;
if (fakeUser) { if (options.fakeUser) {
const ud = this.userDetails.data; const ud = this.userDetails.data;
ud.csCount = 5678 ud.csCount = 5678
ud.loggedIn = true; ud.loggedIn = true;
@ -94,23 +97,24 @@ export class OsmConnection {
self.AttemptLogin() self.AttemptLogin()
} }
}); });
this._dryRun = dryRun; this.isLoggedIn.addCallbackAndRunD(li => console.log("User is logged in!", li))
this._dryRun = options.dryRun;
this.updateAuthObject(); this.updateAuthObject();
this.preferencesHandler = new OsmPreferences(this.auth, this); this.preferencesHandler = new OsmPreferences(this.auth, this);
this.changesetHandler = new ChangesetHandler(layoutName, dryRun, this, this.auth); this.changesetHandler = new ChangesetHandler(options.layoutName, options.dryRun, this, options.allElements, options.changes, this.auth);
if (oauth_token.data !== undefined) { if (options.oauth_token?.data !== undefined) {
console.log(oauth_token.data) console.log(options.oauth_token.data)
const self = this; const self = this;
this.auth.bootstrapToken(oauth_token.data, this.auth.bootstrapToken(options.oauth_token.data,
(x) => { (x) => {
console.log("Called back: ", x) console.log("Called back: ", x)
self.AttemptLogin(); self.AttemptLogin();
}, this.auth); }, this.auth);
oauth_token.setData(undefined); options. oauth_token.setData(undefined);
} }
if (this.auth.authenticated()) { if (this.auth.authenticated()) {
@ -123,10 +127,8 @@ export class OsmConnection {
public UploadChangeset( public UploadChangeset(
layout: LayoutConfig, layout: LayoutConfig,
allElements: ElementStorage, allElements: ElementStorage,
generateChangeXML: (csid: string) => string, generateChangeXML: (csid: string) => string): Promise<void> {
whenDone: (csId: string) => void, return this.changesetHandler.UploadChangeset(layout, generateChangeXML);
onFail: () => {}) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
} }
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> { public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {

View file

@ -1,6 +1,7 @@
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json"; import * as polygon_features from "../../assets/polygon-features.json";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {BBox} from "../BBox";
export abstract class OsmObject { export abstract class OsmObject {
@ -9,11 +10,12 @@ export abstract class OsmObject {
protected static backendURL = OsmObject.defaultBackend; protected static backendURL = OsmObject.defaultBackend;
private static polygonFeatures = OsmObject.constructPolygonFeatures() private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>(); private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>();
private static referencingRelationsCache = new Map<string, UIEventSource<OsmRelation[]>>();
private static historyCache = new Map<string, UIEventSource<OsmObject[]>>(); private static historyCache = new Map<string, UIEventSource<OsmObject[]>>();
type: string; type: string;
id: number; id: number;
/**
* The OSM tags as simple object
*/
tags: {} = {}; tags: {} = {};
version: number; version: number;
public changed: boolean = false; public changed: boolean = false;
@ -37,7 +39,7 @@ export abstract class OsmObject {
this.backendURL = url; this.backendURL = url;
} }
static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> { public static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
let src: UIEventSource<OsmObject>; let src: UIEventSource<OsmObject>;
if (OsmObject.objectCache.has(id)) { if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id) src = OsmObject.objectCache.get(id)
@ -47,80 +49,71 @@ export abstract class OsmObject {
return src; return src;
} }
} else { } else {
src = new UIEventSource<OsmObject>(undefined) src = UIEventSource.FromPromise(OsmObject.DownloadObjectAsync(id))
} }
OsmObject.objectCache.set(id, src);
return src;
}
static async DownloadObjectAsync(id: string): Promise<OsmObject> {
const splitted = id.split("/"); const splitted = id.split("/");
const type = splitted[0]; const type = splitted[0];
const idN = Number(splitted[1]); const idN = Number(splitted[1]);
if (idN < 0) { if (idN < 0) {
return; return undefined;
} }
OsmObject.objectCache.set(id, src); const full = !id.startsWith("way") ? "" : "/full";
const newContinuation = (element: OsmObject) => { const url = `${OsmObject.backendURL}api/0.6/${id}${full}`;
src.setData(element) const rawData = await Utils.downloadJson(url)
// A full query might contain more then just the requested object (e.g. nodes that are part of a way, where we only want the way)
const parsed = OsmObject.ParseObjects(rawData.elements);
// Lets fetch the object we need
for (const osmObject of parsed) {
if(osmObject.type !== type){
continue;
} }
if(osmObject.id !== idN){
continue
}
// Found the one!
return osmObject
}
throw "PANIC: requested object is not part of the response"
switch (type) {
case("node"):
new OsmNode(idN).Download(newContinuation);
break;
case("way"):
new OsmWay(idN).Download(newContinuation);
break;
case("relation"):
new OsmRelation(idN).Download(newContinuation);
break;
default:
throw "Invalid object type:" + type + id;
} }
return src;
}
/** /**
* Downloads the ways that are using this node. * Downloads the ways that are using this node.
* Beware: their geometry will be incomplete! * Beware: their geometry will be incomplete!
*/ */
public static DownloadReferencingWays(id: string): UIEventSource<OsmWay[]> { public static DownloadReferencingWays(id: string): Promise<OsmWay[]> {
if (OsmObject.referencingWaysCache.has(id)) { return Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`).then(
return OsmObject.referencingWaysCache.get(id); data => {
} return data.elements.map(wayInfo => {
const waysSrc = new UIEventSource<OsmWay[]>([])
OsmObject.referencingWaysCache.set(id, waysSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`)
.then(data => {
const ways = data.elements.map(wayInfo => {
const way = new OsmWay(wayInfo.id) const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo) way.LoadData(wayInfo)
return way return way
}) })
waysSrc.setData(ways) }
}) )
return waysSrc;
} }
/** /**
* Downloads the relations that are using this feature. * Downloads the relations that are using this feature.
* Beware: their geometry will be incomplete! * Beware: their geometry will be incomplete!
*/ */
public static DownloadReferencingRelations(id: string): UIEventSource<OsmRelation[]> { public static async DownloadReferencingRelations(id: string): Promise<OsmRelation[]> {
if (OsmObject.referencingRelationsCache.has(id)) { const data = await Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
return OsmObject.referencingRelationsCache.get(id); return data.elements.map(wayInfo => {
}
const relsSrc = new UIEventSource<OsmRelation[]>(undefined)
OsmObject.referencingRelationsCache.set(id, relsSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
.then(data => {
const rels = data.elements.map(wayInfo => {
const rel = new OsmRelation(wayInfo.id) const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo) rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo) rel.SaveExtraData(wayInfo)
return rel return rel
}) })
relsSrc.setData(rels)
})
return relsSrc;
} }
public static DownloadHistory(id: string): UIEventSource<OsmObject []> { public static DownloadHistory(id: string): UIEventSource<OsmObject []> {
@ -158,36 +151,12 @@ export abstract class OsmObject {
} }
// bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds) // bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds)
public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) { public static async LoadArea(bbox: BBox): Promise<OsmObject[]> {
const minlon = bounds[0][1] const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
const maxlon = bounds[1][1] const data = await Utils.downloadJson(url)
const minlat = bounds[1][0]
const maxlat = bounds[0][0];
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
Utils.downloadJson(url).then(data => {
const elements: any[] = data.elements; const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements) return OsmObject.ParseObjects(elements);
callback(objects);
})
} }
public static DownloadAll(neededIds, forceRefresh = true): UIEventSource<OsmObject[]> {
// local function which downloads all the objects one by one
// this is one big loop, running one download, then rerunning the entire function
const allSources: UIEventSource<OsmObject> [] = neededIds.map(id => OsmObject.DownloadObject(id, forceRefresh))
const allCompleted = new UIEventSource(undefined).map(_ => {
return !allSources.some(uiEventSource => uiEventSource.data === undefined)
}, allSources)
return allCompleted.map(completed => {
if (completed) {
return allSources.map(src => src.data)
}
return undefined
});
}
protected static isPolygon(tags: any): boolean { protected static isPolygon(tags: any): boolean {
for (const tagsKey in tags) { for (const tagsKey in tags) {
if (!tags.hasOwnProperty(tagsKey)) { if (!tags.hasOwnProperty(tagsKey)) {
@ -208,7 +177,6 @@ export abstract class OsmObject {
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 }>(); const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
for (const polygonFeature of polygon_features) { for (const polygonFeature of polygon_features) {
const key = polygonFeature.key; const key = polygonFeature.key;
@ -228,6 +196,7 @@ export abstract class OsmObject {
private static ParseObjects(elements: any[]): OsmObject[] { private static ParseObjects(elements: any[]): OsmObject[] {
const objects: OsmObject[] = []; const objects: OsmObject[] = [];
const allNodes: Map<number, OsmNode> = new Map<number, OsmNode>() const allNodes: Map<number, OsmNode> = new Map<number, OsmNode>()
for (const element of elements) { for (const element of elements) {
const type = element.type; const type = element.type;
const idN = element.id; const idN = element.id;
@ -249,6 +218,11 @@ export abstract class OsmObject {
osmObject.SaveExtraData(element, []) osmObject.SaveExtraData(element, [])
break; break;
} }
if (osmObject !== undefined && OsmObject.backendURL !== OsmObject.defaultBackend) {
osmObject.tags["_backend"] = OsmObject.backendURL
}
osmObject?.LoadData(element) osmObject?.LoadData(element)
objects.push(osmObject) objects.push(osmObject)
} }
@ -260,7 +234,7 @@ export abstract class OsmObject {
public abstract asGeoJson(): any; public abstract asGeoJson(): any;
abstract SaveExtraData(element: any, allElements: any[]); abstract SaveExtraData(element: any, allElements: OsmObject[]);
/** /**
* Generates the changeset-XML for tags * Generates the changeset-XML for tags
@ -283,42 +257,6 @@ export abstract class OsmObject {
return tags; return tags;
} }
Download(continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) {
const self = this;
const full = this.type !== "way" ? "" : "/full";
const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`;
Utils.downloadJson(url).then(data => {
const element = data.elements.pop();
let nodes = []
if (self.type === "way" && data.elements.length >= 0) {
nodes = OsmObject.ParseObjects(data.elements)
}
self.LoadData(element)
self.SaveExtraData(element, nodes);
const meta = {
"_last_edit:contributor": element.user,
"_last_edit:contributor:uid": element.uid,
"_last_edit:changeset": element.changeset,
"_last_edit:timestamp": new Date(element.timestamp),
"_version_number": element.version
}
if (OsmObject.backendURL !== OsmObject.defaultBackend) {
self.tags["_backend"] = OsmObject.backendURL
meta["_backend"] = OsmObject.backendURL;
}
continuation(self, meta);
}
);
return this;
}
abstract ChangesetXML(changesetId: string): string; abstract ChangesetXML(changesetId: string): string;
protected VersionXML() { protected VersionXML() {
@ -389,18 +327,10 @@ export class OsmNode extends OsmObject {
} }
} }
export interface OsmObjectMeta {
"_last_edit:contributor": string,
"_last_edit:contributor:uid": number,
"_last_edit:changeset": number,
"_last_edit:timestamp": Date,
"_version_number": number
}
export class OsmWay extends OsmObject { export class OsmWay extends OsmObject {
nodes: number[]; nodes: number[] = [];
// The coordinates of the way, [lat, lon][]
coordinates: [number, number][] = [] coordinates: [number, number][] = []
lat: number; lat: number;
lon: number; lon: number;
@ -436,6 +366,10 @@ export class OsmWay extends OsmObject {
nodeDict.set(node.id, node) nodeDict.set(node.id, node)
} }
if (element.nodes === undefined) {
console.log("PANIC")
}
for (const nodeId of element.nodes) { for (const nodeId of element.nodes) {
const node = nodeDict.get(nodeId) const node = nodeDict.get(nodeId)
if (node === undefined) { if (node === undefined) {
@ -455,12 +389,16 @@ export class OsmWay extends OsmObject {
} }
public asGeoJson() { public asGeoJson() {
let coordinates: ([number, number][] | [number, number][][]) = this.coordinates.map(c => [c[1], c[0]]);
if (this.isPolygon()) {
coordinates = [coordinates]
}
return { return {
"type": "Feature", "type": "Feature",
"properties": this.tags, "properties": this.tags,
"geometry": { "geometry": {
"type": this.isPolygon() ? "Polygon" : "LineString", "type": this.isPolygon() ? "Polygon" : "LineString",
"coordinates": this.coordinates.map(c => [c[1], c[0]]) "coordinates": coordinates
} }
} }
} }
@ -511,7 +449,7 @@ ${members}${tags} </relation>
this.members = element.members; this.members = element.members;
} }
asGeoJson() { asGeoJson(): any {
throw "Not Implemented" throw "Not Implemented"
} }
} }

View file

@ -145,14 +145,14 @@ export class OsmPreferences {
private SetPreference(k: string, v: string) { private SetPreference(k: string, v: string) {
if (!this.userDetails.data.loggedIn) { if (!this.userDetails.data.loggedIn) {
console.log(`Not saving preference ${k}: user not logged in`); console.debug(`Not saving preference ${k}: user not logged in`);
return; return;
} }
if (this.preferences.data[k] === v) { if (this.preferences.data[k] === v) {
return; return;
} }
console.log("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15)); console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15));
if (v === undefined || v === "") { if (v === undefined || v === "") {
this.auth.xhr({ this.auth.xhr({
@ -161,10 +161,10 @@ export class OsmPreferences {
options: {header: {'Content-Type': 'text/plain'}}, options: {header: {'Content-Type': 'text/plain'}},
}, function (error) { }, function (error) {
if (error) { if (error) {
console.log("Could not remove preference", error); console.warn("Could not remove preference", error);
return; return;
} }
console.log("Preference ", k, "removed!"); console.debug("Preference ", k, "removed!");
}); });
return; return;

View file

@ -1,9 +1,9 @@
import * as OsmToGeoJson from "osmtogeojson"; import * as OsmToGeoJson from "osmtogeojson";
import Bounds from "../../Models/Bounds";
import {TagsFilter} from "../Tags/TagsFilter"; import {TagsFilter} from "../Tags/TagsFilter";
import ExtractRelations from "./ExtractRelations"; import RelationsTracker from "./RelationsTracker";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {BBox} from "../BBox";
/** /**
* Interfaces overpass to get all the latest data * Interfaces overpass to get all the latest data
@ -11,46 +11,51 @@ import {UIEventSource} from "../UIEventSource";
export class Overpass { export class Overpass {
public static testUrl: string = null public static testUrl: string = null
private _filter: TagsFilter private _filter: TagsFilter
private readonly _interpreterUrl: UIEventSource<string>; private readonly _interpreterUrl: string;
private readonly _timeout: UIEventSource<number>; private readonly _timeout: UIEventSource<number>;
private readonly _extraScripts: string[]; private readonly _extraScripts: string[];
private _includeMeta: boolean; private _includeMeta: boolean;
private _relationTracker: RelationsTracker;
constructor(filter: TagsFilter, extraScripts: string[],
interpreterUrl: UIEventSource<string>, constructor(filter: TagsFilter,
extraScripts: string[],
interpreterUrl: string,
timeout: UIEventSource<number>, timeout: UIEventSource<number>,
relationTracker: RelationsTracker,
includeMeta = true) { includeMeta = true) {
this._timeout = timeout; this._timeout = timeout;
this._interpreterUrl = interpreterUrl; this._interpreterUrl = interpreterUrl;
this._filter = filter this._filter = filter
this._extraScripts = extraScripts; this._extraScripts = extraScripts;
this._includeMeta = includeMeta; this._includeMeta = includeMeta;
this._relationTracker = relationTracker
} }
queryGeoJson(bounds: Bounds, continuation: ((any, date: Date) => void), onFail: ((reason) => void)): void { public async queryGeoJson(bounds: BBox): Promise<[any, Date]> {
let query = this.buildQuery("[bbox:" + bounds.south + "," + bounds.west + "," + bounds.north + "," + bounds.east + "]") let query = this.buildQuery("[bbox:" + bounds.getSouth() + "," + bounds.getWest() + "," + bounds.getNorth() + "," + bounds.getEast() + "]")
if (Overpass.testUrl !== null) { if (Overpass.testUrl !== null) {
console.log("Using testing URL") console.log("Using testing URL")
query = Overpass.testUrl; query = Overpass.testUrl;
} }
Utils.downloadJson(query) const self = this;
.then(json => { const json = await Utils.downloadJson(query)
if (json.elements === [] && ((json.remarks ?? json.remark).indexOf("runtime error") >= 0)) {
console.log("Timeout or other runtime error"); if (json.elements.length === 0 && json.remark !== undefined) {
onFail("Runtime error (timeout)") console.warn("Timeout or other runtime error while querying overpass", json.remark);
return; throw `Runtime error (timeout or similar)${json.remark}`
}
if(json.elements.length === 0){
console.warn("No features for" ,json)
} }
self._relationTracker.RegisterRelations(json)
ExtractRelations.RegisterRelations(json)
// @ts-ignore // @ts-ignore
const geojson = OsmToGeoJson.default(json); const geojson = OsmToGeoJson.default(json);
const osmTime = new Date(json.osm3s.timestamp_osm_base); const osmTime = new Date(json.osm3s.timestamp_osm_base);
return [geojson, osmTime];
continuation(geojson, osmTime);
}).catch(onFail)
} }
buildQuery(bbox: string): string { buildQuery(bbox: string): string {
@ -64,6 +69,6 @@ export class Overpass {
} }
const query = const query =
`[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;` `[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
return `${this._interpreterUrl.data}?data=${encodeURIComponent(query)}` return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
} }
} }

View file

@ -1,4 +1,5 @@
import State from "../../State"; import State from "../../State";
import {UIEventSource} from "../UIEventSource";
export interface Relation { export interface Relation {
id: number, id: number,
@ -13,11 +14,15 @@ export interface Relation {
properties: any properties: any
} }
export default class ExtractRelations { export default class RelationsTracker {
public static RegisterRelations(overpassJson: any): void { public knownRelations = new UIEventSource<Map<string, { role: string; relation: Relation }[]>>(new Map(), "Relation memberships");
const memberships = ExtractRelations.BuildMembershipTable(ExtractRelations.GetRelationElements(overpassJson))
State.state.knownRelations.setData(memberships) constructor() {
}
public RegisterRelations(overpassJson: any): void {
this.UpdateMembershipTable(RelationsTracker.GetRelationElements(overpassJson))
} }
/** /**
@ -25,7 +30,7 @@ export default class ExtractRelations {
* @param overpassJson * @param overpassJson
* @constructor * @constructor
*/ */
public static GetRelationElements(overpassJson: any): Relation[] { private static GetRelationElements(overpassJson: any): Relation[] {
const relations = overpassJson.elements const relations = overpassJson.elements
.filter(element => element.type === "relation" && element.tags.type !== "multipolygon") .filter(element => element.type === "relation" && element.tags.type !== "multipolygon")
for (const relation of relations) { for (const relation of relations) {
@ -39,12 +44,11 @@ export default class ExtractRelations {
* @param relations * @param relations
* @constructor * @constructor
*/ */
public static BuildMembershipTable(relations: Relation[]): Map<string, { role: string, relation: Relation }[]> { private UpdateMembershipTable(relations: Relation[]): void {
const memberships = new Map<string, { role: string, relation: Relation }[]>() const memberships = this.knownRelations.data
let changed = false;
for (const relation of relations) { for (const relation of relations) {
for (const member of relation.members) { for (const member of relation.members) {
const role = { const role = {
role: member.role, role: member.role,
relation: relation relation: relation
@ -53,11 +57,21 @@ export default class ExtractRelations {
if (!memberships.has(key)) { if (!memberships.has(key)) {
memberships.set(key, []) memberships.set(key, [])
} }
memberships.get(key).push(role) const knownRelations = memberships.get(key)
}
} const alreadyExists = knownRelations.some(knownRole => {
return knownRole.role === role.role && knownRole.relation === role.relation
})
if (!alreadyExists) {
knownRelations.push(role)
changed = true;
}
}
}
if (changed) {
this.knownRelations.ping()
}
return memberships
} }
} }

View file

@ -1,8 +1,5 @@
import {GeoOperations} from "./GeoOperations"; import {GeoOperations} from "./GeoOperations";
import State from "../State"; import State from "../State";
import {And} from "./Tags/And";
import {Tag} from "./Tags/Tag";
import {Or} from "./Tags/Or";
import {Utils} from "../Utils"; import {Utils} from "../Utils";
import opening_hours from "opening_hours"; import opening_hours from "opening_hours";
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine";
@ -20,7 +17,7 @@ const cardinalDirections = {
export default class SimpleMetaTagger { export default class SimpleMetaTagger {
static coder: any; public static coder: any;
public static readonly objectMetaInfo = new SimpleMetaTagger( public static readonly objectMetaInfo = new SimpleMetaTagger(
{ {
keys: ["_last_edit:contributor", keys: ["_last_edit:contributor",
@ -30,7 +27,7 @@ export default class SimpleMetaTagger {
"_version_number"], "_version_number"],
doc: "Information about the last edit of this object." doc: "Information about the last edit of this object."
}, },
(feature) => {/*Note: also handled by 'UpdateTagsFromOsmAPI'*/ (feature) => {/*Note: also called by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties; const tgs = feature.properties;
@ -47,6 +44,7 @@ export default class SimpleMetaTagger {
move("changeset", "_last_edit:changeset") move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp") move("timestamp", "_last_edit:timestamp")
move("version", "_version_number") move("version", "_version_number")
return true;
} }
) )
private static latlon = new SimpleMetaTagger({ private static latlon = new SimpleMetaTagger({
@ -61,6 +59,7 @@ export default class SimpleMetaTagger {
feature.properties["_lon"] = "" + lon; feature.properties["_lon"] = "" + lon;
feature._lon = lon; // This is dirty, I know feature._lon = lon; // This is dirty, I know
feature._lat = lat; feature._lat = lat;
return true;
}) })
); );
private static surfaceArea = new SimpleMetaTagger( private static surfaceArea = new SimpleMetaTagger(
@ -73,6 +72,7 @@ export default class SimpleMetaTagger {
feature.properties["_surface"] = "" + sqMeters; feature.properties["_surface"] = "" + sqMeters;
feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10; feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10;
feature.area = sqMeters; feature.area = sqMeters;
return true;
}) })
); );
@ -83,18 +83,33 @@ export default class SimpleMetaTagger {
}, },
(feature => { (feature => {
const units = Utils.NoNull([].concat(State.state?.layoutToUse?.data?.layers?.map(layer => layer.units ?? []))); const units = Utils.NoNull([].concat(...State.state?.layoutToUse?.layers?.map(layer => layer.units ?? [])));
if (units.length == 0) {
return;
}
let rewritten = false; let rewritten = false;
for (const key in feature.properties) { for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) { if (!feature.properties.hasOwnProperty(key)) {
continue; continue;
} }
for (const unit of units) { for (const unit of units) {
if (unit === undefined) {
continue
}
if (unit.appliesToKeys === undefined) {
console.error("The unit ", unit, "has no appliesToKey defined")
continue
}
if (!unit.appliesToKeys.has(key)) { if (!unit.appliesToKeys.has(key)) {
continue; continue;
} }
const value = feature.properties[key] const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value) const denom = unit.findDenomination(value)
if (denom === undefined) {
// no valid value found
break;
}
const [, denomination] = denom;
let canonical = denomination?.canonicalValue(value) ?? undefined; let canonical = denomination?.canonicalValue(value) ?? undefined;
if (canonical === value) { if (canonical === value) {
break; break;
@ -110,9 +125,7 @@ export default class SimpleMetaTagger {
} }
} }
if (rewritten) { return rewritten
State.state.allElements.getEventSourceById(feature.id).ping();
}
}) })
) )
@ -127,21 +140,21 @@ export default class SimpleMetaTagger {
const km = Math.floor(l / 1000) const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100) const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km + "." + kmRest feature.properties["_length:km"] = "" + km + "." + kmRest
return true;
}) })
) )
private static country = new SimpleMetaTagger( private static country = new SimpleMetaTagger(
{ {
keys: ["_country"], keys: ["_country"],
doc: "The country code of the property (with latlon2country)" doc: "The country code of the property (with latlon2country)",
includesDates: false
}, },
feature => { ((feature, _) => {
let centerPoint: any = GeoOperations.centerpoint(feature); let centerPoint: any = GeoOperations.centerpoint(feature);
const lat = centerPoint.geometry.coordinates[1]; const lat = centerPoint.geometry.coordinates[1];
const lon = centerPoint.geometry.coordinates[0]; const lon = centerPoint.geometry.coordinates[0];
SimpleMetaTagger.GetCountryCodeFor(lon, lat, (countries) => { SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, (countries: string[]) => {
try { try {
const oldCountry = feature.properties["_country"]; const oldCountry = feature.properties["_country"];
feature.properties["_country"] = countries[0].trim().toLowerCase(); feature.properties["_country"] = countries[0].trim().toLowerCase();
@ -149,12 +162,12 @@ export default class SimpleMetaTagger {
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping(); tagsSource.ping();
} }
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
} }
}); })
} return false;
})
) )
private static isOpen = new SimpleMetaTagger( private static isOpen = new SimpleMetaTagger(
{ {
@ -166,7 +179,7 @@ export default class SimpleMetaTagger {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache // We are running from console, thus probably creating a cache
// isOpen is irrelevant // isOpen is irrelevant
return return false
} }
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
@ -191,7 +204,7 @@ export default class SimpleMetaTagger {
if (oldNextChange > (new Date()).getTime() && if (oldNextChange > (new Date()).getTime() &&
tags["_isOpen:oldvalue"] === tags["opening_hours"]) { tags["_isOpen:oldvalue"] === tags["opening_hours"]) {
// Already calculated and should not yet be triggered // Already calculated and should not yet be triggered
return; return false;
} }
tags["_isOpen"] = oh.getState() ? "yes" : "no"; tags["_isOpen"] = oh.getState() ? "yes" : "no";
@ -219,6 +232,7 @@ export default class SimpleMetaTagger {
} }
} }
updateTags(); updateTags();
return true;
} catch (e) { } catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e); console.warn("Error while parsing opening hours of ", tags.id, e);
tags["_isOpen"] = "parse_error"; tags["_isOpen"] = "parse_error";
@ -236,11 +250,11 @@ export default class SimpleMetaTagger {
const tags = feature.properties; const tags = feature.properties;
const direction = tags["camera:direction"] ?? tags["direction"]; const direction = tags["camera:direction"] ?? tags["direction"];
if (direction === undefined) { if (direction === undefined) {
return; return false;
} }
const n = cardinalDirections[direction] ?? Number(direction); const n = cardinalDirections[direction] ?? Number(direction);
if (isNaN(n)) { if (isNaN(n)) {
return; return false;
} }
// The % operator has range (-360, 360). We apply a trick to get [0, 360). // The % operator has range (-360, 360). We apply a trick to get [0, 360).
@ -248,16 +262,17 @@ export default class SimpleMetaTagger {
tags["_direction:numerical"] = normalized; tags["_direction:numerical"] = normalized;
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"; tags["_direction:leftright"] = normalized <= 180 ? "right" : "left";
return true;
}) })
) )
private static currentTime = new SimpleMetaTagger( private static currentTime = new SimpleMetaTagger(
{ {
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"], keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely", doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely",
includesDates: true includesDates: true
}, },
(feature, _, freshness) => { (feature, freshness) => {
const now = new Date(); const now = new Date();
if (typeof freshness === "string") { if (typeof freshness === "string") {
@ -276,7 +291,7 @@ export default class SimpleMetaTagger {
feature.properties["_now:datetime"] = datetime(now); feature.properties["_now:datetime"] = datetime(now);
feature.properties["_loaded:date"] = date(freshness); feature.properties["_loaded:date"] = date(freshness);
feature.properties["_loaded:datetime"] = datetime(freshness); feature.properties["_loaded:datetime"] = datetime(freshness);
return true;
} }
) )
public static metatags = [ public static metatags = [
@ -294,12 +309,18 @@ export default class SimpleMetaTagger {
public readonly keys: string[]; public readonly keys: string[];
public readonly doc: string; public readonly doc: string;
public readonly includesDates: boolean public readonly includesDates: boolean
private readonly _f: (feature: any, index: number, freshness: Date) => void; public readonly applyMetaTagsOnFeature: (feature: any, freshness: Date) => boolean;
constructor(docs: { keys: string[], doc: string, includesDates?: boolean }, f: ((feature: any, index: number, freshness: Date) => void)) { /***
* A function that adds some extra data to a feature
* @param docs: what does this extra data do?
* @param f: apply the changes. Returns true if something changed
*/
constructor(docs: { keys: string[], doc: string, includesDates?: boolean },
f: ((feature: any, freshness: Date) => boolean)) {
this.keys = docs.keys; this.keys = docs.keys;
this.doc = docs.doc; this.doc = docs.doc;
this._f = f; this.applyMetaTagsOnFeature = f;
this.includesDates = docs.includesDates ?? false; this.includesDates = docs.includesDates ?? false;
for (const key of docs.keys) { for (const key of docs.keys) {
if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) { if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) {
@ -308,11 +329,7 @@ export default class SimpleMetaTagger {
} }
} }
static GetCountryCodeFor(lon: number, lat: number, callback: (country: string) => void) { public static HelpText(): BaseUIElement {
SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, callback)
}
static HelpText(): BaseUIElement {
const subElements: (string | BaseUIElement)[] = [ const subElements: (string | BaseUIElement)[] = [
new Combine([ new Combine([
new Title("Metatags", 1), new Title("Metatags", 1),
@ -335,12 +352,4 @@ export default class SimpleMetaTagger {
return new Combine(subElements).SetClass("flex-col") return new Combine(subElements).SetClass("flex-col")
} }
addMetaTags(features: { feature: any, freshness: Date }[]) {
for (let i = 0; i < features.length; i++) {
let feature = features[i];
this._f(feature.feature, i, feature.freshness);
}
}
} }

View file

@ -19,6 +19,9 @@ export class RegexTag extends TagsFilter {
if (fromTag === undefined) { if (fromTag === undefined) {
return; return;
} }
if(typeof fromTag === "number"){
fromTag = "" + fromTag;
}
if (typeof possibleRegex === "string") { if (typeof possibleRegex === "string") {
return fromTag === possibleRegex; return fromTag === possibleRegex;
} }

View file

@ -1,4 +1,5 @@
import {Utils} from "../Utils"; import {Utils} from "../Utils";
import * as Events from "events";
export class UIEventSource<T> { export class UIEventSource<T> {
@ -32,14 +33,14 @@ export class UIEventSource<T> {
return []; return [];
} }
public static flatten<X>(source: UIEventSource<UIEventSource<X>>, possibleSources: UIEventSource<any>[]): UIEventSource<X> { public static flatten<X>(source: UIEventSource<UIEventSource<X>>, possibleSources?: UIEventSource<any>[]): UIEventSource<X> {
const sink = new UIEventSource<X>(source.data?.data); const sink = new UIEventSource<X>(source.data?.data);
source.addCallback((latestData) => { source.addCallback((latestData) => {
sink.setData(latestData?.data); sink.setData(latestData?.data);
}); });
for (const possibleSource of possibleSources) { for (const possibleSource of possibleSources ?? []) {
possibleSource?.addCallback(() => { possibleSource?.addCallback(() => {
sink.setData(source.data?.data); sink.setData(source.data?.data);
}) })
@ -60,7 +61,76 @@ export class UIEventSource<T> {
run(); run();
return source; return source;
}
/**
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
* If the promise fails, the value will stay undefined
* @param promise
* @constructor
*/
public static FromPromise<T>(promise: Promise<T>): UIEventSource<T> {
const src = new UIEventSource<T>(undefined)
promise?.then(d => src.setData(d))
promise?.catch(err => console.warn("Promise failed:", err))
return src
}
/**
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
* If the promise fails, the value will stay undefined
* @param promise
* @constructor
*/
public static FromPromiseWithErr<T>(promise: Promise<T>): UIEventSource<{ success: T } | { error: any }> {
const src = new UIEventSource<{ success: T } | { error: any }>(undefined)
promise?.then(d => src.setData({success: d}))
promise?.catch(err => src.setData({error: err}))
return src
}
/**
* Given a UIEVentSource with a list, returns a new UIEventSource which is only updated if the _contents_ of the list are different.
* E.g.
* const src = new UIEventSource([1,2,3])
* const stable = UIEventSource.ListStabilized(src)
* src.addCallback(_ => console.log("src pinged"))
* stable.addCallback(_ => console.log("stable pinged))
* src.setDate([...src.data])
*
* This will only trigger 'src pinged'
*
* @param src
* @constructor
*/
public static ListStabilized<T>(src: UIEventSource<T[]>): UIEventSource<T[]> {
const stable = new UIEventSource<T[]>(src.data)
src.addCallback(list => {
if (list === undefined) {
stable.setData(undefined)
return;
}
const oldList = stable.data
if (oldList === list) {
return;
}
if (oldList.length !== list.length) {
stable.setData(list);
return;
}
for (let i = 0; i < list.length; i++) {
if (oldList[i] !== list[i]) {
stable.setData(list);
return;
}
}
// No actual changes, so we don't do anything
return;
})
return stable
} }
/** /**
@ -81,9 +151,12 @@ export class UIEventSource<T> {
return this; return this;
} }
public addCallbackAndRun(callback: ((latestData: T) => void)): UIEventSource<T> { public addCallbackAndRun(callback: ((latestData: T) => (boolean | void | any))): UIEventSource<T> {
callback(this.data); const doDeleteCallback = callback(this.data);
return this.addCallback(callback); if (doDeleteCallback !== true) {
this.addCallback(callback);
}
return this;
} }
public setData(t: T): UIEventSource<T> { public setData(t: T): UIEventSource<T> {
@ -114,6 +187,30 @@ export class UIEventSource<T> {
} }
} }
/**
* Monadic bind function
*/
public bind<X>(f: ((t: T) => UIEventSource<X>)): UIEventSource<X> {
const mapped = this.map(f)
const sink = new UIEventSource<X>(undefined)
const seenEventSources = new Set<UIEventSource<X>>();
mapped.addCallbackAndRun(newEventSource => {
if (newEventSource === undefined) {
sink.setData(undefined)
} else if (!seenEventSources.has(newEventSource)) {
seenEventSources.add(newEventSource)
newEventSource.addCallbackAndRun(resultData => {
if (mapped.data === newEventSource) {
sink.setData(resultData);
}
})
}
})
return sink;
}
/** /**
* Monoidal map: * Monoidal map:
* Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)' * Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)'
@ -188,6 +285,14 @@ export class UIEventSource<T> {
} }
}) })
} }
addCallbackD(callback: (data: T) => void) {
this.addCallback(data => {
if (data !== undefined && data !== null) {
return callback(data)
}
})
}
} }
export class UIEventSourceTools { export class UIEventSourceTools {

View file

@ -9,7 +9,7 @@ export default class Hash {
public static hash: UIEventSource<string> = Hash.Get(); public static hash: UIEventSource<string> = Hash.Get();
/** /**
* Gets the current string, including the pound sign * Gets the current string, including the pound sign if there is any
* @constructor * @constructor
*/ */
public static Current(): string { public static Current(): string {

View file

@ -127,7 +127,6 @@ export class QueryParameters {
parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data)) parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data))
} }
// Don't pollute the history every time a parameter changes // Don't pollute the history every time a parameter changes
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current()); history.replaceState(null, "", "?" + parts.join("&") + Hash.Current());
} }

116
Logic/Web/Wikidata.ts Normal file
View file

@ -0,0 +1,116 @@
import {Utils} from "../../Utils";
import {UIEventSource} from "../UIEventSource";
export interface WikidataResponse {
id: string,
labels: Map<string, string>,
descriptions: Map<string, string>,
claims: Map<string, Set<string>>,
wikisites: Map<string, string>
commons: string
}
/**
* Utility functions around wikidata
*/
export default class Wikidata {
private static ParseResponse(entity: any): WikidataResponse {
const labels = new Map<string, string>()
for (const labelName in entity.labels) {
// The labelname is the language code
labels.set(labelName, entity.labels[labelName].value)
}
const descr = new Map<string, string>()
for (const labelName in entity.descriptions) {
// The labelname is the language code
descr.set(labelName, entity.descriptions[labelName].value)
}
const sitelinks = new Map<string, string>();
for (const labelName in entity.sitelinks) {
// labelName is `${language}wiki`
const language = labelName.substring(0, labelName.length - 4)
const title = entity.sitelinks[labelName].title
sitelinks.set(language, title)
}
const commons = sitelinks.get("commons")
sitelinks.delete("commons")
const claims = new Map<string, Set<string>>();
for (const claimId of entity.claims) {
const claimsList: any[] = entity.claims[claimId]
const values = new Set<string>()
for (const claim of claimsList) {
const value = claim.mainsnak.datavalue.value;
values.add(value)
}
claims.set(claimId, values);
}
return {
claims: claims,
descriptions: descr,
id: entity.id,
labels: labels,
wikisites: sitelinks,
commons: commons
}
}
private static readonly _cache = new Map<number, UIEventSource<{success: WikidataResponse} | {error: any}>>()
public static LoadWikidataEntry(value: string | number): UIEventSource<{success: WikidataResponse} | {error: any}> {
const key = this.ExtractKey(value)
const cached = Wikidata._cache.get(key)
if(cached !== undefined){
return cached
}
const src = UIEventSource.FromPromiseWithErr(Wikidata.LoadWikidataEntryAsync(key))
Wikidata._cache.set(key, src)
return src;
}
private static ExtractKey(value: string | number) : number{
if (typeof value === "number") {
return value
}
const wikidataUrl = "https://www.wikidata.org/wiki/"
if (value.startsWith(wikidataUrl)) {
value = value.substring(wikidataUrl.length)
}
if (value.startsWith("http")) {
// Probably some random link in the image field - we skip it
return undefined
}
if (value.startsWith("Q")) {
value = value.substring(1)
}
const n = Number(value)
if(isNaN(n)){
return undefined
}
return n;
}
/**
* Loads a wikidata page
* @returns the entity of the given value
*/
public static async LoadWikidataEntryAsync(value: string | number): Promise<WikidataResponse> {
const id = Wikidata.ExtractKey(value)
if(id === undefined){
console.warn("Could not extract a wikidata entry from", value)
return undefined;
}
console.log("Requesting wikidata with id", id)
const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json";
const response = await Utils.downloadJson(url)
return Wikidata.ParseResponse(response.entities["Q" + id])
}
}

75
Logic/Web/Wikipedia.ts Normal file
View file

@ -0,0 +1,75 @@
/**
* Some usefull utility functions around the wikipedia API
*/
import {Utils} from "../../Utils";
import {UIEventSource} from "../UIEventSource";
import Wikidata from "./Wikidata";
export default class Wikipedia {
/**
* When getting a wikipedia page data result, some elements (e.g. navigation, infoboxes, ...) should be removed if 'removeInfoBoxes' is set.
* We do this based on the classes. This set contains a blacklist of the classes to remove
* @private
*/
private static readonly classesToRemove = [
"shortdescription",
"sidebar",
"infobox","infobox_v2",
"noprint",
"ambox",
"mw-editsection",
"mw-selflink",
"hatnote" // Often redirects
]
private static readonly _cache = new Map<string, UIEventSource<{ success: string } | { error: any }>>()
public static GetArticle(options: {
pageName: string,
language?: "en" | string}): UIEventSource<{ success: string } | { error: any }>{
const key = (options.language ?? "en")+":"+options.pageName
const cached = Wikipedia._cache.get(key)
if(cached !== undefined){
return cached
}
const v = UIEventSource.FromPromiseWithErr(Wikipedia.GetArticleAsync(options))
Wikipedia._cache.set(key, v)
return v;
}
public static async GetArticleAsync(options: {
pageName: string,
language?: "en" | string
}): Promise<string> {
const language = options.language ?? "en"
const url = `https://${language}.wikipedia.org/w/api.php?action=parse&format=json&origin=*&prop=text&page=` + options.pageName
const response = await Utils.downloadJson(url)
const html = response["parse"]["text"]["*"];
const div = document.createElement("div")
div.innerHTML = html
const content = Array.from(div.children)[0]
for (const forbiddenClass of Wikipedia.classesToRemove) {
const toRemove = content.getElementsByClassName(forbiddenClass)
for (const toRemoveElement of Array.from(toRemove)) {
toRemoveElement.parentElement?.removeChild(toRemoveElement)
}
}
const links = Array.from(content.getElementsByTagName("a"))
// Rewrite relative links to absolute links + open them in a new tab
links.filter(link => link.getAttribute("href")?.startsWith("/") ?? false).
forEach(link => {
link.target = '_blank'
// note: link.getAttribute("href") gets the textual value, link.href is the rewritten version which'll contain the host for relative paths
link.href = `https://${language}.wikipedia.org${link.getAttribute("href")}`;
})
return content.innerHTML
}
}

View file

@ -1,6 +0,0 @@
export default interface Bounds {
north: number,
east: number,
south: number,
west: number
}

View file

@ -2,7 +2,22 @@ import {Utils} from "../Utils";
export default class Constants { export default class Constants {
public static vNumber = "0.9.14"; public static vNumber = "0.10.1-rc5";
public static ImgurApiKey = '7070e7167f0a25a'
public static readonly mapillary_client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2'
public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
public static defaultOverpassUrls = [
// The official instance, 10000 queries per day per project allowed
"https://overpass-api.de/api/interpreter",
// 'Fair usage'
"https://overpass.kumi.systems/api/interpreter",
// Offline: "https://overpass.nchc.org.tw/api/interpreter",
"https://overpass.openstreetmap.ru/cgi/interpreter",
// Doesn't support nwr "https://overpass.openstreetmap.fr/api/interpreter"
]
// The user journey states thresholds when a new feature gets unlocked // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {
@ -26,12 +41,6 @@ export default class Constants {
*/ */
static updateTimeoutSec: number = 30; static updateTimeoutSec: number = 30;
/**
* If zoom >= useOsmApiAt, then the OSM api will be used directly.
* If undefined, use overpass exclusively
*/
static useOsmApiAt = undefined;
private static isRetina(): boolean { private static isRetina(): boolean {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return; return;

Some files were not shown because too many files have changed in this diff Show more