forked from MapComplete/MapComplete
Merge branch 'develop'
This commit is contained in:
commit
9f3ba46530
309 changed files with 36982 additions and 9766 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
*.json merge=json
|
2
.github/workflows/deploy_pietervdvn.yml
vendored
2
.github/workflows/deploy_pietervdvn.yml
vendored
|
@ -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'
|
||||
|
||||
|
|
71
.github/workflows/pull_request_check.yml
vendored
71
.github/workflows/pull_request_check.yml
vendored
|
@ -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 }}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 14.14.0
|
||||
nodejs 16.9.1
|
|
@ -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 = []
|
||||
|
|
|
@ -1,89 +1,113 @@
|
|||
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
|
||||
|
||||
|
||||
|
||||
The latitude and longitude of the point (or centerpoint in the case of a way/area)
|
||||
|
||||
|
||||
### _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
|
||||
|
||||
|
||||
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`)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
- distanceTo
|
||||
- overlapWith
|
||||
- closest
|
||||
- memberships
|
||||
- score
|
||||
- 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
|
||||
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
|
||||
|
||||
0. longitude
|
||||
1. latitude
|
||||
0. longitude
|
||||
1. latitude
|
||||
|
||||
### overlapWith
|
||||
|
||||
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
|
||||
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. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
|
||||
For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`
|
||||
|
||||
0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
|
||||
|
||||
### closest
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
0. list of features
|
||||
0. list of features
|
||||
|
||||
### memberships
|
||||
|
||||
Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.
|
||||
Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.
|
||||
|
||||
For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
|
||||
|
||||
|
||||
|
||||
### 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 => ...)`
|
||||
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')`
|
||||
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
|
||||
0. path
|
||||
Generated from SimpleMetaTagger, ExtraFunction
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
-----
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,25 +1,21 @@
|
|||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
{all_tags()}
|
||||
|
||||
`{all_tags()}`
|
||||
### 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
|
||||
------ | --------- | -------------
|
||||
|
@ -28,11 +24,10 @@ smart search | true | Also include images given via 'Wikidata', 'wikimedia_commo
|
|||
|
||||
#### Example usage
|
||||
|
||||
{image_carousel(image,true)}
|
||||
|
||||
`{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
|
||||
------ | --------- | -------------
|
||||
|
@ -40,11 +35,10 @@ image-key | image | Image tag to add the URL to (or image-tag:0, image-tag:1 whe
|
|||
|
||||
#### Example usage
|
||||
|
||||
{image_upload(image)}
|
||||
|
||||
`{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
|
||||
------ | --------- | -------------
|
||||
|
@ -53,13 +47,10 @@ idKey | id | (Matches all resting arguments) This argument should be the key of
|
|||
|
||||
#### Example usage
|
||||
|
||||
`{minimap()}`
|
||||
, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`
|
||||
|
||||
`{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
|
||||
------ | --------- | -------------
|
||||
|
@ -68,13 +59,10 @@ fallback | undefined | The identifier to use, if <i>tags[subjectKey]</i> as spec
|
|||
|
||||
#### Example usage
|
||||
|
||||
<b>{reviews()}<b> for a vanilla review, <b>{reviews(name, play_forest)}</b> to review a play forest. If a name is known,
|
||||
the name will be used as identifier, otherwise 'play_forest' is used
|
||||
|
||||
`{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
|
||||
|
||||
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
|
||||
------ | --------- | -------------
|
||||
|
@ -82,14 +70,10 @@ key | opening_hours | The tagkey from which the table is constructed.
|
|||
|
||||
#### Example usage
|
||||
|
||||
{opening_hours_table(opening_hours)}
|
||||
|
||||
`{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
|
||||
------ | --------- | -------------
|
||||
|
@ -99,12 +83,10 @@ path | undefined | The path (or shorthand) that should be returned
|
|||
|
||||
#### 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)}
|
||||
|
||||
{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
|
||||
------ | --------- | -------------
|
||||
|
@ -115,11 +97,10 @@ colors* | undefined | (Matches all resting arguments - optional) Matches a regex
|
|||
|
||||
#### Example usage
|
||||
|
||||
`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram
|
||||
|
||||
`{histogram('some_key')}` with properties being `{some_key: ['a','b','a','c']} to create a histogram
|
||||
### share_link
|
||||
|
||||
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
|
||||
------ | --------- | -------------
|
||||
|
@ -127,11 +108,10 @@ url | undefined | The url to share (default: current URL)
|
|||
|
||||
#### Example usage
|
||||
|
||||
{share_link()} to share the current page, {share_link(<some_url>)} to share the given url
|
||||
|
||||
{share_link()} to share the current page, {share_link(<some_url>)} to share the given url
|
||||
### canonical
|
||||
|
||||
Converts a short, canonical value into the long, translated text
|
||||
Converts a short, canonical value into the long, translated text
|
||||
|
||||
name | default | description
|
||||
------ | --------- | -------------
|
||||
|
@ -139,4 +119,25 @@ key | undefined | The key of the tag to give the canonical text for
|
|||
|
||||
#### Example usage
|
||||
|
||||
{canonical(length)} will give 42 metre (in french) Generated from UI/SpecialVisualisations.ts
|
||||
{canonical(length)} will give 42 metre (in french)
|
||||
### import_button
|
||||
|
||||
This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes.
|
||||
|
||||
If you want to import a dataset, make sure that:
|
||||
|
||||
1. The dataset to import has a suitable license
|
||||
2. The community has been informed of the import
|
||||
3. The theme will filter out duplicate nodes4. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed
|
||||
|
||||
name | default | description
|
||||
------ | --------- | -------------
|
||||
tags | undefined | Tags to copy-specification. This contains one or more pairs (seperated by a `;`), e.g. `amenity=fast_food; addr:housenumber={number}`. This new point will then have the tags `amenity=fast_food` and `addr:housenumber` with the value that was saved in `number` in the original feature. (Hint: prepare these values, e.g. with calculatedTags)
|
||||
text | Import this data into OpenStreetMap | The text to show on the button
|
||||
icon | ./assets/svg/addSmall.svg | A nice icon to show in the button
|
||||
|
||||
#### Example usage
|
||||
|
||||
`{import_button(,Import this data into OpenStreetMap,./assets/svg/addSmall.svg)}`
|
||||
|
||||
Generated from UI/SpecialVisualisations.ts
|
|
@ -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')"
|
||||
|
|
124
Docs/TagInfo/mapcomplete_observation_towers.json
Normal file
124
Docs/TagInfo/mapcomplete_observation_towers.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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')",
|
||||
|
|
39
Docs/TagInfo/mapcomplete_uk_addresses.json
Normal file
39
Docs/TagInfo/mapcomplete_uk_addresses.json
Normal 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')"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}}
|
||||
|}
|
|
@ -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(
|
||||
|
@ -68,8 +68,22 @@ export class InitUiElements {
|
|||
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);
|
||||
|
||||
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,9 +145,19 @@ export class InitUiElements {
|
|||
).AttachTo("messagesbox");
|
||||
}
|
||||
|
||||
State.state.osmConnection.userDetails
|
||||
.map((userDetails: UserDetails) => userDetails?.home)
|
||||
.addCallbackAndRunD((home) => {
|
||||
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"
|
||||
);
|
||||
|
@ -185,21 +169,19 @@ export class InitUiElements {
|
|||
iconAnchor: [15, 15],
|
||||
});
|
||||
const marker = L.marker([home.lat, home.lon], {icon: icon});
|
||||
marker.addTo(State.state.leafletMap.data);
|
||||
});
|
||||
|
||||
if (layoutToUse.id === personal.id) {
|
||||
updateFavs();
|
||||
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");
|
||||
|
@ -252,16 +234,16 @@ export class InitUiElements {
|
|||
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 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})
|
||||
{url: window.location.protocol + "//" + window.location.hostname + "/index.html", newTab: false})
|
||||
|
||||
])
|
||||
.SetClass("flex flex-col")
|
||||
|
@ -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),
|
||||
appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]),
|
||||
};
|
||||
|
||||
if (layer.filters.length > 0) {
|
||||
const filtersPerName = new Map<string, FilterConfig>()
|
||||
layer.filters.forEach(f => filtersPerName.set(f.id, f))
|
||||
const qp = QueryParameters.GetQueryParameter("filter-" + layer.id, "","Filtering state for a layer")
|
||||
flayer.appliedFilters.map(filters => {
|
||||
filters = filters ?? []
|
||||
return filters.map(f => f.filter.id + "." + f.selected).join(",")
|
||||
}, [], textual => {
|
||||
if(textual.length === 0){
|
||||
return empty
|
||||
}
|
||||
return textual.split(",").map(part => {
|
||||
const [filterId, selected] = part.split(".");
|
||||
return {filter: filtersPerName.get(filterId), selected: Number(selected)}
|
||||
}).filter(f => f.filter !== undefined && !isNaN(f.selected))
|
||||
}).syncWith(qp, true)
|
||||
}
|
||||
|
||||
flayers.push(flayer);
|
||||
}
|
||||
return flayers;
|
||||
});
|
||||
state.filteredLayers = new UIEventSource<FilteredLayer[]>(flayers);
|
||||
|
||||
const updater = new LoadFromOverpass(
|
||||
state.locationControl,
|
||||
state.layoutToUse,
|
||||
state.leafletMap,
|
||||
state.overpassUrl,
|
||||
state.overpassTimeout,
|
||||
Constants.useOsmApiAt
|
||||
);
|
||||
State.state.layerUpdater = updater;
|
||||
|
||||
const source = new FeaturePipeline(
|
||||
state.filteredLayers,
|
||||
State.state.changes,
|
||||
updater,
|
||||
state.osmApiFeatureSource,
|
||||
state.layoutToUse,
|
||||
state.locationControl,
|
||||
state.selectedElement
|
||||
);
|
||||
|
||||
State.state.featurePipeline = source;
|
||||
|
||||
const clusterCounter = TileHierarchyAggregator.createHierarchy()
|
||||
new ShowDataLayer({
|
||||
features: clusterCounter.getCountsForZoom(State.state.locationControl, State.state.layoutToUse.clustering.minNeededElements),
|
||||
leafletMap: State.state.leafletMap,
|
||||
layerToShow: ShowTileInfo.styling,
|
||||
enablePopups: false
|
||||
})
|
||||
|
||||
State.state.featurePipeline = new FeaturePipeline(
|
||||
source => {
|
||||
|
||||
clusterCounter.addTile(source)
|
||||
|
||||
const clustering = State.state.layoutToUse.clustering
|
||||
const doShowFeatures = source.features.map(
|
||||
f => {
|
||||
const z = State.state.locationControl.data.zoom
|
||||
|
||||
if(!source.layer.isDisplayed.data){
|
||||
return false;
|
||||
}
|
||||
|
||||
if (z < source.layer.layerDef.minzoom) {
|
||||
// Layer is always hidden for this zoom level
|
||||
return false;
|
||||
}
|
||||
|
||||
if (z >= clustering.maxZoom) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (f.length > clustering.minNeededElements) {
|
||||
// This tile alone already has too much features
|
||||
return false
|
||||
}
|
||||
|
||||
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex);
|
||||
if (tileZ >= z) {
|
||||
|
||||
while (tileZ > z) {
|
||||
tileZ--
|
||||
tileX = Math.floor(tileX / 2)
|
||||
tileY = Math.floor(tileY / 2)
|
||||
}
|
||||
|
||||
if (clusterCounter.getTile(Tiles.tile_index(tileZ, tileX, tileY))?.totalValue > clustering.minNeededElements) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const bounds = State.state.currentBounds.data
|
||||
if(bounds === undefined){
|
||||
// Map is not yet displayed
|
||||
return false;
|
||||
}
|
||||
if (!source.bbox.overlapsWith(bounds)) {
|
||||
// Not within range
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, [State.state.currentBounds]
|
||||
)
|
||||
|
||||
new ShowDataLayer(
|
||||
source.features,
|
||||
State.state.leafletMap,
|
||||
State.state.layoutToUse
|
||||
{
|
||||
features: source,
|
||||
leafletMap: State.state.leafletMap,
|
||||
layerToShow: source.layer.layerDef,
|
||||
doShowLayer: doShowFeatures
|
||||
}
|
||||
);
|
||||
|
||||
const selectedFeatureHandler = new SelectedFeatureHandler(
|
||||
Hash.hash,
|
||||
State.state.selectedElement,
|
||||
source,
|
||||
State.state.osmApiFeatureSource
|
||||
}, 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 ------------
|
||||
|
|
|
@ -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!
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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>;
|
||||
|
||||
/**
|
||||
* The most important layer should go first, as that one gets first pick for the questions
|
||||
*/
|
||||
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;
|
||||
|
||||
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 self = this;
|
||||
|
||||
|
||||
const layersToDownload = []
|
||||
for (const layer of this.state.layoutToUse.layers) {
|
||||
|
||||
if (typeof (layer) === "string") {
|
||||
throw "A layer was not expanded!"
|
||||
}
|
||||
if(this.state.locationControl.data.zoom < layer.minzoom){
|
||||
continue;
|
||||
}
|
||||
if (layer.doNotDownload) {
|
||||
continue;
|
||||
}
|
||||
if (layer.source.geojsonSource !== undefined) {
|
||||
// Not our responsibility to download this layer!
|
||||
continue;
|
||||
}
|
||||
layersToDownload.push(layer)
|
||||
}
|
||||
|
||||
const diff = this._layoutToUse.data.widenFactor;
|
||||
let data: any = undefined
|
||||
let date: Date = undefined
|
||||
const overpassUrls = self.state.overpassUrl.data
|
||||
let lastUsed = 0;
|
||||
|
||||
const n = Math.min(90, bounds.getNorth() + diff);
|
||||
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};
|
||||
do {
|
||||
try {
|
||||
|
||||
const z = Math.floor(this._location.data.zoom ?? 0);
|
||||
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload);
|
||||
|
||||
const self = this;
|
||||
const overpass = this.GetFilter();
|
||||
if (overpass === undefined) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
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) {
|
||||
[data, date] = await overpass.queryGeoJson(bounds)
|
||||
console.log("Querying overpass is done", data)
|
||||
} catch (e) {
|
||||
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);
|
||||
console.error(`QUERY FAILED due to`, e);
|
||||
|
||||
function countDown() {
|
||||
window?.setTimeout(
|
||||
function () {
|
||||
if (self.timeout.data > 1) {
|
||||
self.timeout.setData(self.timeout.data - 1);
|
||||
window.setTimeout(
|
||||
countDown,
|
||||
1000
|
||||
)
|
||||
await Utils.waitFor(1000)
|
||||
|
||||
if (lastUsed + 1 < overpassUrls.length) {
|
||||
lastUsed++
|
||||
console.log("Trying next time with", overpassUrls[lastUsed])
|
||||
} else {
|
||||
self.timeout.setData(0);
|
||||
self.update()
|
||||
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();
|
||||
}
|
||||
}, 1000
|
||||
)
|
||||
}
|
||||
|
||||
countDown();
|
||||
|
||||
}
|
||||
);
|
||||
} while (data === undefined);
|
||||
|
||||
|
||||
}
|
||||
|
||||
private IsInBounds(bounds: Bounds): boolean {
|
||||
if (this._previousBounds === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const b = this._leafletMap.data.getBounds();
|
||||
return b.getSouth() >= bounds.south &&
|
||||
b.getNorth() <= bounds.north &&
|
||||
b.getEast() <= bounds.east &&
|
||||
b.getWest() >= bounds.west;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
featureSource.features.addCallback(_ => self.selectFeature());
|
||||
hash.addCallback(setSelectedElementFromHash)
|
||||
|
||||
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("")
|
||||
}
|
||||
}
|
||||
|
@ -55,14 +67,25 @@ export default class SelectedFeatureHandler {
|
|||
}
|
||||
})
|
||||
|
||||
this.selectFeature();
|
||||
state.featurePipeline.newDataLoadedSignal.addCallbackAndRunD(_ => {
|
||||
// New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet
|
||||
if(hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)){
|
||||
// This is an invalid hash anyway
|
||||
return;
|
||||
}
|
||||
if(state.selectedElement.data !== undefined){
|
||||
// We already have something selected
|
||||
return;
|
||||
}
|
||||
setSelectedElementFromHash(hash.data)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// If a feature is selected via the hash, zoom there
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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(
|
||||
export default class TitleHandler {
|
||||
constructor(state : {
|
||||
selectedElement: UIEventSource<any>,
|
||||
layoutToUse: LayoutConfig,
|
||||
allElements: ElementStorage
|
||||
}) {
|
||||
const currentTitle: UIEventSource<string> = state.selectedElement.map(
|
||||
selected => {
|
||||
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete"
|
||||
const layout = state.layoutToUse
|
||||
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
|
||||
|
||||
if (selected === undefined) {
|
||||
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 tagsSource = state.allElements.getEventSourceById(tags.id)
|
||||
const title = new TagRenderingAnswer(tagsSource, layer.title)
|
||||
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
|
||||
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? defaultTitle;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultTitle
|
||||
}
|
||||
, [Locale.language, layoutToUse]
|
||||
)
|
||||
}, [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
165
Logic/BBox.ts
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
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 feature of features) {
|
||||
const contributor = feature.feature.properties["_last_edit:contributor"]
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -39,12 +39,11 @@ export class ElementStorage {
|
|||
}
|
||||
|
||||
getEventSourceById(elementId): UIEventSource<any> {
|
||||
if (this._elements.has(elementId)) {
|
||||
return this._elements.get(elementId);
|
||||
}
|
||||
console.error("Can not find eventsource with id ", elementId);
|
||||
if(elementId === undefined){
|
||||
return undefined;
|
||||
}
|
||||
return this._elements.get(elementId);
|
||||
}
|
||||
|
||||
has(id) {
|
||||
return this._elements.has(id);
|
||||
|
|
|
@ -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,22 +56,31 @@ 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;
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
36
Logic/FeatureSource/Actors/SaveTileToLocalStorageActor.ts
Normal file
36
Logic/FeatureSource/Actors/SaveTileToLocalStorageActor.ts
Normal 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)
|
||||
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Select all features, ignore the freshness and other data
|
||||
let featureList: any[] = featurePipeline.features.data.map((feature) =>
|
||||
JSON.parse(JSON.stringify((feature.feature)))); // Make a deep copy!
|
||||
|
||||
if (!options.metadata) {
|
||||
for (let i = 0; i < featureList.length; i++) {
|
||||
let feature = featureList[i];
|
||||
for (let property in feature.properties) {
|
||||
if (property[0] == "_" && property !== "_lat" && property !== "_lon") {
|
||||
delete featureList[i]["properties"][property];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {type: "FeatureCollection", features: featureList}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface Tiled {
|
||||
tileIndex: number,
|
||||
bbox: BBox
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature source which only contains features for the defined layer
|
||||
*/
|
||||
export interface FeatureSourceForLayer extends FeatureSource{
|
||||
readonly layer: FilteredLayer
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature source which is aware of the indexes it contains
|
||||
*/
|
||||
export interface IndexedFeatureSource extends FeatureSource {
|
||||
readonly containedIds: UIEventSource<Set<string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature source which has some extra data about it's state
|
||||
*/
|
||||
export interface FeatureSourceState {
|
||||
readonly sufficientlyZoomed: UIEventSource<boolean>;
|
||||
readonly runningQuery: UIEventSource<boolean>;
|
||||
readonly timeout: UIEventSource<number>;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
93
Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts
Normal file
93
Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts
Normal 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())
|
||||
}
|
||||
}
|
84
Logic/FeatureSource/Sources/ChangeGeometryApplicator.ts
Normal file
84
Logic/FeatureSource/Sources/ChangeGeometryApplicator.ts
Normal 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)
|
||||
|
||||
}
|
||||
|
||||
}
|
100
Logic/FeatureSource/Sources/FeatureSourceMerger.ts
Normal file
100
Logic/FeatureSource/Sources/FeatureSourceMerger.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
90
Logic/FeatureSource/Sources/FilteringFeatureSource.ts
Normal file
90
Logic/FeatureSource/Sources/FilteringFeatureSource.ts
Normal 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;
|
||||
|
||||
}
|
||||
}
|
120
Logic/FeatureSource/Sources/GeoJsonSource.ts
Normal file
120
Logic/FeatureSource/Sources/GeoJsonSource.ts
Normal 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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
public readonly tileIndex : number
|
||||
public readonly bbox : BBox
|
||||
|
||||
constructor(source: FeatureSource) {
|
||||
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];
|
||||
})
|
||||
}
|
22
Logic/FeatureSource/Sources/SimpleFeatureSource.ts
Normal file
22
Logic/FeatureSource/Sources/SimpleFeatureSource.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
28
Logic/FeatureSource/Sources/StaticFeatureSource.ts
Normal file
28
Logic/FeatureSource/Sources/StaticFeatureSource.ts
Normal 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
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
72
Logic/FeatureSource/TileFreshnessCalculator.ts
Normal file
72
Logic/FeatureSource/TileFreshnessCalculator.ts
Normal 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))
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
79
Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts
Normal file
79
Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
118
Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts
Normal file
118
Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
27
Logic/FeatureSource/TiledFeatureSource/README.md
Normal file
27
Logic/FeatureSource/TiledFeatureSource/README.md
Normal 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
|
25
Logic/FeatureSource/TiledFeatureSource/TileHierarchy.ts
Normal file
25
Logic/FeatureSource/TiledFeatureSource/TileHierarchy.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
206
Logic/FeatureSource/TiledFeatureSource/TiledFeatureSource.ts
Normal file
206
Logic/FeatureSource/TiledFeatureSource/TiledFeatureSource.ts
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
36
Logic/ImageProviders/GenericImageProvider.ts
Normal file
36
Logic/ImageProviders/GenericImageProvider.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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>;
|
||||
|
||||
}
|
72
Logic/ImageProviders/ImageProvider.ts
Normal file
72
Logic/ImageProviders/ImageProvider.ts
Normal 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>[]>;
|
||||
|
||||
}
|
|
@ -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,35 +89,18 @@ 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", "");
|
||||
data[k] = kv[1]?.replace("\r", "");
|
||||
}
|
||||
|
||||
|
||||
|
@ -123,13 +109,18 @@ export class Imgur extends ImageAttributionSource {
|
|||
licenseInfo.licenseShortName = data.license;
|
||||
licenseInfo.artist = data.author;
|
||||
|
||||
src.setData(licenseInfo)
|
||||
return licenseInfo
|
||||
}
|
||||
|
||||
}).fail((reason) => {
|
||||
console.log("Getting metadata from to IMGUR failed", reason)
|
||||
});
|
||||
|
||||
return src;
|
||||
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 []
|
||||
}
|
||||
|
||||
|
||||
|
|
10
Logic/ImageProviders/LicenseInfo.ts
Normal file
10
Logic/ImageProviders/LicenseInfo.ts
Normal 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 = "";
|
||||
}
|
|
@ -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 {
|
||||
|
||||
defaultKeyPrefixes = ["mapillary","image"]
|
||||
|
||||
public static readonly singleton = new Mapillary();
|
||||
|
||||
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"
|
||||
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")) {
|
||||
if (value.startsWith(Mapillary.valuePrefix)) {
|
||||
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
|
||||
return {key:key, isApiv4: !isNaN(Number(key))};
|
||||
return {key: key, isApiv4: !isNaN(Number(key))};
|
||||
}
|
||||
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
|
||||
if (newApiFormat !== null) {
|
||||
|
@ -32,10 +33,9 @@ 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))};
|
||||
}
|
||||
|
||||
|
||||
|
@ -49,6 +49,9 @@ export class Mapillary extends ImageAttributionSource {
|
|||
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)
|
||||
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
|
||||
return [this.PrepareUrlAsync(key, value)]
|
||||
}
|
||||
|
||||
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
|
||||
const failIfNoMatch = key.indexOf("mapillary") < 0
|
||||
const keyV = Mapillary.ExtractKeyFromURL(value, failIfNoMatch)
|
||||
if(keyV === undefined){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!keyV.isApiv4) {
|
||||
return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}`
|
||||
const url = `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Constants.mapillary_client_token_v3}`
|
||||
return {
|
||||
url: url,
|
||||
provider: this,
|
||||
key: key
|
||||
}
|
||||
} else {
|
||||
const key = keyV.key;
|
||||
if(Mapillary.v4_cached_urls.has(key)){
|
||||
return Mapillary.v4_cached_urls.get(key)
|
||||
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
|
||||
}
|
||||
|
||||
const metadataUrl ='https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Mapillary.client_token_v4;
|
||||
const source = new UIEventSource<string>(undefined)
|
||||
Mapillary.v4_cached_urls.set(key, source)
|
||||
Utils.downloadJson(metadataUrl).then(
|
||||
json => {
|
||||
console.warn("Got response on mapillary image", json, json["thumb_1024_url"])
|
||||
return source.setData(json["thumb_1024_url"]);
|
||||
}
|
||||
)
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
|
||||
protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
|
||||
|
||||
const keyV = Mapillary.ExtractKeyFromURL(url)
|
||||
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 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;
|
||||
source.setData(license);
|
||||
})
|
||||
|
||||
return source
|
||||
return license
|
||||
}
|
||||
}
|
46
Logic/ImageProviders/WikidataImageProvider.ts
Normal file
46
Logic/ImageProviders/WikidataImageProvider.ts
Normal 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = "";
|
||||
|
||||
|
||||
}
|
168
Logic/ImageProviders/WikimediaImageProvider.ts
Normal file
168
Logic/ImageProviders/WikimediaImageProvider.ts
Normal 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)]
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
const metatagsToApply: SimpleMetaTagger [] = []
|
||||
for (const metatag of SimpleMetaTagger.metatags) {
|
||||
if (metatag.includesDates && !includeDates) {
|
||||
// We do not add dated entries
|
||||
continue;
|
||||
if (metatag.includesDates) {
|
||||
if (options.includeDates ?? true) {
|
||||
metatagsToApply.push(metatag)
|
||||
}
|
||||
} else {
|
||||
if (options.includeNonDates ?? true) {
|
||||
metatagsToApply.push(metatag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The calculated functions - per layer - which add the new keys
|
||||
const layerFuncs = this.createRetaggingFunc(layer)
|
||||
|
||||
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const ff = features[i];
|
||||
const feature = ff.feature
|
||||
const freshness = ff.freshness
|
||||
let somethingChanged = false
|
||||
for (const metatag of metatagsToApply) {
|
||||
try {
|
||||
metatag.addMetaTags(features);
|
||||
if(!metatag.keys.some(key => feature.properties[key] === undefined)){
|
||||
// All keys are already defined, we probably already ran this one
|
||||
continue
|
||||
}
|
||||
const newValueAdded = metatag.applyMetaTagsOnFeature(feature, freshness)
|
||||
/* Note that the expression:
|
||||
* `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)`
|
||||
* Is WRONG
|
||||
*
|
||||
* IF something changed is `true` due to an earlier run, it will short-circuit and _not_ evaluate the right hand of the OR,
|
||||
* thus not running an update!
|
||||
*/
|
||||
somethingChanged = newValueAdded || somethingChanged
|
||||
} catch (e) {
|
||||
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e)
|
||||
|
||||
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e, e.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// The functions - per layer - which add the new keys
|
||||
const layerFuncs = new Map<string, ((params: Params, feature: any) => void)>();
|
||||
for (const layer of layers) {
|
||||
layerFuncs.set(layer.id, this.createRetaggingFunc(layer));
|
||||
}
|
||||
|
||||
allKnownFeatures.addCallbackAndRunD(newFeatures => {
|
||||
|
||||
const featuresPerLayer = new Map<string, any[]>();
|
||||
const allFeatures = Array.from(new Set(features.concat(newFeatures)))
|
||||
for (const feature of allFeatures) {
|
||||
|
||||
const key = feature.feature._matching_layer_id;
|
||||
if (!featuresPerLayer.has(key)) {
|
||||
featuresPerLayer.set(key, [])
|
||||
}
|
||||
featuresPerLayer.get(key).push(feature.feature)
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
// @ts-ignore
|
||||
const key = feature.feature._matching_layer_id;
|
||||
const f = layerFuncs.get(key);
|
||||
if (f === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(layerFuncs !== undefined){
|
||||
try {
|
||||
f({featuresPerLayer: featuresPerLayer, memberships: relations}, feature.feature)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -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[]>
|
||||
|
||||
|
||||
}
|
144
Logic/Osm/Actions/RelationSplitHandler.ts
Normal file
144
Logic/Osm/Actions/RelationSplitHandler.ts
Normal 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}
|
||||
}];
|
||||
}
|
||||
|
||||
}
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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--) {
|
||||
for (let i = allPoints.length - 2; i >= 1; i--) {
|
||||
// We 'merge' points with already existing nodes if they are close enough to avoid closeby elements
|
||||
|
||||
const point = allPoints[i];
|
||||
if (point.properties._original_index !== undefined) {
|
||||
// This point is already in OSM - we have to keep it!
|
||||
continue;
|
||||
// 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
|
||||
}
|
||||
|
||||
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...
|
||||
// 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)
|
||||
// ... and mark the previous point as a split point
|
||||
prevPoint.properties._is_split_point = true
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (i > 0) {
|
||||
const nextPoint = allPoints[i - 1]
|
||||
const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000
|
||||
if (diff <= toleranceInM) {
|
||||
// To close to the next point! We delete this point...
|
||||
allPoints.splice(i, 1)
|
||||
// ... and mark the next point as a split point
|
||||
nextPoint.properties._is_split_point = true
|
||||
// noinspection UnnecessaryContinueJS
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// We don't have to remove this point...
|
||||
}*/
|
||||
|
||||
const splitInfo: SplitInfo[] = []
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
this.flushChangesAsync(flushreason)
|
||||
.then(_ => {
|
||||
this.isUploading.setData(false)
|
||||
console.log("Changes flushed!");
|
||||
})
|
||||
.catch(e => {
|
||||
this.isUploading.setData(false)
|
||||
console.error("Flushing changes failed due to", e);
|
||||
})
|
||||
}
|
||||
|
||||
private async flushChangesAsync(flushreason: string = undefined): Promise<void> {
|
||||
const self = this;
|
||||
try {
|
||||
console.log("Beginning upload... " + flushreason ?? "");
|
||||
// At last, we build the changeset and upload
|
||||
const self = this;
|
||||
const pending = self.pendingChanges.data;
|
||||
const neededIds = Changes.GetNeededIds(pending)
|
||||
console.log("Needed ids", neededIds)
|
||||
OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => {
|
||||
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,
|
||||
await State.state.osmConnection.UploadChangeset(
|
||||
State.state.layoutToUse,
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
return true;
|
||||
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
}
|
|
@ -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,37 +35,57 @@ export class ChangesetHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage): void {
|
||||
const nodes = response.getElementsByTagName("node");
|
||||
// @ts-ignore
|
||||
for (const node of nodes) {
|
||||
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 = allElements.getEventSourceById("node/" + oldId);
|
||||
const element =this. allElements.getEventSourceById("node/" + oldId);
|
||||
element.data._deleted = "yes"
|
||||
element.ping();
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = parseInt(node.attributes.new_id.value);
|
||||
if (oldId !== undefined && newId !== undefined &&
|
||||
!isNaN(oldId) && !isNaN(newId)) {
|
||||
const result: [string, string] = [type + "/" + oldId, type + "/" + newId]
|
||||
if (!(oldId !== undefined && newId !== undefined &&
|
||||
!isNaN(oldId) && !isNaN(newId))) {
|
||||
return undefined;
|
||||
}
|
||||
if (oldId == newId) {
|
||||
continue;
|
||||
return undefined;
|
||||
}
|
||||
console.log("Rewriting id: ", oldId, "-->", newId);
|
||||
const element = allElements.getEventSourceById("node/" + oldId);
|
||||
element.data.id = "node/" + newId;
|
||||
allElements.addElementById("node/" + newId, element);
|
||||
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 mapping = this.handleIdRewrite(node, "node")
|
||||
if (mapping !== undefined) {
|
||||
mappings.set(mapping[0], mapping[1])
|
||||
}
|
||||
}
|
||||
|
||||
const ways = response.getElementsByTagName("way");
|
||||
// @ts-ignore
|
||||
for (const way of ways) {
|
||||
const mapping = this.handleIdRewrite(way, "way")
|
||||
if (mapping !== undefined) {
|
||||
mappings.set(mapping[0], mapping[1])
|
||||
}
|
||||
}
|
||||
this.changes.registerIdRewrites(mappings)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* The full logic to upload a change to one or more elements.
|
||||
*
|
||||
|
@ -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()
|
||||
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("")
|
||||
}
|
||||
)
|
||||
}, {
|
||||
onFail: onFail
|
||||
})
|
||||
} else {
|
||||
// There still exists an open changeset (or at least we hope so)
|
||||
const csId = this.currentChangeset.data;
|
||||
self.AddChange(
|
||||
try {
|
||||
|
||||
await this.AddChange(
|
||||
csId,
|
||||
generateChangeXML(csId),
|
||||
allElements,
|
||||
whenDone,
|
||||
(e) => {
|
||||
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
|
||||
self.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
|
||||
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,38 +182,28 @@ 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)
|
||||
})
|
||||
}, {
|
||||
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) = () => {
|
||||
}) {
|
||||
private async CloseChangeset(changesetId: string = undefined): Promise<void> {
|
||||
const self = this
|
||||
return new Promise<void>(function (resolve, reject) {
|
||||
if (changesetId === undefined) {
|
||||
changesetId = this.currentChangeset.data;
|
||||
changesetId = self.currentChangeset.data;
|
||||
}
|
||||
if (changesetId === undefined) {
|
||||
return;
|
||||
}
|
||||
console.log("closing changeset", changesetId);
|
||||
this.currentChangeset.setData("");
|
||||
this.auth.xhr({
|
||||
self.currentChangeset.setData("");
|
||||
self.auth.xhr({
|
||||
method: 'PUT',
|
||||
path: '/api/0.6/changeset/' + changesetId + '/close',
|
||||
}, function (err, response) {
|
||||
|
@ -203,22 +212,20 @@ export class ChangesetHandler {
|
|||
console.log("err", err);
|
||||
}
|
||||
console.log("Closed changeset ", changesetId)
|
||||
|
||||
if (continuation !== undefined) {
|
||||
continuation();
|
||||
}
|
||||
resolve()
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private OpenChangeset(
|
||||
layout: LayoutConfig,
|
||||
continuation: (changesetId: string) => void,
|
||||
options?: {
|
||||
isDeletionCS?: boolean,
|
||||
deletionReason?: string,
|
||||
onFail?: () => void
|
||||
}
|
||||
) {
|
||||
): 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 : "";
|
||||
|
@ -248,7 +255,8 @@ export class ChangesetHandler {
|
|||
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
|
||||
.join("\n")
|
||||
|
||||
this.auth.xhr({
|
||||
|
||||
self.auth.xhr({
|
||||
method: 'PUT',
|
||||
path: '/api/0.6/changeset/create',
|
||||
options: {header: {'Content-Type': 'text/xml'}},
|
||||
|
@ -258,32 +266,23 @@ export class ChangesetHandler {
|
|||
}, function (err, response) {
|
||||
if (response === undefined) {
|
||||
console.log("err", err);
|
||||
if (options.onFail) {
|
||||
options.onFail()
|
||||
}
|
||||
return;
|
||||
reject(err)
|
||||
} else {
|
||||
continuation(response);
|
||||
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({
|
||||
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',
|
||||
|
@ -291,15 +290,14 @@ export class ChangesetHandler {
|
|||
}, function (err, response) {
|
||||
if (response == null) {
|
||||
console.log("err", err);
|
||||
if (onFail) {
|
||||
onFail(changesetId, err);
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ChangesetHandler.parseUploadChangesetResponse(response, allElements);
|
||||
self.parseUploadChangesetResponse(response);
|
||||
console.log("Uploaded changeset ", changesetId);
|
||||
continuation(changesetId);
|
||||
resolve(changesetId);
|
||||
});
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
throw "PANIC: requested object is not part of the response"
|
||||
|
||||
switch (type) {
|
||||
case("node"):
|
||||
new OsmNode(idN).Download(newContinuation);
|
||||
break;
|
||||
case("way"):
|
||||
new OsmWay(idN).Download(newContinuation);
|
||||
break;
|
||||
case("relation"):
|
||||
new OsmRelation(idN).Download(newContinuation);
|
||||
break;
|
||||
default:
|
||||
throw "Invalid object type:" + type + id;
|
||||
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Downloads the ways that are using this node.
|
||||
* 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 => {
|
||||
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
|
||||
})
|
||||
relsSrc.setData(rels)
|
||||
})
|
||||
return relsSrc;
|
||||
}
|
||||
|
||||
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 => {
|
||||
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;
|
||||
const objects = OsmObject.ParseObjects(elements)
|
||||
callback(objects);
|
||||
|
||||
})
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
self._relationTracker.RegisterRelations(json)
|
||||
// @ts-ignore
|
||||
const geojson = OsmToGeoJson.default(json);
|
||||
const osmTime = new Date(json.osm3s.timestamp_osm_base);
|
||||
|
||||
continuation(geojson, osmTime);
|
||||
}).catch(onFail)
|
||||
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)}`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
116
Logic/Web/Wikidata.ts
Normal 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
75
Logic/Web/Wikipedia.ts
Normal 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
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default interface Bounds {
|
||||
north: number,
|
||||
east: number,
|
||||
south: number,
|
||||
west: number
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue