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
uses: actions/setup-node@v1.2.0
with:
node-version: '15'
node-version: '16'
env:
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
uses: actions/setup-node@v1.2.0
with:
node-version: '15'
node-version: '16'
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
@ -23,9 +23,6 @@ jobs:
- name: create generated dir
run: mkdir ./assets/generated
- name: create stub themes
run: "echo '{\"layers\": [], \"themes\": []}' > ./assets/generated/known_layers_and_themes.json"
- name: 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 * as known_themes from "../assets/generated/known_layers_and_themes.json"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
export class AllKnownLayouts {
@ -8,6 +9,26 @@ export class AllKnownLayouts {
public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts();
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[] {
const keys = ["personal", "cyclofix", "hailhydrant", "bookcases", "toilets", "aed"]
const list = []

View file

@ -1,91 +1,115 @@
Metatags
Metatags
==========
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
overview of the available metatags.
The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an 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
box in the popup for features which shows all the properties of the object
**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
Metatags calculated by MapComplete
Metatags calculated by MapComplete
------------------------------------
The following values are always calculated, by default, by MapComplete and are available automatically on all elements
in every theme
The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme
### _lat, _lon
### _lat, _lon
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
### _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
### _length, _length:km
### 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`)
### _country
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
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
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')
### _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
### _width:needed, _width:needed:no_pedestrians, _width:difference
### _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
### _now:date, _now:datetime, _loaded:date, _loaded:_datetime
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 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
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
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
Information about the last edit of this object.
Calculating tags with Javascript
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
default (e.g. `lat`, `lon`, `_country`), as detailed above.
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.
It is also possible to calculate your own tags - but this requires some javascript knowledge.
Before proceeding, some warnings:
- 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
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.
To enable this feature, add a field `calculatedTags` in the layer object, e.g.:
- 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 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.
To enable this feature, add a field `calculatedTags` in the layer object, e.g.:
````
@ -101,56 +125,58 @@ 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:
- `area` contains the surface area (in square meters) of the object
- `lat` and `lon` contain the latitude and longitude
Some advanced functions are available on **feat** as well:
The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:
- distanceTo
- overlapWith
- closest
- memberships
- score
### distanceTo
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
- `area` contains the surface area (in square meters) of the object
- `lat` and `lon` contain the latitude and longitude
0. longitude
1. latitude
### overlapWith
Some advanced functions are available on **feat** as well:
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
- distanceTo
- overlapWith
- closest
- memberships
- score
### distanceTo
0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
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
### closest
0. longitude
1. latitude
### overlapWith
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.
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.
0. list of features
For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`
### memberships
0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
### closest
Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.
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.
For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
0. list of features
### memberships
### score
Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.
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 example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
For
example: `_comfort_score=feat.score('https://raw.githubusercontent.com/pietervdvn/AspectedRouting/master/Examples/bicycle/aspects/bicycle.comfort.json')`
0. path Generated from SimpleMetaTagger, ExtraFunction
### score
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 example: `_comfort_score=feat.score('https://raw.githubusercontent.com/pietervdvn/AspectedRouting/master/Examples/bicycle/aspects/bicycle.comfort.json')`
0. path
Generated from SimpleMetaTagger, ExtraFunction

View file

@ -26,14 +26,17 @@ Devcontainer (see more details later).
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.
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/
3. Run `npm run init` and generate some additional dependencies and generated files. Note that it'll install the
dependencies too
4. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename`
0. On iOS, install `wget` (`brew install wget`)
0. Run `npm run init` which …
- runs `npm install`
- 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 (
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`
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
--------------------------------
@ -125,4 +133,3 @@ Overview of package.json-scripts
- `deploy:staging`,`deploy:pietervdvn`, `deploy:production`: deploy the latest code on various locations
- `lint`: get depressed by the amount of warnings
- `clean`: remove some generated files which are annoying in the repo

View file

@ -3,6 +3,41 @@ Release Notes
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
-----

View file

@ -1,7 +1,6 @@
# Available types for text fields
The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to
activate them
The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them
## string
@ -21,8 +20,7 @@ A geographical direction, in degrees. 0° is north, 90° is east, ... Will retur
## length
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"]
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"]
## wikidata

View file

@ -1,110 +1,92 @@
### Special tag renderings
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
### Special tag renderings
### all_tags
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
### all_tags
Prints all key-value pairs of the object - used for debugging
Prints all key-value pairs of the object - used for debugging
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:
Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)
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)
name | default | description
------ | --------- | -------------
image key/prefix | image | The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc...
image key/prefix | image | The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc...
smart search | true | Also include images given via 'Wikidata', 'wikimedia_commons' and 'mapillary
#### Example usage
#### Example usage
`{image_carousel(image,true)}`
### image_upload
{image_carousel(image,true)}
### 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
name | default | description
------ | --------- | -------------
image-key | image | Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)
#### Example usage
#### Example usage
`{image_upload(image)}`
### minimap
{image_upload(image)}
### 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
name | default | description
------ | --------- | -------------
zoomlevel | 18 | The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close
idKey | id | (Matches all resting arguments) This argument should be the key of a property of the feature. The corresponding value is interpreted as either the id or the a list of ID's. The features with these ID's will be shown on this minimap.
#### Example usage
#### Example usage
`{minimap()}`, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`
### reviews
`{minimap()}`
, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`
### reviews
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
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
name | default | description
------ | --------- | -------------
subjectKey | name | The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>
fallback | undefined | The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value
#### Example usage
#### Example usage
`{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
### opening_hours_table
<b>{reviews()}<b> for a vanilla review, <b>{reviews(name, play_forest)}</b> to review a play forest. If a name is known,
the name will be used as identifier, otherwise 'play_forest' is used
### opening_hours_table
Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag '
opening_hours'.
Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag 'opening_hours'.
name | default | description
------ | --------- | -------------
key | opening_hours | The tagkey from which the table is constructed.
#### Example usage
#### Example usage
`{opening_hours_table(opening_hours)}`
### live
{opening_hours_table(opening_hours)}
### 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
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)}
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)}
name | default | description
------ | --------- | -------------
Url | undefined | The URL to load
Shorthands | undefined | A list of shorthands, of the format 'shorthandname:path.path.path'. separated by ;
path | undefined | The path (or shorthand) that should be returned
#### Example usage
#### Example usage
{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)}
### histogram
{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)}
### 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.
name | default | description
------ | --------- | -------------
@ -112,31 +94,50 @@ key | undefined | The key to be read and to generate a histogram from
title | | The text to put above the given values column
countHeader | | The text to put above the counts
colors* | undefined | (Matches all resting arguments - optional) Matches a regex onto a color value, e.g. `3[a-zA-Z+-]*:#33cc33`
#### Example usage
#### Example usage
`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram
### share_link
`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram
### share_link
Creates a link that (attempts to) open the native 'share'-screen
Creates a link that (attempts to) open the native 'share'-screen
name | default | description
------ | --------- | -------------
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
### canonical
{share_link()} to share the current page, {share_link(<some_url>)} to share the given url
### canonical
Converts a short, canonical value into the long, translated text
Converts a short, canonical value into the long, translated text
name | default | description
------ | --------- | -------------
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)
### import_button
{canonical(length)} will give 42 metre (in french) Generated from UI/SpecialVisualisations.ts
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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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"
},
{
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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')",
"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",
"description": "Layer 'Charging stations' shows and asks freeform values for key 'network' (in the MapComplete.osm.be theme 'Charging stations')"
@ -412,4 +793,4 @@
"value": "yes"
}
]
}
}

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')",
"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",
"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",
"description": "The MapComplete theme Waste Basket has a layer Waste Basket showing features with this tag",
"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 `key!~^value$`
If `key` is not present or empty, this will match too.
### 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

View file

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

View file

@ -1,3 +1,4 @@
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.
For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`, the
URL-parameters are stated in the part between the `?` and the `#`. There are multiple, all separated by `&`, namely:
For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`,
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 `lon` is `4.3` in this instance
@ -19,175 +20,169 @@ URL-parameters are stated in the part between the `?` and the `#`. There are mul
Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case.
download-control-toggle
download-control-toggle
-------------------------
Whether or not the download panel is shown The default value is _false_
Whether or not the download panel is shown The default value is _false_
filter-toggle
filter-toggle
---------------
Whether or not the filter view is shown The default value is _false_
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 =
more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
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_
z
z
---
The initial/current zoom level The default value is _0_
The initial/current zoom level The default value is _0_
lat
lat
-----
The initial/current latitude The default value is _0_
The initial/current latitude The default value is _0_
lon
lon
-----
The initial/current longitude of the app The default value is _0_
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 editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
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_
fs-search
fs-search
-----------
Disables/Enables the search bar The default value is _true_
Disables/Enables the search bar The default value is _true_
fs-background
fs-background
---------------
Disables/Enables the background layer control The default value is _true_
Disables/Enables the background layer control The default value is _true_
fs-filter
fs-filter
-----------
Disables/Enables the filter The default value is _true_
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
value is _true_
Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
fs-welcome-message
fs-welcome-message
--------------------
Disables/enables the help menu or welcome message The default value is _true_
Disables/enables the help menu or welcome message The default value is _true_
fs-iframe
fs-iframe
-----------
Disables/Enables the iframe-popup The default value is _false_
Disables/Enables the iframe-popup The default value is _false_
fs-more-quests
fs-more-quests
----------------
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
fs-share-screen
fs-share-screen
-----------------
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
fs-geolocation
fs-geolocation
----------------
Disables/Enables the geolocation button The default value is _true_
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
fs-all-questions
------------------
Always show all questions The default value is _false_
Always show all questions The default value is _false_
fs-export
fs-export
-----------
Enable the export as GeoJSON and CSV button The default value is _false_
Enable the export as GeoJSON and CSV button The default value is _false_
fs-pdf
fs-pdf
--------
Enable the PDF download button The default value is _false_
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
console instead of actually uploaded to osm.org The default value is _false_
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_
debug
debug
-------
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
fake-user
fake-user
-----------
If true, 'dryrun' mode is activated and a fake user account is loaded The default value is _false_
If true, 'dryrun' mode is activated and a fake user account is loaded The default value is _false_
backend
backend
---------
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default
value is _osm_
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
overpassUrl
overpassUrl
-------------
Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter The default value
is _https://overpass-api.de/api/interpreter_
Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter The default value is _https://overpass-api.de/api/interpreter_
overpassTimeout
overpassTimeout
-----------------
Set a different timeout (in seconds) for queries in overpass The default value is _30_
Set a different timeout (in seconds) for queries in overpass The default value is _30_
custom-css
custom-css
------------
If specified, the custom css from the given link will be loaded additionaly The default value is __
If specified, the custom css from the given link will be loaded additionaly The default value is __
background
background
------------
The id of the background layer to start with The default value is _osm_
The id of the background layer to start with The default value is _osm_
layer-<layer-id>
layer-<layer-id>
------------------
Wether or not the layer with id <layer-id> is shown The default value is _true_ Generated from QueryParameters
Wether or not the layer with id <layer-id> is shown The default value is _true_ Generated from QueryParameters

View file

@ -4,8 +4,8 @@
{{service_item
|name= [https://mapcomplete.osm.be/personal personal]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:es|en}}, {{#language:ca|en}}, {{#language:gl|en}}, {{#language:de|en}}
|descr= A MapComplete theme: Create a personal theme based on all the available layers of all themes
|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. In order to show some data, open [[#filter]]
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, personal
@ -13,25 +13,29 @@
{{service_item
|name= [https://mapcomplete.osm.be/cyclofix cyclofix]
|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/]].
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, cyclofix
}}
{{service_item
|name= [https://mapcomplete.osm.be/aed aed]
|name= [https://mapcomplete.osm.be/hailhydrant hailhydrant]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:ca|en}}, {{#language:es|en}}, {{#language:fr|en}}, {{#language:nl|en}}, {{#language:de|en}}
|descr= A MapComplete theme: On this map, one can find and mark nearby defibrillators
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|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 you can find and update hydrants, fire stations, ambulance stations, and extinguishers in your favorite neighborhoods.
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
|genre= POI, editor, aed
|genre= POI, editor, hailhydrant
}}
{{service_item
|name= [https://mapcomplete.osm.be/bookcases bookcases]
|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.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
@ -40,47 +44,173 @@
{{service_item
|name= [https://mapcomplete.osm.be/toilets toilets]
|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
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, toilets
}}
{{service_item
|name= [https://mapcomplete.osm.be/artworks artworks]
|name= [https://mapcomplete.osm.be/aed aed]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|en}}, {{#language:de|en}}
|descr= A MapComplete theme: Welcome to Open Artwork Map, a map of statues, busts, grafittis, ... all over the world
|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: On this map, one can find and mark nearby defibrillators
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|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
|name= [https://mapcomplete.osm.be/ghostbikes ghostbikes]
|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.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, ghostbikes
}}
{{service_item
|name= [https://mapcomplete.osm.be/shops shops]
|name= [https://mapcomplete.osm.be/hackerspaces hackerspaces]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:fr|en}}
|descr= A MapComplete theme: On this map, one can mark basic information about shops, add opening hours and phone numbers
|lang= {{#language:en|en}}
|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]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, shops
|genre= POI, editor, hackerspaces
}}
{{service_item
|name= [https://mapcomplete.osm.be/drinking_water drinking_water]
|name= [https://mapcomplete.osm.be/maps maps]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}
|descr= A MapComplete theme: On this map, publicly accessible drinkging water spots are shown and can be easily added
|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 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]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, drinking_water
|genre= POI, editor, maps
}}
{{service_item
|name= [https://mapcomplete.osm.be/nature nature]
@ -92,111 +222,93 @@
|genre= POI, editor, nature
}}
{{service_item
|name= [https://mapcomplete.osm.be/fietsstraten fietsstraten]
|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]
|name= [https://mapcomplete.osm.be/observation_towers observation_towers]
|region= Worldwide
|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]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, bicyclelib
|genre= POI, editor, observation_towers
}}
{{service_item
|name= [https://mapcomplete.osm.be/maps maps]
|name= [https://mapcomplete.osm.be/openwindpowermap openwindpowermap]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:nl|en}}, {{#language:fr|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.
|lang= {{#language:en|en}}, {{#language:fr|en}}, {{#language:nl|en}}
|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]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, maps
|genre= POI, editor, parkings
}}
{{service_item
|name= [https://mapcomplete.osm.be/fritures fritures]
|name= [https://mapcomplete.osm.be/playgrounds playgrounds]
|region= Worldwide
|lang= {{#language:nl|en}}, {{#language:fr|en}}
|descr= A MapComplete theme: Op deze kaart vind je je favoriete frituur!
|lang= {{#language:nl|en}}, {{#language:en|en}}, {{#language:fr|en}}, {{#language:ja|en}}, {{#language:zh_Hant|en}}, {{#language:ru|en}}
|descr= A MapComplete theme: On this map, you find playgrounds and can add more information
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, fritures
|genre= POI, editor, playgrounds
}}
{{service_item
|name= [https://mapcomplete.osm.be/benches benches]
|name= [https://mapcomplete.osm.be/shops shops]
|region= Worldwide
|lang= {{#language:en|en}}, {{#language:de|en}}, {{#language:fr|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/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
|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: On this map, one can mark basic information about shops, add opening hours and phone numbers
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|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
|name= [https://mapcomplete.osm.be/surveillance surveillance]
|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.
|material= {{yes|[https://mapcomplete.osm.be/ Yes]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, surveillance
}}
{{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]
|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!
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Midgard;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, trees
}}
{{service_item
|name= [https://mapcomplete.osm.be/campersite campersite]
|name= [https://mapcomplete.osm.be/uk_addresses uk_addresses]
|region= Worldwide
|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.
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by joost schouppe;]}}
|descr= A MapComplete theme: Contribute to OpenStreetMap by filling out address information
|material= {{yes|[https://mapcomplete.osm.be/ Yes, by Pieter Vander Vennet, Rob Nickerson, Russ Garrett;]}}
|image= MapComplete_Screenshot.png
|genre= POI, editor, campersite
|genre= POI, editor, uk_addresses
}}
{{service_item
|name= [https://mapcomplete.osm.be/sport_pitches sport_pitches]
|name= [https://mapcomplete.osm.be/waste_basket waste_basket]
|region= Worldwide
|lang= {{#language:nl|en}}
|descr= A MapComplete theme: Een sportveld is een ingerichte plaats met infrastructuur om een sport te beoefenen
|lang= {{#language:en|en}}, {{#language:nl|en}}
|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]}}
|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 Toggle from "./UI/Input/Toggle";
import State from "./State";
import LoadFromOverpass from "./Logic/Actors/OverpassFeatureSource";
import {UIEventSource} from "./Logic/UIEventSource";
import {QueryParameters} from "./Logic/Web/QueryParameters";
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 L from "leaflet";
import Img from "./UI/Base/Img";
import UserDetails from "./Logic/Osm/OsmConnection";
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 ShowDataLayer from "./UI/ShowDataLayer";
import ShowDataLayer from "./UI/ShowDataLayer/ShowDataLayer";
import Hash from "./Logic/Web/Hash";
import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
import ScrollableFullScreen from "./UI/Base/ScrollableFullScreen";
import Translations from "./UI/i18n/Translations";
import MapControlButton from "./UI/MapControlButton";
import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler";
import LZString from "lz-string";
import FeatureSource from "./Logic/FeatureSource/FeatureSource";
import AllKnownLayers from "./Customizations/AllKnownLayers";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
import {TagsFilter} from "./Logic/Tags/TagsFilter";
import LeftControls from "./UI/BigComponents/LeftControls";
import RightControls from "./UI/BigComponents/RightControls";
import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson";
import LayoutConfig from "./Models/ThemeConfig/LayoutConfig";
import LayerConfig from "./Models/ThemeConfig/LayerConfig";
import Minimap from "./UI/Base/Minimap";
import Constants from "./Models/Constants";
import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler";
import Combine from "./UI/Base/Combine";
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 {
static InitAll(
@ -67,10 +67,24 @@ export class InitUiElements {
"LayoutFromBase64 is ",
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);
// This 'leaks' the global state via the window object, useful for debugging
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
// @ts-ignore
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) {
Utils.LoadCustomCss(layoutToUse.customCss);
}
@ -171,35 +145,43 @@ export class InitUiElements {
).AttachTo("messagesbox");
}
State.state.osmConnection.userDetails
.map((userDetails: UserDetails) => userDetails?.home)
.addCallbackAndRunD((home) => {
const color = getComputedStyle(document.body).getPropertyValue(
"--subtle-detail-color"
);
const icon = L.icon({
iconUrl: Img.AsData(
Svg.home_white_bg.replace(/#ffffff/g, color)
),
iconSize: [30, 30],
iconAnchor: [15, 15],
});
const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(State.state.leafletMap.data);
function addHomeMarker() {
const userDetails = State.state.osmConnection.userDetails.data;
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(
"--subtle-detail-color"
);
const icon = L.icon({
iconUrl: Img.AsData(
Svg.home_white_bg.replace(/#ffffff/g, color)
),
iconSize: [30, 30],
iconAnchor: [15, 15],
});
if (layoutToUse.id === personal.id) {
updateFavs();
const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(leaflet);
return true;
}
State.state.osmConnection.userDetails
.addCallbackAndRunD(_ => addHomeMarker());
State.state.leafletMap.addCallbackAndRunD(_ => addHomeMarker())
InitUiElements.setupAllLayerElements();
if (layoutToUse.id === personal.id) {
State.state.favouriteLayers.addCallback(updateFavs);
State.state.installedThemes.addCallback(updateFavs);
} else {
State.state.locationControl.ping();
}
new SelectedFeatureHandler(Hash.hash, State.state)
// Reset the loading message once things are loaded
new CenterMessageBox().AttachTo("centermessage");
@ -211,7 +193,7 @@ export class InitUiElements {
static LoadLayoutFromHash(
userLayoutParam: UIEventSource<string>
): [LayoutConfig, string] {
let hash = location.hash.substr(1);
let hash = location.hash.substr(1);
try {
const layoutFromBase64 = userLayoutParam.data;
// layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
@ -251,18 +233,18 @@ export class InitUiElements {
userLayoutParam.setData(layoutToUse.id);
return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))];
} catch (e) {
if(hash === undefined || hash.length < 10){
if (hash === undefined || hash.length < 10) {
e = "Did you effectively add a theme? It seems no data could be found."
}
new Combine([
"Error: could not parse the custom layout:",
new FixedUiElement(""+e).SetClass("alert"),
new SubtleButton("./assets/svg/mapcomplete_logo.svg",
"Go back to the theme overview",
{url: window.location.protocol+"//"+ window.location.hostname+"/index.html", newTab: false})
new FixedUiElement("" + e).SetClass("alert"),
new SubtleButton("./assets/svg/mapcomplete_logo.svg",
"Go back to the theme overview",
{url: window.location.protocol + "//" + window.location.hostname + "/index.html", newTab: false})
])
.SetClass("flex flex-col")
.AttachTo("centermessage");
@ -334,40 +316,39 @@ export class InitUiElements {
(layer) => layer.id
);
new LayerResetter(
new BackgroundLayerResetter(
State.state.backgroundLayer,
State.state.locationControl,
State.state.availableBackgroundLayers,
State.state.layoutToUse.map(
(layout: LayoutConfig) => layout.defaultBackgroundId
)
State.state.layoutToUse.defaultBackgroundId
);
const attr = new Attribution(
State.state.locationControl,
State.state.osmConnection.userDetails,
State.state.layoutToUse,
State.state.leafletMap
State.state.currentBounds
);
new Minimap({
Minimap.createMiniMap({
background: State.state.backgroundLayer,
location: State.state.locationControl,
leafletMap: State.state.leafletMap,
bounds: State.state.currentBounds,
attribution: attr,
lastClickLocation: State.state.LastClickLocation
}).SetClass("w-full h-full")
.AttachTo("leafletDiv")
const layout = State.state.layoutToUse.data;
const layout = State.state.layoutToUse;
if (layout.lockLocation) {
if (layout.lockLocation === true) {
const tile = Utils.embedded_tile(
const tile = Tiles.embedded_tile(
layout.startLat,
layout.startLon,
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
const latDiff = bounds[0][0] - bounds[1][0];
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;
state.filteredLayers = state.layoutToUse.map((layoutToUse) => {
const flayers = [];
const empty = []
for (const layer of layoutToUse.layers) {
const isDisplayed = QueryParameters.GetQueryParameter(
const flayers: FilteredLayer[] = [];
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,
"true",
defaultShown,
"Wether or not layer " + layer.id + " is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => b.toString()
);
const flayer = {
isDisplayed: isDisplayed,
layerDef: layer,
appliedFilters: new UIEventSource<TagsFilter>(undefined),
};
flayers.push(flayer);
}
return flayers;
});
const flayer = {
isDisplayed: isDisplayed,
layerDef: layer,
appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]),
};
const updater = new LoadFromOverpass(
state.locationControl,
state.layoutToUse,
state.leafletMap,
state.overpassUrl,
state.overpassTimeout,
Constants.useOsmApiAt
);
State.state.layerUpdater = updater;
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)
}
const source = new FeaturePipeline(
state.filteredLayers,
State.state.changes,
updater,
state.osmApiFeatureSource,
state.layoutToUse,
state.locationControl,
state.selectedElement
);
flayers.push(flayer);
}
state.filteredLayers = new UIEventSource<FilteredLayer[]>(flayers);
State.state.featurePipeline = source;
new ShowDataLayer(
source.features,
State.state.leafletMap,
State.state.layoutToUse
);
const selectedFeatureHandler = new SelectedFeatureHandler(
Hash.hash,
State.state.selectedElement,
source,
State.state.osmApiFeatureSource
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(
{
features: source,
leafletMap: State.state.leafletMap,
layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures
}
);
}, state
);
selectedFeatureHandler.zoomToSelectedFeature(
State.state.locationControl
);
return source;
}
private static setupAllLayerElements() {
// ------------- 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");
// ------------------ 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
*/
export default class LayerResetter {
export default class BackgroundLayerResetter {
constructor(currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: UIEventSource<string> = undefined) {
defaultLayerId = defaultLayerId ?? new UIEventSource<string>(AvailableBaseLayers.osmCarto.id);
defaultLayerId: string = undefined) {
defaultLayerId = defaultLayerId ?? AvailableBaseLayers.osmCarto.id;
// Change the baselayer back to OSM if we go out of the current range of the layer
availableLayers.addCallbackAndRun(availableLayers => {
@ -28,7 +28,7 @@ export default class LayerResetter {
if (availableLayer.min_zoom > location.data.zoom) {
break;
}
if (availableLayer.id === defaultLayerId.data) {
if (availableLayer.id === defaultLayerId) {
defaultLayer = availableLayer;
}
return; // All good - the current layer still works!

View file

@ -60,12 +60,12 @@ export default class GeoLocationHandler extends VariableUiElement {
* @private
*/
private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _layoutToUse: LayoutConfig;
constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>
layoutToUse: LayoutConfig
) {
const hasLocation = currentGPSLocation.map(
(location) => location !== undefined
@ -207,6 +207,9 @@ export default class GeoLocationHandler extends VariableUiElement {
});
const map = self._leafletMap.data;
if(map === undefined){
return;
}
const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map);
@ -230,7 +233,7 @@ export default class GeoLocationHandler extends VariableUiElement {
navigator?.permissions
?.query({name: "geolocation"})
?.then(function (status) {
console.log("Geolocation is already", status);
console.log("Geolocation permission is ", status.state);
if (status.state === "granted") {
self.StartGeolocating(forceZoom);
}
@ -264,7 +267,7 @@ export default class GeoLocationHandler extends VariableUiElement {
}
// 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;
if (b) {
if (b !== true) {
@ -289,7 +292,6 @@ export default class GeoLocationHandler extends VariableUiElement {
private StartGeolocating(zoomToGPS = true) {
const self = this;
console.log("Starting geolocation");
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
if (self._permission.data === "denied") {
@ -301,8 +303,6 @@ export default class GeoLocationHandler extends VariableUiElement {
this.MoveToCurrentLoction(16);
}
console.log("Searching location using GPS");
if (self._isActive.data) {
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 Loc from "../../Models/Loc";
import {Or} from "../Tags/Or";
import {Overpass} from "../Osm/Overpass";
import Bounds from "../../Models/Bounds";
import FeatureSource from "../FeatureSource/FeatureSource";
import {Utils} from "../../Utils";
import {TagsFilter} from "../Tags/TagsFilter";
import SimpleMetaTagger from "../SimpleMetaTagger";
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 {
@ -20,116 +22,54 @@ export default class OverpassFeatureSource implements FeatureSource {
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 timeout: UIEventSource<number> = new UIEventSource<number>(0);
public readonly relationsTracker: RelationsTracker;
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: {
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;
/**
* The most important layer should go first, as that one gets first pick for the questions
*/
constructor(
location: UIEventSource<Loc>,
layoutToUse: UIEventSource<LayoutConfig>,
leafletMap: UIEventSource<L.Map>,
interpreterUrl: UIEventSource<string>,
timeout: UIEventSource<number>,
maxZoom = undefined) {
this._location = location;
this._layoutToUse = layoutToUse;
this._leafletMap = leafletMap;
this._interpreterUrl = interpreterUrl;
this._timeout = timeout;
state: {
readonly locationControl: UIEventSource<Loc>,
readonly layoutToUse: LayoutConfig,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>,
readonly currentBounds: UIEventSource<BBox>
},
options?: {
isActive?: UIEventSource<boolean>,
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;
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(() => {
state.currentBounds.addCallback(_ => {
self.update()
});
location.addCallback(() => {
self.update()
});
leafletMap.addCallbackAndRunD(_ => {
self.update();
})
}
public ForceRefresh() {
for (let i = 0; i < 25; i++) {
this._previousBounds.set(i, []);
}
this.update();
}
private GetFilter(): Overpass {
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = [];
let extraScripts: string[] = [];
for (const layer of this._layoutToUse.data.layers) {
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;
}
for (const layer of layersToDownload) {
if (layer.source.overpassScript !== undefined) {
extraScripts.push(layer.source.overpassScript)
} else {
@ -141,95 +81,113 @@ export default class OverpassFeatureSource implements FeatureSource {
if (filters.length + extraScripts.length === 0) {
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) {
console.log("Still running a query, not updating");
return;
return undefined;
}
if (this.timeout.data > 0) {
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) {
return;
return undefined;
}
const diff = this._layoutToUse.data.widenFactor;
const n = Math.min(90, bounds.getNorth() + diff);
const e = Math.min(180, bounds.getEast() + diff);
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 self = this;
const overpass = this.GetFilter();
if (overpass === undefined) {
return;
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)
}
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);
self.runningQuery.setData(false);
},
function (reason) {
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.runningQuery.setData(false);
let data: any = undefined
let date: Date = undefined
const overpassUrls = self.state.overpassUrl.data
let lastUsed = 0;
function countDown() {
window?.setTimeout(
function () {
if (self.timeout.data > 1) {
self.timeout.setData(self.timeout.data - 1);
window.setTimeout(
countDown,
1000
)
} else {
self.timeout.setData(0);
self.update()
}
}, 1000
)
do {
try {
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload);
if (overpass === undefined) {
return undefined;
}
this.runningQuery.setData(true);
countDown();
[data, date] = await overpass.queryGeoJson(bounds)
console.log("Querying overpass is done", data)
} catch (e) {
self.retries.data++;
self.retries.ping();
console.error(`QUERY FAILED due to`, e);
await Utils.waitFor(1000)
if (lastUsed + 1 < overpassUrls.length) {
lastUsed++
console.log("Trying next time with", overpassUrls[lastUsed])
} else {
lastUsed = 0
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();
}
}
}
);
} while (data === undefined);
}
private IsInBounds(bounds: Bounds): boolean {
if (this._previousBounds === undefined) {
return false;
self.retries.setData(0);
try {
data.features.forEach(feature => SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(feature, date));
self.features.setData(data.features.map(f => ({feature: f, freshness: date})));
return [bounds, date, layersToDownload];
} catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally {
self.runningQuery.setData(false);
}
const b = this._leafletMap.data.getBounds();
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 FeatureSource from "../FeatureSource/FeatureSource";
import {OsmObject} from "../Osm/OsmObject";
import Loc from "../../Models/Loc";
import {ElementStorage} from "../ElementStorage";
import FeaturePipeline from "../FeatureSource/FeaturePipeline";
import OsmApiFeatureSource from "../FeatureSource/OsmApiFeatureSource";
/**
* Makes sure the hash shows the selected element and vice-versa.
*/
export default class SelectedFeatureHandler {
private static readonly _no_trigger_on = ["welcome", "copyright", "layers", "new"]
private readonly _featureSource: FeatureSource;
private readonly _hash: UIEventSource<string>;
private readonly _selectedFeature: UIEventSource<any>;
private readonly _osmApiSource: OsmApiFeatureSource;
private static readonly _no_trigger_on = new Set(["welcome", "copyright", "layers", "new", "", undefined])
hash: UIEventSource<string>;
private readonly state: {
selectedElement: UIEventSource<any>
}
constructor(hash: UIEventSource<string>,
selectedFeature: UIEventSource<any>,
featureSource: FeaturePipeline,
osmApiSource: OsmApiFeatureSource) {
this._hash = hash;
this._selectedFeature = selectedFeature;
this._featureSource = featureSource;
this._osmApiSource = osmApiSource;
const self = this;
hash.addCallback(h => {
constructor(
hash: UIEventSource<string>,
state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
featurePipeline: FeaturePipeline
}
) {
this.hash = hash;
this.state = state
// If the hash changes, set the selected element correctly
function setSelectedElementFromHash(h){
if (h === undefined || h === "") {
selectedFeature.setData(undefined);
} else {
self.selectFeature();
// Hash has been cleared - we clear the selected element
state.selectedElement.setData(undefined);
}else{
// we search the element to select
const feature = state.allElements.ContainingFeatures.get(h)
if(feature === undefined){
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.addCallbackAndRunD(h => {
try {
self.downloadFeature(h)
} catch (e) {
console.error("Could not download feature, probably a weird hash")
}
})
hash.addCallback(setSelectedElementFromHash)
featureSource.features.addCallback(_ => self.selectFeature());
selectedFeature.addCallback(feature => {
// IF the selected element changes, set the hash correctly
state.selectedElement.addCallback(feature => {
if (feature === undefined) {
if (SelectedFeatureHandler._no_trigger_on.indexOf(hash.data) < 0) {
if (!SelectedFeatureHandler._no_trigger_on.has(hash.data)) {
hash.setData("")
}
}
@ -54,15 +66,26 @@ export default class SelectedFeatureHandler {
hash.setData(h)
}
})
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
public zoomToSelectedFeature(location: UIEventSource<Loc>) {
const hash = this._hash.data;
if (hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0) {
const hash = this.hash.data;
if (hash === undefined || SelectedFeatureHandler._no_trigger_on.has(hash)) {
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
@ -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 Locale from "../../UI/i18n/Locale";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import {ElementStorage} from "../ElementStorage";
import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../ElementStorage";
class TitleElement extends UIEventSource<string> {
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _selectedFeature: UIEventSource<any>;
private readonly _allElementsStorage: ElementStorage;
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
super("MapComplete");
this._layoutToUse = layoutToUse;
this._selectedFeature = selectedFeature;
this._allElementsStorage = allElementsStorage;
this.syncWith(
this._selectedFeature.map(
selected => {
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete"
if (selected === undefined) {
return defaultTitle
}
const layout = layoutToUse.data;
const tags = selected.properties;
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = allElementsStorage.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
}
}
export default class TitleHandler {
constructor(state : {
selectedElement: UIEventSource<any>,
layoutToUse: LayoutConfig,
allElements: ElementStorage
}) {
const currentTitle: UIEventSource<string> = state.selectedElement.map(
selected => {
const layout = state.layoutToUse
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
if (selected === undefined) {
return defaultTitle
}
, [Locale.language, layoutToUse]
)
const tags = selected.properties;
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = state.allElements.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? defaultTitle;
}
}
return defaultTitle
}, [Locale.language]
)
}
}
export default class TitleHandler {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => {
currentTitle.addCallbackAndRunD(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
import FeatureSource from "./FeatureSource/FeatureSource";
import {UIEventSource} from "./UIEventSource";
import FeaturePipeline from "./FeatureSource/FeaturePipeline";
import Loc from "../Models/Loc";
import {BBox} from "./BBox";
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) {
this.Contributors = featureSource.features.map(features => {
const hist = new Map<string, number>();
for (const feature of features) {
const contributor = feature.feature.properties["_last_edit:contributor"]
constructor(state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource<BBox>, locationControl: UIEventSource<Loc> }) {
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>();
for (const list of featuresList) {
for (const feature of list) {
const contributor = feature.properties["_last_edit:contributor"]
const count = hist.get(contributor) ?? 0;
hist.set(contributor, count + 1)
}
return hist;
})
}
this.Contributors.setData(hist)
}
}

View file

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

View file

@ -1,13 +1,24 @@
import {GeoOperations} from "./GeoOperations";
import Combine from "../UI/Base/Combine";
import {Relation} from "./Osm/ExtractRelations";
import RelationsTracker from "./Osm/RelationsTracker";
import State from "../State";
import {Utils} from "../Utils";
import BaseUIElement from "../UI/BaseUIElement";
import List from "../UI/Base/List";
import Title from "../UI/Base/Title";
import {UIEventSourceTools} from "./UIEventSource";
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 {
@ -45,21 +56,30 @@ export class ExtraFunction {
private static readonly OverlapFunc = new ExtraFunction(
{
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)"]
},
(params, feat) => {
return (...layerIds: string[]) => {
const result = []
const bbox = BBox.get(feat)
for (const layerId of layerIds) {
const otherLayer = params.featuresPerLayer.get(layerId);
if (otherLayer === undefined) {
const otherLayers = params.getFeaturesWithin(layerId, bbox)
if (otherLayers === undefined) {
continue;
}
if (otherLayer.length === 0) {
if (otherLayers.length === 0) {
continue;
}
result.push(...GeoOperations.calculateOverlap(feat, otherLayer));
for (const otherLayer of otherLayers) {
result.push(...GeoOperations.calculateOverlap(feat, otherLayer));
}
}
return result;
}
@ -69,10 +89,13 @@ export class ExtraFunction {
{
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",
args: ["longitude", "latitude"]
args: ["feature OR featureID OR longitude", "undefined OR latitude"]
},
(featuresPerLayer, feature) => {
return (arg0, lat) => {
if (arg0 === undefined) {
return undefined;
}
if (typeof arg0 === "number") {
// Feature._lon and ._lat is conveniently place by one of the other metatags
return GeoOperations.distanceBetween([arg0, lat], [feature._lon, feature._lat]);
@ -92,59 +115,40 @@ export class ExtraFunction {
}
}
)
private static readonly ClosestObjectFunc = new ExtraFunction(
{
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"]
},
(params, feature) => {
return (features) => {
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;
}
return (features) => ExtraFunction.GetClosestNFeatures(params, feature, features)?.[0]?.feat
}
)
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(
{
@ -154,11 +158,12 @@ export class ExtraFunction {
"For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`",
args: []
},
(params, _) => {
return () => params.relations ?? [];
(params, feat) => {
return () =>
params.memberships.knownRelations.data.get(feat.properties.id) ?? []
}
)
private static readonly AspectedRouting = new ExtraFunction(
{
name: "score",
@ -178,30 +183,30 @@ export class ExtraFunction {
}
}
)
private static readonly allFuncs: ExtraFunction[] = [
ExtraFunction.DistanceToFunc,
ExtraFunction.OverlapFunc,
ExtraFunction.ClosestObjectFunc,
ExtraFunction.ClosestNObjectFunc,
ExtraFunction.Memberships,
ExtraFunction.AspectedRouting
];
private readonly _name: string;
private readonly _args: 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[] },
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._doc = options.doc;
this._args = options.args;
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) {
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 {UIEventSource} from "../UIEventSource";
import State from "../../State";
import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import State from "../../../State";
export default class RegisteringFeatureSource implements FeatureSource {
export default class RegisteringAllFromFeatureSourceActor {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
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 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[]>,
changes: Changes,
updater: FeatureSource,
fromOsmApi: FeatureSource,
layout: UIEventSource<LayoutConfig>,
locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>) {
public readonly runningQuery: UIEventSource<boolean>;
public readonly timeout: UIEventSource<number>;
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
// Note that we need to register before we do metatagging (as it expects the event sources)
private readonly overpassUpdater: OverpassFeatureSource
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
const amendedOverpassSource =
new RememberingSource(
new LocalStorageSaver(
new MetaTaggingFeatureSource(allLoadedFeatures,
new FeatureDuplicatorPerLayer(flayers,
new RegisteringFeatureSource(
new ChangeApplicator(
updater, changes
))
)), layout));
private readonly freshnesses = new Map<string, TileFreshnessCalculator>();
const geojsonSources: FeatureSource [] = GeoJsonSource
.ConstructMultiSource(flayers.data, locationControl)
.map(geojsonSource => {
let source = new RegisteringFeatureSource(
new FeatureDuplicatorPerLayer(flayers,
new ChangeApplicator(geojsonSource, changes)));
if (!geojsonSource.isOsmCache) {
source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features);
private readonly oldestAllowedDate: Date = new Date(new Date().getTime() - 60 * 60 * 24 * 30 * 1000);
private readonly osmSourceZoomLevel = 14
constructor(
handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
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>
}) {
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(
new MetaTaggingFeatureSource(allLoadedFeatures,
new FeatureDuplicatorPerLayer(flayers,
new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes,
{
// We lump in the new points here
generateNewGeometries: true
}
)))));
// Register everything in the state' 'AllElements'
new RegisteringAllFromFeatureSourceActor(updater)
return updater;
}
const merged =
new FeatureSourceMerger([
amendedOverpassSource,
amendedOsmApiSource,
amendedLocalStorageSource,
...geojsonSources
]);
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,
{
memberships: this.relationTracker,
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
)
merged.features.syncWith(allLoadedFeatures)
}
this.features = new WayHandlingApplyingFeatureSource(flayers,
new FilteringFeatureSource(
flayers,
locationControl,
selectedElement,
merged
)).features;
private updateAllMetaTagging() {
const self = this;
console.log("Reupdating all metatagging")
this.perLayerHierarchy.forEach(hierarchy => {
hierarchy.loadedTiles.forEach(src => {
self.applyMetaTags(src)
})
})
}
public GetAllFeaturesWithin(bbox: BBox): any[][] {
const self = this
const tiles = []
Array.from(this.perLayerHierarchy.keys())
.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 {Utils} from "../../Utils";
import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../BBox";
export default interface FeatureSource {
features: UIEventSource<{ feature: any, freshness: Date }[]>;
@ -9,38 +11,30 @@ export default interface FeatureSource {
name: string;
}
export class FeatureSourceUtils {
export interface Tiled {
tileIndex: number,
bbox: BBox
}
/**
* Exports given featurePipeline as a geojson FeatureLists (downloads as a json)
* @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 } = {}) {
let defaults = {
metadata: false,
}
options = Utils.setDefaults(options, defaults);
/**
* A feature source which only contains features for the defined layer
*/
export interface FeatureSourceForLayer extends FeatureSource{
readonly layer: FilteredLayer
}
// 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!
/**
* A feature source which is aware of the indexes it contains
*/
export interface IndexedFeatureSource extends FeatureSource {
readonly containedIds: UIEventSource<Set<string>>
}
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 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 {UIEventSource} from "../UIEventSource";
import FeatureSource, {Tiled} from "../FeatureSource";
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 name;
constructor(source: FeatureSource) {
public readonly tileIndex : number
public readonly bbox : BBox
constructor(source: FeatureSource & Tiled) {
const self = this;
this.name = "RememberingSource of " + source.name;
this.tileIndex= source.tileIndex
this.bbox = source.bbox;
const empty = [];
this.features = source.features.map(features => {
const oldFeatures = self.features?.data ?? empty;
@ -20,9 +27,9 @@ export default class RememberingSource implements FeatureSource {
}
// 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
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];
})
}

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)
*/
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 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(
features => {
if (features === undefined) {
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 }[] = [];
for (const f of features) {
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) {
newFeatures.push(f);
@ -47,19 +40,17 @@ export default class WayHandlingApplyingFeatureSource implements FeatureSource {
if (feat.geometry.type === "Point") {
newFeatures.push(f);
// it is a point, nothing to do here
// feature is a point, nothing to do here
continue;
}
// Create the copy
const centerPoint = GeoOperations.centerpoint(feat);
centerPoint["_matching_layer_id"] = feat._matching_layer_id;
newFeatures.push({feature: centerPoint, freshness: f.freshness});
if (layer.wayHandling === LayerConfig.WAYHANDLING_CENTER_AND_WAY) {
newFeatures.push(f);
}
}
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 {BBox} from "./BBox";
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
*/
static centerpoint(feature: any) {
@ -185,8 +186,51 @@ export class GeoOperations {
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
*
* 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 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 {Wikimedia} from "./Wikimedia";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
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 {
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 {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import {UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
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();
@ -86,51 +89,39 @@ export class Imgur extends ImageAttributionSource {
return undefined;
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
const src = new UIEventSource<LicenseInfo>(undefined)
protected DownloadAttribution: (url: string) => Promise<LicenseInfo> = async (url: string) => {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
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 data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1].replace("\r", "");
}
const descr: string = response.data.description ?? "";
const data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1]?.replace("\r", "");
}
const licenseInfo = new LicenseInfo();
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
src.setData(licenseInfo)
}).fail((reason) => {
console.log("Getting metadata from to IMGUR failed", reason)
});
return src;
return licenseInfo
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) {
return [Promise.resolve({
url: value,
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,30 +1,31 @@
import {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
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 {
public static readonly singleton = new Mapillary();
defaultKeyPrefixes = ["mapillary","image"]
private static readonly v4_cached_urls = new Map<string, UIEventSource<string>>();
private static readonly client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2'
private static readonly client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
public static readonly singleton = new Mapillary();
private static readonly valuePrefix = "https://a.mapillary.com"
public static readonly valuePrefixes = [Mapillary.valuePrefix, "http://mapillary.com","https://mapillary.com"]
private constructor() {
super();
}
private static ExtractKeyFromURL(value: string): {
private static ExtractKeyFromURL(value: string, failIfNoMath = false): {
key: string,
isApiv4?: boolean
} {
if (value.startsWith("https://a.mapillary.com")) {
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
return {key:key, isApiv4: !isNaN(Number(key))};
if (value.startsWith(Mapillary.valuePrefix)) {
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
return {key: key, isApiv4: !isNaN(Number(key))};
}
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
if (newApiFormat !== null) {
@ -32,12 +33,11 @@ export class Mapillary extends ImageAttributionSource {
}
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]
return {key:key, isApiv4: !isNaN(Number(key))};
return {key: key, isApiv4: !isNaN(Number(key))};
}
if (value.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
// Extract the key of the image
@ -48,8 +48,11 @@ export class Mapillary extends ImageAttributionSource {
if (matchApi !== null) {
return {key: matchApi[1]};
}
if(failIfNoMath){
return undefined;
}
return {key: value, isApiv4: !isNaN(Number(value))};
}
@ -57,54 +60,59 @@ export class Mapillary extends ImageAttributionSource {
return Svg.mapillary_svg();
}
PrepareUrl(value: string): string | UIEventSource<string> {
const keyV = Mapillary.ExtractKeyFromURL(value)
if (!keyV.isApiv4) {
return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}`
} else {
const key = keyV.key;
if(Mapillary.v4_cached_urls.has(key)){
return Mapillary.v4_cached_urls.get(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
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.PrepareUrlAsync(key, value)]
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
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) {
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 {
const mapillaryId = keyV.key;
const metadataUrl = 'https://graph.mapillary.com/' + mapillaryId + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
const response = await Utils.downloadJson(metadataUrl)
const url = <string> response["thumb_1024_url"];
return {
url: url,
provider: this,
key: key
}
}
}
protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
const keyV = Mapillary.ExtractKeyFromURL(url)
if(keyV.isApiv4){
if (keyV.isApiv4) {
const license = new LicenseInfo()
license.artist = "Contributor name unavailable";
license.license = "CC BY-SA 4.0";
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
return new UIEventSource<LicenseInfo>(license)
return license
}
const key = keyV.key
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined)
Utils.downloadJson(metadataURL).then(data => {
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
source.setData(license);
})
return source
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const data = await Utils.downloadJson(metadataURL)
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
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 {ExtraFunction} from "./ExtraFunction";
import {Relation} from "./Osm/ExtractRelations";
import {ExtraFuncParams, ExtraFunction} from "./ExtraFunction";
import {UIEventSource} from "./UIEventSource";
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, ...
*
@ -22,92 +17,98 @@ export default class MetaTagging {
private static readonly stopErrorOutputAt = 10;
/**
* An actor which adds metatags on every feature in the given object
* The features are a list of geojson-features, with a "properties"-field and geometry
* This method (re)calculates all metatags and calculated tags on every given object.
* The given features should be part of the given layer
*/
static addMetatags(features: { feature: any; freshness: Date }[],
allKnownFeatures: UIEventSource<{ feature: any; freshness: Date }[]>,
relations: Map<string, { role: string, relation: Relation }[]>,
layers: LayerConfig[],
includeDates = true) {
public static addMetatags(features: { feature: any; freshness: Date }[],
params: ExtraFuncParams,
layer: LayerConfig,
options?: {
includeDates?: true | boolean,
includeNonDates?: true | boolean
}) {
if (features === undefined || features.length === 0) {
return;
}
for (const metatag of SimpleMetaTagger.metatags) {
if (metatag.includesDates && !includeDates) {
// We do not add dated entries
continue;
}
try {
metatag.addMetaTags(features);
} catch (e) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e)
}
}
// The functions - per layer - which add the new keys
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, [])
const metatagsToApply: SimpleMetaTagger [] = []
for (const metatag of SimpleMetaTagger.metatags) {
if (metatag.includesDates) {
if (options.includeDates ?? true) {
metatagsToApply.push(metatag)
}
} else {
if (options.includeNonDates ?? true) {
metatagsToApply.push(metatag)
}
}
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;
}
// The calculated functions - per layer - which add the new keys
const layerFuncs = this.createRetaggingFunc(layer)
try {
f({featuresPerLayer: featuresPerLayer, memberships: relations}, feature.feature)
} catch (e) {
console.error(e)
}
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 {
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) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e, e.stack)
}
}
if(layerFuncs !== undefined){
try {
layerFuncs(params, feature)
} catch (e) {
console.error(e)
}
somethingChanged = true
}
if(somethingChanged){
State.state?.allElements?.getEventSourceById(feature.properties.id)?.ping()
}
}
})
}
private static createRetaggingFunc(layer: LayerConfig):
((params: Params, feature: any) => void) {
((params: ExtraFuncParams, feature: any) => void) {
const calculatedTags: [string, string][] = layer.calculatedTags;
if (calculatedTags === undefined) {
return undefined;
}
const functions: ((params: Params, feature: any) => void)[] = [];
const functions: ((params: ExtraFuncParams, feature: any) => void)[] = [];
for (const entry of calculatedTags) {
const key = entry[0]
const code = entry[1];
if (code === undefined) {
continue;
}
const func = new Function("feat", "return " + code + ";");
try {
const f = (featuresPerLayer, feature: any) => {
try {
let result = func(feature);
@ -132,7 +133,7 @@ export default class MetaTagging {
feature.properties[key] = result;
} catch (e) {
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++;
if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) {
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)
}
}
return (params: Params, feature) => {
return (params: ExtraFuncParams, feature) => {
const tags = feature.properties
if (tags === undefined) {
return;
}
const relations = params.memberships?.get(feature.properties.id) ?? []
ExtraFunction.FullPatchFeature(params.featuresPerLayer, relations, feature);
ExtraFunction.FullPatchFeature(params, feature);
try {
for (const f of functions) {
f(params, feature);
}
State.state?.allElements?.getEventSourceById(feature.properties.id)?.ping();
} catch (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 {
/**
* Identifier of the object
*/
type: "node" | "way" | "relation",
/**
* Negative for a new objects
* Identifier of the object
* Negative for new objects
*/
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 }[],
/**
* A change to the geometry:
* 1) Change of node location
* 2) Change of way geometry
* 3) Change of relation members (untested)
*/
changes?: {
lat: number,
lon: number
} | {
// Coordinates are only used for rendering. They should be lon, lat
locations: [number, number][]
// Coordinates are only used for rendering. They should be LAT, LON
coordinates: [number, number][]
nodes: number[],
} | {
members: { type: "node" | "way" | "relation", ref: number, role: string }[]
@ -25,6 +42,26 @@ export interface ChangeDescription {
Set to delete the object
*/
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()};
}
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 typeId = this._elementId.split("/")
const type = typeId[0]

View file

@ -24,10 +24,10 @@ export default class CreateNewNodeAction extends OsmChangeAction {
throw "Lat or lon are undefined!"
}
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 properties = {
id: "node/" + id
@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
type: "way",
id: this._snapOnto.id,
changes: {
locations: locations,
coordinates: locations,
nodes: ids
}
}

View file

@ -62,7 +62,7 @@ export default class DeleteAction {
}
State.state.osmConnection.changesetHandler.DeleteElement(
obj,
State.state.layoutToUse.data,
State.state.layoutToUse,
reason,
State.state.allElements,
() => {
@ -159,7 +159,7 @@ export default class DeleteAction {
canBeDeleted: false,
reason: t.notEnoughExperience
})
return;
return true; // unregister this caller!
}
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
OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => {
OsmObject.DownloadReferencingRelations(id).then(rels => {
hasRelations.setData(rels.length > 0)
})
OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => {
OsmObject.DownloadReferencingWays(id).then(ways => {
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)
}
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 {GeoOperations} from "../../GeoOperations";
import OsmChangeAction from "./OsmChangeAction";
import {ChangeDescription} from "./ChangeDescription";
import RelationSplitlHandler from "./RelationSplitlHandler";
import RelationSplitHandler from "./RelationSplitHandler";
interface SplitInfo {
originalIndex?: number, // or negative for new elements
@ -12,17 +12,18 @@ interface SplitInfo {
}
export default class SplitAction extends OsmChangeAction {
private readonly roadObject: any;
private readonly osmWay: OsmWay;
private _partOf: OsmRelation[];
private readonly _splitPoints: any[];
private readonly wayId: string;
private readonly _splitPointsCoordinates: [number, number] []// lon, lat
constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) {
/**
*
* @param wayId
* @param splitPointCoordinates: lon, lat
*/
constructor(wayId: string, splitPointCoordinates: [number, number][]) {
super()
this.osmWay = osmWay;
this.roadObject = wayGeoJson;
this._partOf = partOf;
this._splitPoints = splitPoints;
this.wayId = wayId;
this._splitPointsCoordinates = splitPointCoordinates
}
private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] {
@ -42,26 +43,16 @@ export default class SplitAction extends OsmChangeAction {
return wayParts.filter(wp => wp.length > 0)
}
CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
const splitPoints = this._splitPoints
// We mark the new split points with a new id
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;
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const originalElement = <OsmWay> await OsmObject.DownloadObjectAsync(this.wayId)
const originalNodes = originalElement.nodes;
// 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.
// [ { 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) {
if (element.originalIndex >= 0) {
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
for (const wayPart of wayParts) {
let isOriginal = wayPart === longest
if (isOriginal) {
// We change the actual element!
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({
type: "way",
id: originalElement.id,
changes: {
locations: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex)
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
}
})
allWayIdsInOrder.push(originalElement.id)
allWaysNodesInOrder.push(nodeIds)
} else {
let id = changes.getNewID();
newWayIds.push(id)
// Copy the tags from the original object onto the new
const kv = []
for (const k in originalElement.tags) {
if (!originalElement.tags.hasOwnProperty(k)) {
@ -131,22 +127,35 @@ export default class SplitAction extends OsmChangeAction {
}
kv.push({k: k, v: originalElement.tags[k]})
}
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({
type: "way",
id: id,
tags: kv,
changes: {
locations: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex)
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
}
})
}
allWayIdsInOrder.push(id)
allWaysNodesInOrder.push(nodeIds)
}
}
// 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
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!
// Time to upload
@ -158,75 +167,102 @@ export default class SplitAction extends OsmChangeAction {
* Calculates the actual points to split
* If another point is closer then ~5m, we reuse that point
*/
private CalculateSplitCoordinates(
splitPoints: any[],
toleranceInM = 5): SplitInfo[] {
private CalculateSplitCoordinates(osmWay: OsmWay, toleranceInM = 5): SplitInfo[] {
const wayGeoJson = osmWay.asGeoJson()
// 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: [ [lat, lon], [lat, lon], ...] ...
const originalPoints: [number, number][] = this.roadObject.geometry.coordinates
// We project them onto the line (which should yield pretty much the same point
// We have a bunch of coordinates here: [ [lon, lon], [lat, lon], ...] ...
// We project them onto the line (which should yield pretty much the same point and add them to allPoints
for (let i = 0; i < originalPoints.length; i++) {
let originalPoint = originalPoints[i];
let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint)
projected.properties["_is_split_point"] = false
projected.properties["_original_index"] = i
allPoints.push(projected)
let projected = GeoOperations.nearestPoint(wayGeoJson, originalPoint)
allPoints.push({
coordinates: originalPoint,
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
// 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--) {
const point = allPoints[i];
if (point.properties._original_index !== undefined) {
// This point is already in OSM - we have to keep it!
continue;
}
if (i != allPoints.length - 1) {
const prevPoint = allPoints[i + 1]
const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000
if (diff <= toleranceInM) {
// To close to the previous point! We delete this point...
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...
}*/
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
// Note the loop bounds: we skip the first two and last two elements:
// The first and last element are always part of the original way and should be kept
// Furthermore, we run in reverse order as we'll delete elements on the go
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
}
// At this point, 'dist' told us the point is pretty close to an already existing point.
// Lets see which (already existing) point is closer and mark it as splitpoint
const nextPoint = allPoints[i + 1]
const prevPoint = allPoints[i - 1]
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)
}
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) {
let index = p.properties._original_index
let index = p.originalIndex
if (index === undefined) {
index = nextId;
nextId--;
}
const splitInfoElement = {
originalIndex: index,
lngLat: p.geometry.coordinates,
doSplit: p.properties._is_split_point
lngLat: p.coordinates,
doSplit: p.isSplitPoint
}
splitInfo.push(splitInfoElement)
}

View file

@ -14,19 +14,27 @@ import {LocalStorageSource} from "../Web/LocalStorageSource";
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"
/**
* All the newly created features as featureSource + all the modified features
*/
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 previouslyCreated: OsmObject[] = []
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,
@ -74,7 +82,7 @@ export class Changes {
* Returns a new ID and updates the value for the next ID
*/
public getNewID() {
return Changes._nextId--;
return this._nextId--;
}
/**
@ -85,71 +93,69 @@ export class Changes {
if (this.pendingChanges.data.length === 0) {
return;
}
if (this.isUploading.data) {
console.log("Is already uploading... Abort")
return;
}
this.isUploading.setData(true)
console.log("Beginning upload... " + flushreason ?? "");
// At last, we build the changeset and upload
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;
const pending = self.pendingChanges.data;
const neededIds = Changes.GetNeededIds(pending)
console.log("Needed ids", neededIds)
OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => {
try {
console.log("Beginning upload... " + flushreason ?? "");
// At last, we build the changeset and upload
const pending = self.pendingChanges.data;
const neededIds = Changes.GetNeededIds(pending)
const osmObjects = await Promise.all(neededIds.map(id => OsmObject.DownloadObjectAsync(id)));
console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
try {
const changes: {
newObjects: OsmObject[],
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made")
self.pendingChanges.setData([])
self.isUploading.setData(false)
return true; // Unregister the callback
}
State.state.osmConnection.UploadChangeset(
State.state.layoutToUse.data,
State.state.allElements,
(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
)
} catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
const changes: {
newObjects: OsmObject[],
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made")
self.pendingChanges.setData([])
self.isUploading.setData(false)
}
return true;
});
await State.state.osmConnection.UploadChangeset(
State.state.layoutToUse,
State.state.allElements,
(csId) => Changes.createChangesetFor(csId, changes),
)
console.log("Upload successfull!")
this.pendingChanges.setData([]);
this.isUploading.setData(false)
} catch (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.isUploading.setData(false)
}
}
public applyAction(action: OsmChangeAction) {
const changes = action.Perform(this)
public async applyAction(action: OsmChangeAction): Promise<void> {
const changes = await action.Perform(this)
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
this.allChanges.data.push(...changes)
this.allChanges.ping()
}
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
@ -303,4 +309,8 @@ export class Changes {
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 {OsmObject} from "./OsmObject";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "./Changes";
export class ChangesetHandler {
public readonly currentChangeset: UIEventSource<string>;
private readonly allElements: ElementStorage;
private readonly changes: Changes;
private readonly _dryRun: boolean;
private readonly userDetails: UIEventSource<UserDetails>;
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.userDetails = osmConnection.userDetails;
this.auth = auth;
@ -27,35 +35,55 @@ export class ChangesetHandler {
}
}
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage): void {
private handleIdRewrite(node: any, type: string): [string, string] {
const oldId = parseInt(node.attributes.old_id.value);
if (node.attributes.new_id === undefined) {
// We just removed this point!
const element =this. allElements.getEventSourceById("node/" + oldId);
element.data._deleted = "yes"
element.ping();
return;
}
const newId = parseInt(node.attributes.new_id.value);
const result: [string, string] = [type + "/" + oldId, type + "/" + newId]
if (!(oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId))) {
return undefined;
}
if (oldId == newId) {
return undefined;
}
console.log("Rewriting id: ", type + "/" + oldId, "-->", type + "/" + newId);
const element = this.allElements.getEventSourceById("node/" + oldId);
element.data.id = type + "/" + newId;
this.allElements.addElementById(type + "/" + newId, element);
this.allElements.ContainingFeatures.set(type + "/" + newId, this.allElements.ContainingFeatures.get(type + "/" + oldId))
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 oldId = parseInt(node.attributes.old_id.value);
if (node.attributes.new_id === undefined) {
// We just removed this point!
const element = allElements.getEventSourceById("node/" + oldId);
element.data._deleted = "yes"
element.ping();
continue;
const mapping = this.handleIdRewrite(node, "node")
if (mapping !== undefined) {
mappings.set(mapping[0], mapping[1])
}
const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId)) {
if (oldId == newId) {
continue;
}
console.log("Rewriting id: ", oldId, "-->", newId);
const element = allElements.getEventSourceById("node/" + oldId);
element.data.id = "node/" + newId;
allElements.addElementById("node/" + newId, element);
element.ping();
}
}
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)
}
/**
@ -68,13 +96,9 @@ export class ChangesetHandler {
* If 'dryrun' is specified, the changeset XML will be printed to console instead of being uploaded
*
*/
public UploadChangeset(
public async UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string,
whenDone: (csId: string) => void,
onFail: () => void) {
generateChangeXML: (csid: string) => string): Promise<void> {
if (this.userDetails.data.csCount == 0) {
// The user became a contributor!
this.userDetails.data.csCount = 1;
@ -84,46 +108,36 @@ export class ChangesetHandler {
if (this._dryRun) {
const changesetXML = generateChangeXML("123456");
console.log(changesetXML);
whenDone("123456")
return;
}
const self = this;
if (this.currentChangeset.data === undefined || this.currentChangeset.data === "") {
// We have to open a new changeset
this.OpenChangeset(layout, (csId) => {
try {
const csId = await this.OpenChangeset(layout)
this.currentChangeset.setData(csId);
const changeset = generateChangeXML(csId);
console.log(changeset);
self.AddChange(csId, changeset,
allElements,
whenDone,
(e) => {
console.error("UPLOADING FAILED!", e)
onFail()
}
)
}, {
onFail: onFail
})
console.log("Current changeset is:", changeset);
await this.AddChange(csId, changeset)
} catch (e) {
console.error("Could not open/upload changeset due to ", e)
this.currentChangeset.setData("")
}
} else {
// There still exists an open changeset (or at least we hope so)
const csId = this.currentChangeset.data;
self.AddChange(
csId,
generateChangeXML(csId),
allElements,
whenDone,
(e) => {
console.warn("Could not upload, changeset is probably closed: ", e);
// Mark the CS as closed...
this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist
self.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
}
)
try {
await this.AddChange(
csId,
generateChangeXML(csId))
} catch (e) {
console.warn("Could not upload, changeset is probably closed: ", e);
// Mark the CS as closed...
this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist
await this.UploadChangeset(layout, generateChangeXML)
}
}
}
@ -143,6 +157,13 @@ export class ChangesetHandler {
reason: string,
allElements: ElementStorage,
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) {
let [lat, lon] = object.centerpoint();
@ -151,9 +172,7 @@ export class ChangesetHandler {
changes +=
`<delete><${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" /></delete>`;
changes += "</osmChange>";
continuation()
return changes;
}
@ -163,143 +182,122 @@ export class ChangesetHandler {
return;
}
const self = this;
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,
deletionReason: reason
}
)
const csId = await this.OpenChangeset(layout, {
isDeletionCS: true,
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) = () => {
}) {
if (changesetId === undefined) {
changesetId = this.currentChangeset.data;
}
if (changesetId === undefined) {
return;
}
console.log("closing changeset", changesetId);
this.currentChangeset.setData("");
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
private async CloseChangeset(changesetId: string = undefined): Promise<void> {
const self = this
return new Promise<void>(function (resolve, reject) {
if (changesetId === undefined) {
changesetId = self.currentChangeset.data;
}
console.log("Closed changeset ", changesetId)
if (continuation !== undefined) {
continuation();
if (changesetId === undefined) {
return;
}
});
console.log("closing changeset", changesetId);
self.currentChangeset.setData("");
self.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
}
console.log("Closed changeset ", changesetId)
resolve()
});
})
}
private OpenChangeset(
layout: LayoutConfig,
continuation: (changesetId: string) => void,
options?: {
isDeletionCS?: boolean,
deletionReason?: string,
onFail?: () => void
}
) {
options = options ?? {}
options.isDeletionCS = options.isDeletionCS ?? false
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`
if (options.isDeletionCS) {
comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}`
if (options.deletionReason) {
comment += ": " + options.deletionReason;
}
}
let path = window.location.pathname;
path = path.substr(1, path.lastIndexOf("/"));
const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`],
["comment", comment],
["deletion", options.isDeletionCS ? "yes" : undefined],
["theme", layout.id],
["language", Locale.language.data],
["host", window.location.host],
["path", path],
["source", State.state.currentGPSLocation.data !== undefined ? "survey" : undefined],
["imagery", State.state.backgroundLayer.data.id],
["theme-creator", layout.maintainer]
]
.filter(kv => (kv[1] ?? "") !== "")
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
.join("\n")
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/create',
options: {header: {'Content-Type': 'text/xml'}},
content: [`<osm><changeset>`,
metadata,
`</changeset></osm>`].join("")
}, function (err, response) {
if (response === undefined) {
console.log("err", err);
if (options.onFail) {
options.onFail()
): Promise<string> {
const self = this;
return new Promise<string>(function (resolve, reject) {
options = options ?? {}
options.isDeletionCS = options.isDeletionCS ?? false
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`
if (options.isDeletionCS) {
comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}`
if (options.deletionReason) {
comment += ": " + options.deletionReason;
}
return;
} else {
continuation(response);
}
});
let path = window.location.pathname;
path = path.substr(1, path.lastIndexOf("/"));
const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`],
["comment", comment],
["deletion", options.isDeletionCS ? "yes" : undefined],
["theme", layout.id],
["language", Locale.language.data],
["host", window.location.host],
["path", path],
["source", State.state.currentGPSLocation.data !== undefined ? "survey" : undefined],
["imagery", State.state.backgroundLayer.data.id],
["theme-creator", layout.maintainer]
]
.filter(kv => (kv[1] ?? "") !== "")
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
.join("\n")
self.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/create',
options: {header: {'Content-Type': 'text/xml'}},
content: [`<osm><changeset>`,
metadata,
`</changeset></osm>`].join("")
}, function (err, response) {
if (response === undefined) {
console.log("err", err);
reject(err)
} else {
resolve(response);
}
});
})
}
/**
* Upload a changesetXML
* @param changesetId
* @param changesetXML
* @param allElements
* @param continuation
* @param onFail
* @constructor
* @private
*/
private AddChange(changesetId: string,
changesetXML: string,
allElements: ElementStorage,
continuation: ((changesetId: string) => void),
onFail: ((changesetId: string, reason: string) => void) = undefined) {
this.auth.xhr({
method: 'POST',
options: {header: {'Content-Type': 'text/xml'}},
path: '/api/0.6/changeset/' + changesetId + '/upload',
content: changesetXML
}, function (err, response) {
if (response == null) {
console.log("err", err);
if (onFail) {
onFail(changesetId, err);
changesetXML: string): Promise<string> {
const self = this;
return new Promise(function (resolve, reject) {
self.auth.xhr({
method: 'POST',
options: {header: {'Content-Type': 'text/xml'}},
path: '/api/0.6/changeset/' + changesetId + '/upload',
content: changesetXML
}, function (err, response) {
if (response == null) {
console.log("err", err);
reject(err);
}
return;
}
ChangesetHandler.parseUploadChangesetResponse(response, allElements);
console.log("Uploaded changeset ", changesetId);
continuation(changesetId);
});
self.parseUploadChangesetResponse(response);
console.log("Uploaded changeset ", changesetId);
resolve(changesetId);
});
})
}

View file

@ -11,7 +11,7 @@ export class Geocoding {
osm_type: string, osm_id: string
}[]) => 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=" +
`${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}` +
"&accept-language=nl&q=" + query;

View file

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

View file

@ -1,6 +1,7 @@
import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json";
import {UIEventSource} from "../UIEventSource";
import {BBox} from "../BBox";
export abstract class OsmObject {
@ -9,11 +10,12 @@ export abstract class OsmObject {
protected static backendURL = OsmObject.defaultBackend;
private static polygonFeatures = OsmObject.constructPolygonFeatures()
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[]>>();
type: string;
id: number;
/**
* The OSM tags as simple object
*/
tags: {} = {};
version: number;
public changed: boolean = false;
@ -37,7 +39,7 @@ export abstract class OsmObject {
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>;
if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id)
@ -47,80 +49,71 @@ export abstract class OsmObject {
return src;
}
} 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 type = splitted[0];
const idN = Number(splitted[1]);
if (idN < 0) {
return;
return undefined;
}
OsmObject.objectCache.set(id, src);
const newContinuation = (element: OsmObject) => {
src.setData(element)
const full = !id.startsWith("way") ? "" : "/full";
const url = `${OsmObject.backendURL}api/0.6/${id}${full}`;
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
}
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;
throw "PANIC: requested object is not part of the response"
}
/**
* Downloads the ways that are using this node.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingWays(id: string): UIEventSource<OsmWay[]> {
if (OsmObject.referencingWaysCache.has(id)) {
return OsmObject.referencingWaysCache.get(id);
}
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 => {
public static DownloadReferencingWays(id: string): Promise<OsmWay[]> {
return Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`).then(
data => {
return data.elements.map(wayInfo => {
const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo)
return way
})
waysSrc.setData(ways)
})
return waysSrc;
}
)
}
/**
* Downloads the relations that are using this feature.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingRelations(id: string): UIEventSource<OsmRelation[]> {
if (OsmObject.referencingRelationsCache.has(id)) {
return OsmObject.referencingRelationsCache.get(id);
}
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)
rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo)
return rel
})
relsSrc.setData(rels)
})
return relsSrc;
public static async DownloadReferencingRelations(id: string): Promise<OsmRelation[]> {
const data = await Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
return data.elements.map(wayInfo => {
const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo)
return rel
})
}
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)
public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) {
const minlon = bounds[0][1]
const maxlon = bounds[1][1]
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 objects = OsmObject.ParseObjects(elements)
callback(objects);
})
public static async LoadArea(bbox: BBox): Promise<OsmObject[]> {
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
const data = await Utils.downloadJson(url)
const elements: any[] = data.elements;
return OsmObject.ParseObjects(elements);
}
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 {
for (const tagsKey in tags) {
if (!tags.hasOwnProperty(tagsKey)) {
@ -208,7 +177,6 @@ export abstract class OsmObject {
private static constructPolygonFeatures(): Map<string, { values: Set<string>, blacklist: boolean }> {
const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
for (const polygonFeature of polygon_features) {
const key = polygonFeature.key;
@ -228,6 +196,7 @@ export abstract class OsmObject {
private static ParseObjects(elements: any[]): OsmObject[] {
const objects: OsmObject[] = [];
const allNodes: Map<number, OsmNode> = new Map<number, OsmNode>()
for (const element of elements) {
const type = element.type;
const idN = element.id;
@ -249,6 +218,11 @@ export abstract class OsmObject {
osmObject.SaveExtraData(element, [])
break;
}
if (osmObject !== undefined && OsmObject.backendURL !== OsmObject.defaultBackend) {
osmObject.tags["_backend"] = OsmObject.backendURL
}
osmObject?.LoadData(element)
objects.push(osmObject)
}
@ -260,7 +234,7 @@ export abstract class OsmObject {
public abstract asGeoJson(): any;
abstract SaveExtraData(element: any, allElements: any[]);
abstract SaveExtraData(element: any, allElements: OsmObject[]);
/**
* Generates the changeset-XML for tags
@ -283,42 +257,6 @@ export abstract class OsmObject {
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;
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 {
nodes: number[];
nodes: number[] = [];
// The coordinates of the way, [lat, lon][]
coordinates: [number, number][] = []
lat: number;
lon: number;
@ -436,6 +366,10 @@ export class OsmWay extends OsmObject {
nodeDict.set(node.id, node)
}
if (element.nodes === undefined) {
console.log("PANIC")
}
for (const nodeId of element.nodes) {
const node = nodeDict.get(nodeId)
if (node === undefined) {
@ -455,12 +389,16 @@ export class OsmWay extends OsmObject {
}
public asGeoJson() {
let coordinates: ([number, number][] | [number, number][][]) = this.coordinates.map(c => [c[1], c[0]]);
if (this.isPolygon()) {
coordinates = [coordinates]
}
return {
"type": "Feature",
"properties": this.tags,
"geometry": {
"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;
}
asGeoJson() {
asGeoJson(): any {
throw "Not Implemented"
}
}

View file

@ -145,14 +145,14 @@ export class OsmPreferences {
private SetPreference(k: string, v: string) {
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;
}
if (this.preferences.data[k] === v) {
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 === "") {
this.auth.xhr({
@ -161,10 +161,10 @@ export class OsmPreferences {
options: {header: {'Content-Type': 'text/plain'}},
}, function (error) {
if (error) {
console.log("Could not remove preference", error);
console.warn("Could not remove preference", error);
return;
}
console.log("Preference ", k, "removed!");
console.debug("Preference ", k, "removed!");
});
return;

View file

@ -1,9 +1,9 @@
import * as OsmToGeoJson from "osmtogeojson";
import Bounds from "../../Models/Bounds";
import {TagsFilter} from "../Tags/TagsFilter";
import ExtractRelations from "./ExtractRelations";
import RelationsTracker from "./RelationsTracker";
import {Utils} from "../../Utils";
import {UIEventSource} from "../UIEventSource";
import {BBox} from "../BBox";
/**
* Interfaces overpass to get all the latest data
@ -11,46 +11,51 @@ import {UIEventSource} from "../UIEventSource";
export class Overpass {
public static testUrl: string = null
private _filter: TagsFilter
private readonly _interpreterUrl: UIEventSource<string>;
private readonly _interpreterUrl: string;
private readonly _timeout: UIEventSource<number>;
private readonly _extraScripts: string[];
private _includeMeta: boolean;
private _relationTracker: RelationsTracker;
constructor(filter: TagsFilter, extraScripts: string[],
interpreterUrl: UIEventSource<string>,
constructor(filter: TagsFilter,
extraScripts: string[],
interpreterUrl: string,
timeout: UIEventSource<number>,
relationTracker: RelationsTracker,
includeMeta = true) {
this._timeout = timeout;
this._interpreterUrl = interpreterUrl;
this._filter = filter
this._extraScripts = extraScripts;
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) {
console.log("Using testing URL")
query = Overpass.testUrl;
}
Utils.downloadJson(query)
.then(json => {
if (json.elements === [] && ((json.remarks ?? json.remark).indexOf("runtime error") >= 0)) {
console.log("Timeout or other runtime error");
onFail("Runtime error (timeout)")
return;
}
const self = this;
const json = await Utils.downloadJson(query)
if (json.elements.length === 0 && json.remark !== undefined) {
console.warn("Timeout or other runtime error while querying overpass", json.remark);
throw `Runtime error (timeout or similar)${json.remark}`
}
if(json.elements.length === 0){
console.warn("No features for" ,json)
}
ExtractRelations.RegisterRelations(json)
// @ts-ignore
const geojson = OsmToGeoJson.default(json);
const osmTime = new Date(json.osm3s.timestamp_osm_base);
continuation(geojson, osmTime);
}).catch(onFail)
self._relationTracker.RegisterRelations(json)
// @ts-ignore
const geojson = OsmToGeoJson.default(json);
const osmTime = new Date(json.osm3s.timestamp_osm_base);
return [geojson, osmTime];
}
buildQuery(bbox: string): string {
@ -64,6 +69,6 @@ export class Overpass {
}
const query =
`[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 {UIEventSource} from "../UIEventSource";
export interface Relation {
id: number,
@ -13,11 +14,15 @@ export interface Relation {
properties: any
}
export default class ExtractRelations {
export default class RelationsTracker {
public static RegisterRelations(overpassJson: any): void {
const memberships = ExtractRelations.BuildMembershipTable(ExtractRelations.GetRelationElements(overpassJson))
State.state.knownRelations.setData(memberships)
public knownRelations = new UIEventSource<Map<string, { role: string; relation: Relation }[]>>(new Map(), "Relation memberships");
constructor() {
}
public RegisterRelations(overpassJson: any): void {
this.UpdateMembershipTable(RelationsTracker.GetRelationElements(overpassJson))
}
/**
@ -25,7 +30,7 @@ export default class ExtractRelations {
* @param overpassJson
* @constructor
*/
public static GetRelationElements(overpassJson: any): Relation[] {
private static GetRelationElements(overpassJson: any): Relation[] {
const relations = overpassJson.elements
.filter(element => element.type === "relation" && element.tags.type !== "multipolygon")
for (const relation of relations) {
@ -39,12 +44,11 @@ export default class ExtractRelations {
* @param relations
* @constructor
*/
public static BuildMembershipTable(relations: Relation[]): Map<string, { role: string, relation: Relation }[]> {
const memberships = new Map<string, { role: string, relation: Relation }[]>()
private UpdateMembershipTable(relations: Relation[]): void {
const memberships = this.knownRelations.data
let changed = false;
for (const relation of relations) {
for (const member of relation.members) {
const role = {
role: member.role,
relation: relation
@ -53,11 +57,21 @@ export default class ExtractRelations {
if (!memberships.has(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 State from "../State";
import {And} from "./Tags/And";
import {Tag} from "./Tags/Tag";
import {Or} from "./Tags/Or";
import {Utils} from "../Utils";
import opening_hours from "opening_hours";
import Combine from "../UI/Base/Combine";
@ -20,7 +17,7 @@ const cardinalDirections = {
export default class SimpleMetaTagger {
static coder: any;
public static coder: any;
public static readonly objectMetaInfo = new SimpleMetaTagger(
{
keys: ["_last_edit:contributor",
@ -30,7 +27,7 @@ export default class SimpleMetaTagger {
"_version_number"],
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;
@ -47,6 +44,7 @@ export default class SimpleMetaTagger {
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
return true;
}
)
private static latlon = new SimpleMetaTagger({
@ -61,6 +59,7 @@ export default class SimpleMetaTagger {
feature.properties["_lon"] = "" + lon;
feature._lon = lon; // This is dirty, I know
feature._lat = lat;
return true;
})
);
private static surfaceArea = new SimpleMetaTagger(
@ -73,6 +72,7 @@ export default class SimpleMetaTagger {
feature.properties["_surface"] = "" + sqMeters;
feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10;
feature.area = sqMeters;
return true;
})
);
@ -83,18 +83,33 @@ export default class SimpleMetaTagger {
},
(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;
for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) {
continue;
}
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)) {
continue;
}
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;
if (canonical === value) {
break;
@ -110,9 +125,7 @@ export default class SimpleMetaTagger {
}
}
if (rewritten) {
State.state.allElements.getEventSourceById(feature.id).ping();
}
return rewritten
})
)
@ -127,21 +140,21 @@ export default class SimpleMetaTagger {
const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km + "." + kmRest
return true;
})
)
private static country = new SimpleMetaTagger(
{
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);
const lat = centerPoint.geometry.coordinates[1];
const lon = centerPoint.geometry.coordinates[0];
SimpleMetaTagger.GetCountryCodeFor(lon, lat, (countries) => {
SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, (countries: string[]) => {
try {
const oldCountry = feature.properties["_country"];
feature.properties["_country"] = countries[0].trim().toLowerCase();
@ -149,12 +162,12 @@ export default class SimpleMetaTagger {
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping();
}
} catch (e) {
console.warn(e)
}
});
}
})
return false;
})
)
private static isOpen = new SimpleMetaTagger(
{
@ -166,7 +179,7 @@ export default class SimpleMetaTagger {
if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache
// isOpen is irrelevant
return
return false
}
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
@ -191,7 +204,7 @@ export default class SimpleMetaTagger {
if (oldNextChange > (new Date()).getTime() &&
tags["_isOpen:oldvalue"] === tags["opening_hours"]) {
// Already calculated and should not yet be triggered
return;
return false;
}
tags["_isOpen"] = oh.getState() ? "yes" : "no";
@ -219,6 +232,7 @@ export default class SimpleMetaTagger {
}
}
updateTags();
return true;
} catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e);
tags["_isOpen"] = "parse_error";
@ -236,11 +250,11 @@ export default class SimpleMetaTagger {
const tags = feature.properties;
const direction = tags["camera:direction"] ?? tags["direction"];
if (direction === undefined) {
return;
return false;
}
const n = cardinalDirections[direction] ?? Number(direction);
if (isNaN(n)) {
return;
return false;
}
// 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:leftright"] = normalized <= 180 ? "right" : "left";
return true;
})
)
private static currentTime = new SimpleMetaTagger(
{
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",
includesDates: true
},
(feature, _, freshness) => {
(feature, freshness) => {
const now = new Date();
if (typeof freshness === "string") {
@ -276,7 +291,7 @@ export default class SimpleMetaTagger {
feature.properties["_now:datetime"] = datetime(now);
feature.properties["_loaded:date"] = date(freshness);
feature.properties["_loaded:datetime"] = datetime(freshness);
return true;
}
)
public static metatags = [
@ -294,12 +309,18 @@ export default class SimpleMetaTagger {
public readonly keys: string[];
public readonly doc: string;
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.doc = docs.doc;
this._f = f;
this.applyMetaTagsOnFeature = f;
this.includesDates = docs.includesDates ?? false;
for (const key of docs.keys) {
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) {
SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, callback)
}
static HelpText(): BaseUIElement {
public static HelpText(): BaseUIElement {
const subElements: (string | BaseUIElement)[] = [
new Combine([
new Title("Metatags", 1),
@ -335,12 +352,4 @@ export default class SimpleMetaTagger {
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) {
return;
}
if(typeof fromTag === "number"){
fromTag = "" + fromTag;
}
if (typeof possibleRegex === "string") {
return fromTag === possibleRegex;
}

View file

@ -1,4 +1,5 @@
import {Utils} from "../Utils";
import * as Events from "events";
export class UIEventSource<T> {
@ -32,14 +33,14 @@ export class UIEventSource<T> {
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);
source.addCallback((latestData) => {
sink.setData(latestData?.data);
});
for (const possibleSource of possibleSources) {
for (const possibleSource of possibleSources ?? []) {
possibleSource?.addCallback(() => {
sink.setData(source.data?.data);
})
@ -60,7 +61,76 @@ export class UIEventSource<T> {
run();
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;
}
public addCallbackAndRun(callback: ((latestData: T) => void)): UIEventSource<T> {
callback(this.data);
return this.addCallback(callback);
public addCallbackAndRun(callback: ((latestData: T) => (boolean | void | any))): UIEventSource<T> {
const doDeleteCallback = callback(this.data);
if (doDeleteCallback !== true) {
this.addCallback(callback);
}
return this;
}
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:
* 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 {

View file

@ -9,7 +9,7 @@ export default class Hash {
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
*/
public static Current(): string {

View file

@ -127,7 +127,6 @@ export class QueryParameters {
parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data))
}
// Don't pollute the history every time a parameter changes
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 {
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
public static userJourney = {
@ -26,12 +41,6 @@ export default class Constants {
*/
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 {
if (Utils.runningFromConsole) {
return;

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