Merge branch 'develop' into fix-answer-with-image-style-#491

This commit is contained in:
Pieter Vander Vennet 2021-10-01 00:31:39 +02:00 committed by GitHub
commit ba2b4754a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
262 changed files with 27237 additions and 25052 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ To develop and build MapComplete, you
- You can [use asdf to manage your runtime versions](https://asdf-vm.com/). - 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 0. Install `npm`. Linux: `sudo apt install npm` (or your favourite package manager), Windows: install
nodeJS: https://nodejs.org/en/download/ nodeJS: https://nodejs.org/en/download/
0. Install `wget`, `brew install wget` 0. On iOS, install `wget` (`brew install wget`)
0. Run `npm run init` which … 0. Run `npm run init` which …
- runs `npm install` - runs `npm install`
- generates some additional dependencies and files - generates some additional dependencies and files

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ import Link from "./UI/Base/Link";
import * as personal from "./assets/themes/personal/personal.json"; import * as personal from "./assets/themes/personal/personal.json";
import * as L from "leaflet"; import * as L from "leaflet";
import Img from "./UI/Base/Img"; import Img from "./UI/Base/Img";
import UserDetails from "./Logic/Osm/OsmConnection";
import Attribution from "./UI/BigComponents/Attribution"; import Attribution from "./UI/BigComponents/Attribution";
import BackgroundLayerResetter from "./Logic/Actors/BackgroundLayerResetter"; import BackgroundLayerResetter from "./Logic/Actors/BackgroundLayerResetter";
import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs"; import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs";
@ -26,16 +25,22 @@ import ScrollableFullScreen from "./UI/Base/ScrollableFullScreen";
import Translations from "./UI/i18n/Translations"; import Translations from "./UI/i18n/Translations";
import MapControlButton from "./UI/MapControlButton"; import MapControlButton from "./UI/MapControlButton";
import LZString from "lz-string"; import LZString from "lz-string";
import AllKnownLayers from "./Customizations/AllKnownLayers";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
import {TagsFilter} from "./Logic/Tags/TagsFilter";
import LeftControls from "./UI/BigComponents/LeftControls"; import LeftControls from "./UI/BigComponents/LeftControls";
import RightControls from "./UI/BigComponents/RightControls"; import RightControls from "./UI/BigComponents/RightControls";
import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson"; import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson";
import LayoutConfig from "./Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "./Models/ThemeConfig/LayoutConfig";
import LayerConfig from "./Models/ThemeConfig/LayerConfig";
import Minimap from "./UI/Base/Minimap"; import Minimap from "./UI/Base/Minimap";
import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler"; 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 { export class InitUiElements {
static InitAll( static InitAll(
@ -63,8 +68,22 @@ export class InitUiElements {
layoutFromBase64 layoutFromBase64
); );
if(layoutToUse.id === personal.id){
layoutToUse.layers = AllKnownLayouts.AllPublicLayers()
for (const layer of layoutToUse.layers) {
layer.minzoomVisible = Math.max(layer.minzoomVisible, layer.minzoom)
layer.minzoom = Math.max(16, layer.minzoom)
}
}
State.state = new State(layoutToUse); State.state = new State(layoutToUse);
if(layoutToUse.id === personal.id) {
// Disable overpass all together
State.state.overpassMaxZoom.setData(0)
}
// This 'leaks' the global state via the window object, useful for debugging // This 'leaks' the global state via the window object, useful for debugging
// @ts-ignore // @ts-ignore
window.mapcomplete_state = State.state; window.mapcomplete_state = State.state;
@ -94,45 +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) {
State.state.layoutToUse.data.layers = Array.from(neededLayers);
State.state.layoutToUse.ping();
State.state.featurePipeline?.ForceRefresh();
}
}
if (layoutToUse.customCss !== undefined) { if (layoutToUse.customCss !== undefined) {
Utils.LoadCustomCss(layoutToUse.customCss); Utils.LoadCustomCss(layoutToUse.customCss);
} }
@ -165,9 +145,20 @@ export class InitUiElements {
).AttachTo("messagesbox"); ).AttachTo("messagesbox");
} }
State.state.osmConnection.userDetails function addHomeMarker() {
.map((userDetails: UserDetails) => userDetails?.home) const userDetails = State.state.osmConnection.userDetails.data;
.addCallbackAndRunD((home) => { if (userDetails === undefined) {
return false;
}
console.log("Adding home location of ", userDetails)
const home = userDetails.home;
if (home === undefined) {
return userDetails.loggedIn; // If logged in, the home is not set and we unregister. If not logged in, we stay registered if a login still comes
}
const leaflet = State.state.leafletMap.data;
if (leaflet === undefined) {
return false;
}
const color = getComputedStyle(document.body).getPropertyValue( const color = getComputedStyle(document.body).getPropertyValue(
"--subtle-detail-color" "--subtle-detail-color"
); );
@ -179,21 +170,17 @@ export class InitUiElements {
iconAnchor: [15, 15], iconAnchor: [15, 15],
}); });
const marker = L.marker([home.lat, home.lon], {icon: icon}); const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(State.state.leafletMap.data); marker.addTo(leaflet);
}); return true;
if (layoutToUse.id === personal.id) {
updateFavs();
} }
State.state.osmConnection.userDetails
.addCallbackAndRunD(_ => addHomeMarker());
State.state.leafletMap.addCallbackAndRunD(_ => addHomeMarker())
InitUiElements.setupAllLayerElements(); InitUiElements.setupAllLayerElements();
if (layoutToUse.id === personal.id) {
State.state.favouriteLayers.addCallback(updateFavs);
State.state.installedThemes.addCallback(updateFavs);
} else {
State.state.locationControl.ping(); State.state.locationControl.ping();
}
new SelectedFeatureHandler(Hash.hash, State.state) new SelectedFeatureHandler(Hash.hash, State.state)
@ -207,8 +194,8 @@ export class InitUiElements {
static LoadLayoutFromHash( static LoadLayoutFromHash(
userLayoutParam: UIEventSource<string> userLayoutParam: UIEventSource<string>
): [LayoutConfig, string] { ): [LayoutConfig, string] {
try {
let hash = location.hash.substr(1); let hash = location.hash.substr(1);
try {
const layoutFromBase64 = userLayoutParam.data; const layoutFromBase64 = userLayoutParam.data;
// layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter // layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
@ -247,9 +234,21 @@ export class InitUiElements {
userLayoutParam.setData(layoutToUse.id); userLayoutParam.setData(layoutToUse.id);
return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))]; return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))];
} catch (e) { } catch (e) {
new FixedUiElement(
"Error: could not parse the custom layout:<br/> " + e if (hash === undefined || hash.length < 10) {
).AttachTo("centermessage"); e = "Did you effectively add a theme? It seems no data could be found."
}
new Combine([
"Error: could not parse the custom layout:",
new FixedUiElement("" + e).SetClass("alert"),
new SubtleButton("./assets/svg/mapcomplete_logo.svg",
"Go back to the theme overview",
{url: window.location.protocol + "//" + window.location.hostname + "/index.html", newTab: false})
])
.SetClass("flex flex-col")
.AttachTo("centermessage");
throw e; throw e;
} }
} }
@ -322,9 +321,7 @@ export class InitUiElements {
State.state.backgroundLayer, State.state.backgroundLayer,
State.state.locationControl, State.state.locationControl,
State.state.availableBackgroundLayers, State.state.availableBackgroundLayers,
State.state.layoutToUse.map( State.state.layoutToUse.defaultBackgroundId
(layout: LayoutConfig) => layout.defaultBackgroundId
)
); );
const attr = new Attribution( const attr = new Attribution(
@ -344,15 +341,15 @@ export class InitUiElements {
}).SetClass("w-full h-full") }).SetClass("w-full h-full")
.AttachTo("leafletDiv") .AttachTo("leafletDiv")
const layout = State.state.layoutToUse.data; const layout = State.state.layoutToUse;
if (layout.lockLocation) { if (layout.lockLocation) {
if (layout.lockLocation === true) { if (layout.lockLocation === true) {
const tile = Utils.embedded_tile( const tile = Tiles.embedded_tile(
layout.startLat, layout.startLat,
layout.startLon, layout.startLon,
layout.startZoom - 1 layout.startZoom - 1
); );
const bounds = Utils.tile_bounds(tile.z, tile.x, tile.y); const bounds = Tiles.tile_bounds(tile.z, tile.x, tile.y);
// We use the bounds to get a sense of distance for this zoom level // We use the bounds to get a sense of distance for this zoom level
const latDiff = bounds[0][0] - bounds[1][0]; const latDiff = bounds[0][0] - bounds[1][0];
const lonDiff = bounds[0][1] - bounds[1][1]; const lonDiff = bounds[0][1] - bounds[1][1];
@ -372,41 +369,137 @@ export class InitUiElements {
private static InitLayers(): void { private static InitLayers(): void {
const state = State.state; const state = State.state;
state.filteredLayers = state.layoutToUse.map((layoutToUse) => { const empty = []
const flayers = [];
for (const layer of layoutToUse.layers) { const flayers: FilteredLayer[] = [];
const isDisplayed = QueryParameters.GetQueryParameter(
for (const layer of state.layoutToUse.layers) {
let defaultShown = "true"
if(state.layoutToUse.id === personal.id){
defaultShown = "false"
}
let isDisplayed: UIEventSource<boolean>
if(state.layoutToUse.id === personal.id){
isDisplayed = State.state.osmConnection.GetPreference("personal-theme-layer-" + layer.id + "-enabled")
.map(value => value === "yes", [], enabled => {
return enabled ? "yes" : "";
})
isDisplayed.addCallbackAndRun(d =>console.log("IsDisplayed for layer", layer.id, "is currently", d) )
}else{
isDisplayed = QueryParameters.GetQueryParameter(
"layer-" + layer.id, "layer-" + layer.id,
"true", defaultShown,
"Wether or not layer " + layer.id + " is shown" "Wether or not layer " + layer.id + " is shown"
).map<boolean>( ).map<boolean>(
(str) => str !== "false", (str) => str !== "false",
[], [],
(b) => b.toString() (b) => b.toString()
); );
}
const flayer = { const flayer = {
isDisplayed: isDisplayed, isDisplayed: isDisplayed,
layerDef: layer, layerDef: layer,
appliedFilters: new UIEventSource<TagsFilter>(undefined), appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]),
}; };
if (layer.filters.length > 0) {
const filtersPerName = new Map<string, FilterConfig>()
layer.filters.forEach(f => filtersPerName.set(f.id, f))
const qp = QueryParameters.GetQueryParameter("filter-" + layer.id, "","Filtering state for a layer")
flayer.appliedFilters.map(filters => {
filters = filters ?? []
return filters.map(f => f.filter.id + "." + f.selected).join(",")
}, [], textual => {
if(textual.length === 0){
return empty
}
return textual.split(",").map(part => {
const [filterId, selected] = part.split(".");
return {filter: filtersPerName.get(filterId), selected: Number(selected)}
}).filter(f => f.filter !== undefined && !isNaN(f.selected))
}).syncWith(qp, true)
}
flayers.push(flayer); flayers.push(flayer);
} }
return flayers; state.filteredLayers = new UIEventSource<FilteredLayer[]>(flayers);
});
const 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( State.state.featurePipeline = new FeaturePipeline(
source => { 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
const tilebbox = BBox.fromTileIndex(source.tileIndex)
if (!tilebbox.overlapsWith(bounds)) {
// Not within range
return false
}
return true
}, [State.state.currentBounds]
)
new ShowDataLayer( new ShowDataLayer(
{ {
features: source, features: source,
leafletMap: State.state.leafletMap, leafletMap: State.state.leafletMap,
layerToShow: source.layer.layerDef layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures
} }
); );
}, state }, state
); );
} }
private static setupAllLayerElements() { private static setupAllLayerElements() {

View file

@ -11,8 +11,8 @@ export default class BackgroundLayerResetter {
constructor(currentBackgroundLayer: UIEventSource<BaseLayer>, constructor(currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>, location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>, availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: UIEventSource<string> = undefined) { defaultLayerId: string = undefined) {
defaultLayerId = defaultLayerId ?? new UIEventSource<string>(AvailableBaseLayers.osmCarto.id); defaultLayerId = defaultLayerId ?? AvailableBaseLayers.osmCarto.id;
// Change the baselayer back to OSM if we go out of the current range of the layer // Change the baselayer back to OSM if we go out of the current range of the layer
availableLayers.addCallbackAndRun(availableLayers => { availableLayers.addCallbackAndRun(availableLayers => {
@ -28,7 +28,7 @@ export default class BackgroundLayerResetter {
if (availableLayer.min_zoom > location.data.zoom) { if (availableLayer.min_zoom > location.data.zoom) {
break; break;
} }
if (availableLayer.id === defaultLayerId.data) { if (availableLayer.id === defaultLayerId) {
defaultLayer = availableLayer; defaultLayer = availableLayer;
} }
return; // All good - the current layer still works! return; // All good - the current layer still works!

View file

@ -60,12 +60,12 @@ export default class GeoLocationHandler extends VariableUiElement {
* @private * @private
*/ */
private readonly _previousLocationGrant: UIEventSource<string>; private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>; private readonly _layoutToUse: LayoutConfig;
constructor( constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>, currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>, leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig> layoutToUse: LayoutConfig
) { ) {
const hasLocation = currentGPSLocation.map( const hasLocation = currentGPSLocation.map(
(location) => location !== undefined (location) => location !== undefined
@ -207,6 +207,9 @@ export default class GeoLocationHandler extends VariableUiElement {
}); });
const map = self._leafletMap.data; const map = self._leafletMap.data;
if(map === undefined){
return;
}
const newMarker = L.marker(location.latlng, {icon: icon}); const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map); newMarker.addTo(map);
@ -264,7 +267,7 @@ export default class GeoLocationHandler extends VariableUiElement {
} }
// We check that the GPS location is not out of bounds // We check that the GPS location is not out of bounds
const b = this._layoutToUse.data.lockLocation; const b = this._layoutToUse.lockLocation;
let inRange = true; let inRange = true;
if (b) { if (b) {
if (b !== true) { if (b !== true) {

View file

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

View file

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

View file

@ -3,12 +3,18 @@ import Translations from "../../UI/i18n/Translations";
import Locale from "../../UI/i18n/Locale"; import Locale from "../../UI/i18n/Locale";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer"; import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import Combine from "../../UI/Base/Combine"; import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../ElementStorage";
export default class TitleHandler { export default class TitleHandler {
constructor(state) { constructor(state : {
selectedElement: UIEventSource<any>,
layoutToUse: LayoutConfig,
allElements: ElementStorage
}) {
const currentTitle: UIEventSource<string> = state.selectedElement.map( const currentTitle: UIEventSource<string> = state.selectedElement.map(
selected => { selected => {
const layout = state.layoutToUse.data const layout = state.layoutToUse
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete" const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
if (selected === undefined) { if (selected === undefined) {
@ -23,11 +29,11 @@ export default class TitleHandler {
if (layer.source.osmTags.matchesProperties(tags)) { if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = state.allElements.getEventSourceById(tags.id) const tagsSource = state.allElements.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title) const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText; return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? defaultTitle;
} }
} }
return defaultTitle return defaultTitle
}, [Locale.language, state.layoutToUse] }, [Locale.language]
) )

158
Logic/BBox.ts Normal file
View file

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

View file

@ -2,7 +2,7 @@
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import FeaturePipeline from "./FeatureSource/FeaturePipeline"; import FeaturePipeline from "./FeatureSource/FeaturePipeline";
import Loc from "../Models/Loc"; import Loc from "../Models/Loc";
import {BBox} from "./GeoOperations"; import {BBox} from "./BBox";
export default class ContributorCount { export default class ContributorCount {

View file

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

View file

@ -1,4 +1,4 @@
import {BBox, GeoOperations} from "./GeoOperations"; import {GeoOperations} from "./GeoOperations";
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine";
import RelationsTracker from "./Osm/RelationsTracker"; import RelationsTracker from "./Osm/RelationsTracker";
import State from "../State"; import State from "../State";
@ -7,6 +7,7 @@ import List from "../UI/Base/List";
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title";
import {UIEventSourceTools} from "./UIEventSource"; import {UIEventSourceTools} from "./UIEventSource";
import AspectedRouting from "./Osm/aspectedRouting"; import AspectedRouting from "./Osm/aspectedRouting";
import {BBox} from "./BBox";
export interface ExtraFuncParams { export interface ExtraFuncParams {
/** /**
@ -134,11 +135,18 @@ export class ExtraFunction {
args: ["list of features or layer name", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"] args: ["list of features or layer name", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"]
}, },
(params, feature) => { (params, feature) => {
return (features, amount, uniqueTag, maxDistanceInMeters) => ExtraFunction.GetClosestNFeatures(params, feature, features, {
return (features, amount, uniqueTag, maxDistanceInMeters) => {
let distance : number = Number(maxDistanceInMeters)
if(isNaN(distance)){
distance = undefined
}
return ExtraFunction.GetClosestNFeatures(params, feature, features, {
maxFeatures: Number(amount), maxFeatures: Number(amount),
uniqueTag: uniqueTag, uniqueTag: uniqueTag,
maxDistance: Number(maxDistanceInMeters) maxDistance: distance
}) });
}
} }
) )
@ -249,7 +257,7 @@ export class ExtraFunction {
let closestFeatures: { feat: any, distance: number }[] = []; let closestFeatures: { feat: any, distance: number }[] = [];
for(const featureList of features) { for(const featureList of features) {
for (const otherFeature of featureList) { for (const otherFeature of featureList) {
if (otherFeature == feature || otherFeature.id == feature.id) { if (otherFeature === feature || otherFeature.id === feature.id) {
continue; // We ignore self continue; // We ignore self
} }
let distance = undefined; let distance = undefined;
@ -261,7 +269,8 @@ export class ExtraFunction {
[feature._lon, feature._lat] [feature._lon, feature._lat]
) )
} }
if (distance === undefined) { if (distance === undefined || distance === null) {
console.error("Could not calculate the distance between", feature, "and", otherFeature)
throw "Undefined distance!" throw "Undefined distance!"
} }
if (distance > maxDistance) { if (distance > maxDistance) {

View file

@ -7,26 +7,30 @@ import {FeatureSourceForLayer} from "../FeatureSource";
export default class SaveTileToLocalStorageActor { export default class SaveTileToLocalStorageActor {
public static readonly storageKey: string = "cached-features"; public static readonly storageKey: string = "cached-features";
public static readonly formatVersion: string = "1"
constructor(source: FeatureSourceForLayer, tileIndex: number) { constructor(source: FeatureSourceForLayer, tileIndex: number) {
source.features.addCallbackAndRunD(features => { source.features.addCallbackAndRunD(features => {
const key = `${SaveTileToLocalStorageActor.storageKey}-${source.layer.layerDef.id}-${tileIndex}` const key = `${SaveTileToLocalStorageActor.storageKey}-${source.layer.layerDef.id}-${tileIndex}`
const now = new Date().getTime() const now = new Date()
if (features.length == 0) {
return;
}
try { try {
if (features.length > 0) {
localStorage.setItem(key, JSON.stringify(features)); localStorage.setItem(key, JSON.stringify(features));
localStorage.setItem(key + "-time", JSON.stringify(now)) }
// We _still_ write the time to know that this tile is empty!
SaveTileToLocalStorageActor.MarkVisited(source.layer.layerDef.id, tileIndex, now)
} catch (e) { } catch (e) {
console.warn("Could not save the features to local storage:", e) console.warn("Could not save the features to local storage:", e)
} }
}) })
} }
public static MarkVisited(layerId: string, tileId: number, freshness: Date){
const key = `${SaveTileToLocalStorageActor.storageKey}-${layerId}-${tileId}`
localStorage.setItem(key + "-time", JSON.stringify(freshness.getTime()))
localStorage.setItem(key + "-format", SaveTileToLocalStorageActor.formatVersion)
}
} }

View file

@ -1,7 +1,7 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteringFeatureSource from "./Sources/FilteringFeatureSource"; import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter"; import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter";
import FeatureSource, {FeatureSourceForLayer, FeatureSourceState, IndexedFeatureSource, Tiled} from "./FeatureSource"; import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "./FeatureSource";
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource"; import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy"; import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
@ -17,76 +17,111 @@ import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFea
import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource"; import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource";
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor"; import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor";
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource"; import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource";
import {BBox} from "../GeoOperations";
import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger"; import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger";
import RelationsTracker from "../Osm/RelationsTracker"; import RelationsTracker from "../Osm/RelationsTracker";
import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource"; import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource";
import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator"; 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 FeatureSourceState { export default class FeaturePipeline {
public readonly sufficientlyZoomed: UIEventSource<boolean>; public readonly sufficientlyZoomed: UIEventSource<boolean>;
public readonly runningQuery: UIEventSource<boolean>; public readonly runningQuery: UIEventSource<boolean>;
public readonly timeout: UIEventSource<number>; public readonly timeout: UIEventSource<number>;
public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false) public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined) public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
private readonly overpassUpdater: OverpassFeatureSource 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 relationTracker: RelationsTracker
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>; private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>;
private readonly freshnesses = new Map<string, TileFreshnessCalculator>();
private readonly oldestAllowedDate: Date = new Date(new Date().getTime() - 60 * 60 * 24 * 30 * 1000);
private readonly osmSourceZoomLevel = 14
constructor( constructor(
handleFeatureSource: (source: FeatureSourceForLayer) => void, handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
state: { state: {
filteredLayers: UIEventSource<FilteredLayer[]>, readonly filteredLayers: UIEventSource<FilteredLayer[]>,
locationControl: UIEventSource<Loc>, readonly locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>, readonly selectedElement: UIEventSource<any>,
changes: Changes, readonly changes: Changes,
layoutToUse: UIEventSource<LayoutConfig>, readonly layoutToUse: LayoutConfig,
leafletMap: any, readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string>; readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>; readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>; readonly overpassMaxZoom: UIEventSource<number>;
readonly osmConnection: OsmConnection
readonly currentBounds: UIEventSource<BBox>
}) { }) {
this.state = state;
const self = this const self = this
const updater = new OverpassFeatureSource(state); // milliseconds
updater.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(updater)) const useOsmApi = state.locationControl.map(l => l.zoom > (state.overpassMaxZoom.data ?? 12))
this.overpassUpdater = updater; this.relationTracker = new RelationsTracker()
this.sufficientlyZoomed = updater.sufficientlyZoomed
this.runningQuery = updater.runningQuery const neededTilesFromOsm = this.getNeededTilesFromOsm()
this.timeout = updater.timeout
this.relationTracker = updater.relationsTracker
// Register everything in the state' 'AllElements' this.sufficientlyZoomed = state.locationControl.map(location => {
new RegisteringAllFromFeatureSourceActor(updater) if (location?.zoom === undefined) {
return false;
}
let minzoom = Math.min(...state.layoutToUse.layers.map(layer => layer.minzoom ?? 18));
return location.zoom >= minzoom;
}
);
const perLayerHierarchy = new Map<string, TileHierarchyMerger>() const perLayerHierarchy = new Map<string, TileHierarchyMerger>()
this.perLayerHierarchy = perLayerHierarchy this.perLayerHierarchy = perLayerHierarchy
const patchedHandleFeatureSource = function (src: FeatureSourceForLayer & IndexedFeatureSource) { 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 // This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile
const srcFiltered = const srcFiltered =
new FilteringFeatureSource(state, new FilteringFeatureSource(state, src.tileIndex,
new WayHandlingApplyingFeatureSource( new WayHandlingApplyingFeatureSource(
new ChangeGeometryApplicator(src, state.changes) new ChangeGeometryApplicator(src, state.changes)
) )
) )
handleFeatureSource(srcFiltered) handleFeatureSource(srcFiltered)
self.somethingLoaded.setData(true) self.somethingLoaded.setData(true)
self.freshnesses.get(src.layer.layerDef.id).addTileLoad(src.tileIndex, new Date())
}; };
function addToHierarchy(src: FeatureSource & Tiled, layerId: string) {
perLayerHierarchy.get(layerId).registerTile(src)
}
for (const filteredLayer of state.filteredLayers.data) { for (const filteredLayer of state.filteredLayers.data) {
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile))
const id = filteredLayer.layerDef.id const id = filteredLayer.layerDef.id
perLayerHierarchy.set(id, hierarchy)
const source = filteredLayer.layerDef.source 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) { if (source.geojsonSource === undefined) {
// This is an OSM layer // This is an OSM layer
// We load the cached values and register them // We load the cached values and register them
@ -97,49 +132,81 @@ export default class FeaturePipeline implements FeatureSourceState {
hierarchy.registerTile(src); hierarchy.registerTile(src);
src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src)) src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src))
}, state) }, state)
TiledFromLocalStorageSource.GetFreshnesses(id).forEach((value, key) => {
self.freshnesses.get(id).addTileLoad(key, value)
})
continue continue
} }
if (source.geojsonZoomLevel === undefined) { if (source.geojsonZoomLevel === undefined) {
// This is a 'load everything at once' geojson layer // This is a 'load everything at once' geojson layer
// We split them up into tiles // We split them up into tiles anyway
const src = new GeoJsonSource(filteredLayer) const src = new GeoJsonSource(filteredLayer)
TiledFeatureSource.createHierarchy(src, { TiledFeatureSource.createHierarchy(src, {
layer: src.layer, layer: src.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
registerTile: (tile) => { registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile) new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id) perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
} }
}) })
} else { } else {
new DynamicGeoJsonTileSource( new DynamicGeoJsonTileSource(
filteredLayer, filteredLayer,
src => TiledFeatureSource.createHierarchy(src, { tile => {
layer: src.layer,
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile) new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id) perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
} },
}),
state 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 // Actually load data from the overpass source
new PerLayerFeatureSourceSplitter(state.filteredLayers, new PerLayerFeatureSourceSplitter(state.filteredLayers,
(source) => TiledFeatureSource.createHierarchy(source, { (source) => TiledFeatureSource.createHierarchy(source, {
layer: source.layer, layer: source.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
maxFeatureCount: state.layoutToUse.clustering.minNeededElements,
maxZoomLevel: state.layoutToUse.clustering.maxZoom,
registerTile: (tile) => { registerTile: (tile) => {
// We save the tile data for the given layer to local storage // We save the tile data for the given layer to local storage
new SaveTileToLocalStorageActor(tile, tile.tileIndex) new SaveTileToLocalStorageActor(tile, tile.tileIndex)
addToHierarchy(tile, source.layer.layerDef.id); perLayerHierarchy.get(source.layer.layerDef.id).registerTile(new RememberingSource(tile))
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
} }
}), }),
new RememberingSource(updater)) updater)
// Also load points/lines that are newly added. // Also load points/lines that are newly added.
@ -149,9 +216,11 @@ export default class FeaturePipeline implements FeatureSourceState {
new PerLayerFeatureSourceSplitter(state.filteredLayers, new PerLayerFeatureSourceSplitter(state.filteredLayers,
(perLayer) => { (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 // We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
addToHierarchy(perLayer, perLayer.layer.layerDef.id) perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer)
// AT last, we always apply the metatags whenever possible // AT last, we always apply the metatags whenever possible
perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer)) perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer))
perLayer.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(perLayer))
}, },
newGeometry newGeometry
) )
@ -162,10 +231,119 @@ export default class FeaturePipeline implements FeatureSourceState {
self.updateAllMetaTagging() 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(): UIEventSource<number[]> {
const self = this
return this.state.currentBounds.map(bbox => {
if (bbox === undefined) {
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 allUpToDateAndZoomSufficient = state.currentBounds.map(bbox => {
if (bbox === undefined) {
return true
}
let zoom = state.locationControl.data.zoom
if (zoom < minzoom) {
return true;
}
if (zoom > 16) {
zoom = 16
}
if (zoom < 8) {
zoom = zoom + 2
}
const range = bbox.containingTileRange(zoom)
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])
allUpToDateAndZoomSufficient.addCallbackAndRunD(allUpToDate => console.log("All up to data is: ", allUpToDate))
const self = this;
const updater = new OverpassFeatureSource(state,
{
relationTracker: this.relationTracker,
isActive: useOsmApi.map(b => !b && !allUpToDateAndZoomSufficient.data, [allUpToDateAndZoomSufficient]),
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)
})
})
})
});
// Register everything in the state' 'AllElements'
new RegisteringAllFromFeatureSourceActor(updater)
return updater;
} }
private applyMetaTags(src: FeatureSourceForLayer) { private applyMetaTags(src: FeatureSourceForLayer) {
const self = this const self = this
console.debug("Applying metatagging onto ", src.name)
window.setTimeout(
() => {
MetaTagging.addMetatags( MetaTagging.addMetatags(
src.features.data, src.features.data,
{ {
@ -179,10 +357,15 @@ export default class FeaturePipeline implements FeatureSourceState {
includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer
} }
) )
},
15
)
} }
private updateAllMetaTagging() { private updateAllMetaTagging() {
const self = this; const self = this;
console.log("Reupdating all metatagging")
this.perLayerHierarchy.forEach(hierarchy => { this.perLayerHierarchy.forEach(hierarchy => {
hierarchy.loadedTiles.forEach(src => { hierarchy.loadedTiles.forEach(src => {
self.applyMetaTags(src) self.applyMetaTags(src)
@ -216,7 +399,4 @@ export default class FeaturePipeline implements FeatureSourceState {
}) })
} }
public ForceRefresh() {
this.overpassUpdater.ForceRefresh()
}
} }

View file

@ -1,7 +1,7 @@
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import FilteredLayer from "../../Models/FilteredLayer"; import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../GeoOperations"; import {BBox} from "../BBox";
export default interface FeatureSource { export default interface FeatureSource {
features: UIEventSource<{ feature: any, freshness: Date }[]>; features: UIEventSource<{ feature: any, freshness: Date }[]>;

View file

@ -15,6 +15,7 @@ export default class PerLayerFeatureSourceSplitter {
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void, handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
upstream: FeatureSource, upstream: FeatureSource,
options?:{ options?:{
tileIndex?: number,
handleLeftovers?: (featuresWithoutLayer: any[]) => void handleLeftovers?: (featuresWithoutLayer: any[]) => void
}) { }) {
@ -71,7 +72,7 @@ export default class PerLayerFeatureSourceSplitter {
let featureSource = knownLayers.get(id) let featureSource = knownLayers.get(id)
if (featureSource === undefined) { if (featureSource === undefined) {
// Not yet initialized - now is a good time // Not yet initialized - now is a good time
featureSource = new SimpleFeatureSource(layer) featureSource = new SimpleFeatureSource(layer, options?.tileIndex)
featureSource.features.setData(features) featureSource.features.setData(features)
knownLayers.set(id, featureSource) knownLayers.set(id, featureSource)
handleLayerData(featureSource) handleLayerData(featureSource)

View file

@ -5,8 +5,9 @@
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import {BBox} from "../../GeoOperations";
import {Utils} from "../../../Utils"; import {Utils} from "../../../Utils";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource { export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
@ -23,7 +24,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
this.bbox = bbox; this.bbox = bbox;
this._sources = sources; this._sources = sources;
this.layer = layer; this.layer = layer;
this.name = "FeatureSourceMerger("+layer.layerDef.id+", "+Utils.tile_from_index(tileIndex).join(",")+")" this.name = "FeatureSourceMerger("+layer.layerDef.id+", "+Tiles.tile_from_index(tileIndex).join(",")+")"
const self = this; const self = this;
const handledSources = new Set<FeatureSource>(); const handledSources = new Set<FeatureSource>();

View file

@ -1,29 +1,36 @@
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer} from "../FeatureSource"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import Hash from "../../Web/Hash"; import Hash from "../../Web/Hash";
import {BBox} from "../../BBox";
export default class FilteringFeatureSource implements FeatureSourceForLayer { export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = public features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([]); new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name; public readonly name;
public readonly layer: FilteredLayer; public readonly layer: FilteredLayer;
public readonly tileIndex: number
public readonly bbox: BBox
constructor( constructor(
state: { state: {
locationControl: UIEventSource<{ zoom: number }>, locationControl: UIEventSource<{ zoom: number }>,
selectedElement: UIEventSource<any>, selectedElement: UIEventSource<any>,
}, },
tileIndex,
upstream: FeatureSourceForLayer upstream: FeatureSourceForLayer
) { ) {
const self = this; const self = this;
this.name = "FilteringFeatureSource(" + upstream.name + ")" this.name = "FilteringFeatureSource(" + upstream.name + ")"
this.tileIndex = tileIndex
this.bbox = BBox.fromTileIndex(tileIndex)
this.layer = upstream.layer; this.layer = upstream.layer;
const layer = upstream.layer; const layer = upstream.layer;
function update() { function update() {
const features: { feature: any; freshness: Date }[] = upstream.features.data; const features: { feature: any; freshness: Date }[] = upstream.features.data;
const newFeatures = features.filter((f) => { const newFeatures = features.filter((f) => {
if ( if (
@ -45,17 +52,15 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
} }
const tagsFilter = layer.appliedFilters.data; const tagsFilter = layer.appliedFilters.data;
if (tagsFilter) { for (const filter of tagsFilter ?? []) {
if (!tagsFilter.matchesProperties(f.feature.properties)) { 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 // Hidden by the filter on the layer itself - we want to hide it no matter wat
return false; return false;
} }
} }
if (!FilteringFeatureSource.showLayer(layer, state.locationControl.data)) {
// 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; return true;
}); });
@ -66,23 +71,8 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
update(); update();
}); });
let isShown = state.locationControl.map((l) => FilteringFeatureSource.showLayer(layer, l),
[layer.isDisplayed])
isShown.addCallback(isShown => {
if (isShown) {
update();
} else {
self.features.setData([])
}
});
layer.appliedFilters.addCallback(_ => { layer.appliedFilters.addCallback(_ => {
if(!isShown.data){
// Currently not shown.
// Note that a change in 'isSHown' will trigger an update as well, so we don't have to watch it another time
return;
}
update() update()
}) })
@ -93,10 +83,8 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
layer: { layer: {
isDisplayed: UIEventSource<boolean>; isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig; layerDef: LayerConfig;
}, }) {
location: { zoom: number }) { return layer.isDisplayed.data;
return layer.isDisplayed.data &&
layer.layerDef.minzoomVisible <= location.zoom;
} }
} }

View file

@ -5,7 +5,8 @@ import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils"; import {Utils} from "../../../Utils";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {BBox} from "../../GeoOperations"; import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
@ -20,14 +21,26 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
public readonly tileIndex public readonly tileIndex
public readonly bbox; 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, public constructor(flayer: FilteredLayer,
zxy?: [number, number, number]) { zxy?: [number, number, number],
options?: {
featureIdBlacklist?: UIEventSource<Set<string>>
}) {
if (flayer.layerDef.source.geojsonZoomLevel !== undefined && zxy === undefined) { if (flayer.layerDef.source.geojsonZoomLevel !== undefined && zxy === undefined) {
throw "Dynamic layers are not supported. Use 'DynamicGeoJsonTileSource instead" throw "Dynamic layers are not supported. Use 'DynamicGeoJsonTileSource instead"
} }
this.layer = flayer; this.layer = flayer;
this.featureIdBlacklist = options?.featureIdBlacklist
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id); let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
if (zxy !== undefined) { if (zxy !== undefined) {
const [z, x, y] = zxy; const [z, x, y] = zxy;
@ -35,10 +48,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
.replace('{z}', "" + z) .replace('{z}', "" + z)
.replace('{x}', "" + x) .replace('{x}', "" + x)
.replace('{y}', "" + y) .replace('{y}', "" + y)
this.tileIndex = Utils.tile_index(z, x, y) this.tileIndex = Tiles.tile_index(z, x, y)
this.bbox = BBox.fromTile(z, x, y) this.bbox = BBox.fromTile(z, x, y)
} else { } else {
this.tileIndex = Utils.tile_index(0, 0, 0) this.tileIndex = Tiles.tile_index(0, 0, 0)
this.bbox = BBox.global; this.bbox = BBox.global;
} }
@ -67,6 +80,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
const props = feature.properties const props = feature.properties
for (const key in props) { for (const key in props) {
if (typeof props[key] !== "string") { if (typeof props[key] !== "string") {
// Make sure all the values are string, it crashes stuff otherwise
props[key] = "" + props[key] props[key] = "" + props[key]
} }
} }
@ -82,6 +96,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
} }
self.seenids.add(props.id) self.seenids.add(props.id)
if(self.featureIdBlacklist?.data?.has(props.id)){
continue;
}
let freshness: Date = time; let freshness: Date = time;
if (feature.properties["_last_edit:timestamp"] !== undefined) { if (feature.properties["_last_edit:timestamp"] !== undefined) {
freshness = new Date(props["_last_edit:timestamp"]) freshness = new Date(props["_last_edit:timestamp"])
@ -89,7 +107,6 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
newFeatures.push({feature: feature, freshness: freshness}) newFeatures.push({feature: feature, freshness: freshness})
} }
console.debug("Downloaded " + newFeatures.length + " new features and " + skipped + " already seen features from " + url);
if (newFeatures.length == 0) { if (newFeatures.length == 0) {
return; return;

View file

@ -3,6 +3,7 @@ import {OsmNode, OsmRelation, OsmWay} from "../../Osm/OsmObject";
import FeatureSource from "../FeatureSource"; import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import {ChangeDescription} from "../../Osm/Actions/ChangeDescription"; import {ChangeDescription} from "../../Osm/Actions/ChangeDescription";
import State from "../../../State";
export class NewGeometryFromChangesFeatureSource implements FeatureSource { export class NewGeometryFromChangesFeatureSource implements FeatureSource {
// This class name truly puts the 'Java' into 'Javascript' // This class name truly puts the 'Java' into 'Javascript'
@ -54,6 +55,9 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
tags[kv.k] = kv.v tags[kv.k] = kv.v
} }
tags["id"] = change.type+"/"+change.id tags["id"] = change.type+"/"+change.id
tags["_backend"] = State.state.osmConnection._oauth_config.url
switch (change.type) { switch (change.type) {
case "node": case "node":
const n = new OsmNode(change.id) const n = new OsmNode(change.id)

View file

@ -2,17 +2,23 @@
* 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 * Data coming from upstream will always overwrite a previous value
*/ */
import FeatureSource from "../FeatureSource"; import FeatureSource, {Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import {BBox} from "../../BBox";
export default class RememberingSource implements FeatureSource { export default class RememberingSource implements FeatureSource , Tiled{
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>;
public readonly name; public readonly name;
public readonly tileIndex : number
public readonly bbox : BBox
constructor(source: FeatureSource) { constructor(source: FeatureSource & Tiled) {
const self = this; const self = this;
this.name = "RememberingSource of " + source.name; this.name = "RememberingSource of " + source.name;
this.tileIndex= source.tileIndex
this.bbox = source.bbox;
const empty = []; const empty = [];
this.features = source.features.map(features => { this.features = source.features.map(features => {
const oldFeatures = self.features?.data ?? empty; const oldFeatures = self.features?.data ?? empty;

View file

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

View file

@ -8,12 +8,13 @@ export default class StaticFeatureSource implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name: string = "StaticFeatureSource" public readonly name: string = "StaticFeatureSource"
constructor(features: any[] | UIEventSource<any[]>, useFeaturesDirectly = false) { constructor(features: any[] | UIEventSource<any[] | UIEventSource<{ feature: any, freshness: Date }>>, useFeaturesDirectly) {
const now = new Date(); const now = new Date();
if (useFeaturesDirectly) { if (useFeaturesDirectly) {
// @ts-ignore // @ts-ignore
this.features = features this.features = features
} else if (features instanceof UIEventSource) { } else if (features instanceof UIEventSource) {
// @ts-ignore
this.features = features.map(features => features.map(f => ({feature: f, freshness: now}))) this.features = features.map(features => features.map(f => ({feature: f, freshness: now})))
} else { } else {
this.features = new UIEventSource(features.map(f => ({ this.features = new UIEventSource(features.map(f => ({

View file

@ -12,6 +12,7 @@ export default class WayHandlingApplyingFeatureSource implements FeatureSourceFo
public readonly layer; public readonly layer;
constructor(upstream: FeatureSourceForLayer) { constructor(upstream: FeatureSourceForLayer) {
this.name = "Wayhandling(" + upstream.name + ")"; this.name = "Wayhandling(" + upstream.name + ")";
this.layer = upstream.layer this.layer = upstream.layer
const layer = upstream.layer.layerDef; const layer = upstream.layer.layerDef;

View file

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

View file

@ -1,5 +1,5 @@
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer} from "../FeatureSource"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc"; import Loc from "../../../Models/Loc";
import DynamicTileSource from "./DynamicTileSource"; import DynamicTileSource from "./DynamicTileSource";
@ -8,7 +8,7 @@ import GeoJsonSource from "../Sources/GeoJsonSource";
export default class DynamicGeoJsonTileSource extends DynamicTileSource { export default class DynamicGeoJsonTileSource extends DynamicTileSource {
constructor(layer: FilteredLayer, constructor(layer: FilteredLayer,
registerLayer: (layer: FeatureSourceForLayer) => void, registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
state: { state: {
locationControl: UIEventSource<Loc> locationControl: UIEventSource<Loc>
leafletMap: any leafletMap: any
@ -37,6 +37,8 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
console.warn("No whitelist found for ", layer.layerDef.id, err) console.warn("No whitelist found for ", layer.layerDef.id, err)
}) })
const seenIds = new Set<string>();
const blackList = new UIEventSource(seenIds)
super( super(
layer, layer,
source.geojsonZoomLevel, source.geojsonZoomLevel,
@ -50,8 +52,15 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
const src = new GeoJsonSource( const src = new GeoJsonSource(
layer, layer,
zxy zxy,
{
featureIdBlacklist: blackList
}
) )
src.features.addCallbackAndRunD(feats => {
feats.forEach(feat => seenIds.add(feat.feature.properties.id))
blackList.ping();
})
registerLayer(src) registerLayer(src)
return src return src
}, },

View file

@ -6,6 +6,7 @@ import {Utils} from "../../../Utils";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc"; import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy"; import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
/*** /***
* A tiled source which dynamically loads the required tiles at a fixed zoom level * A tiled source which dynamically loads the required tiles at a fixed zoom level
@ -46,9 +47,9 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
// We'll retry later // We'll retry later
return undefined return undefined
} }
const tileRange = Utils.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest()) const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const needed = Utils.MapRange(tileRange, (x, y) => Utils.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i)) const needed = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i))
if (needed.length === 0) { if (needed.length === 0) {
return undefined return undefined
} }
@ -63,7 +64,7 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
} }
for (const neededIndex of neededIndexes) { for (const neededIndex of neededIndexes) {
self._loadedTiles.add(neededIndex) self._loadedTiles.add(neededIndex)
const src = constructTile( Utils.tile_from_index(neededIndex)) const src = constructTile(Tiles.tile_from_index(neededIndex))
if(src !== undefined){ if(src !== undefined){
self.loadedTiles.set(neededIndex, src) self.loadedTiles.set(neededIndex, src)
} }

View file

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

View file

@ -1,5 +1,5 @@
import FeatureSource, {Tiled} from "../FeatureSource"; import FeatureSource, {Tiled} from "../FeatureSource";
import {BBox} from "../../GeoOperations"; import {BBox} from "../../BBox";
export default interface TileHierarchy<T extends FeatureSource & Tiled> { export default interface TileHierarchy<T extends FeatureSource & Tiled> {

View file

@ -2,9 +2,9 @@ import TileHierarchy from "./TileHierarchy";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
import {BBox} from "../../GeoOperations";
import FeatureSourceMerger from "../Sources/FeatureSourceMerger"; import FeatureSourceMerger from "../Sources/FeatureSourceMerger";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer & Tiled> { export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, FeatureSourceForLayer & Tiled>(); public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, FeatureSourceForLayer & Tiled>();
@ -13,7 +13,7 @@ export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer
public readonly layer: FilteredLayer; public readonly layer: FilteredLayer;
private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void; private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void;
constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void) { constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource & Tiled, index: number) => void) {
this.layer = layer; this.layer = layer;
this._handleTile = handleTile; this._handleTile = handleTile;
} }
@ -37,7 +37,7 @@ export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer
// We have to setup // We have to setup
const sources = new UIEventSource<FeatureSource[]>([src]) const sources = new UIEventSource<FeatureSource[]>([src])
this.sources.set(index, sources) this.sources.set(index, sources)
const merger = new FeatureSourceMerger(this.layer, index, BBox.fromTile(...Utils.tile_from_index(index)), sources) const merger = new FeatureSourceMerger(this.layer, index, BBox.fromTile(...Tiles.tile_from_index(index)), sources)
this.loadedTiles.set(index, merger) this.loadedTiles.set(index, merger)
this._handleTile(merger, index) this._handleTile(merger, index)
} }

View file

@ -1,10 +1,10 @@
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import {Utils} from "../../../Utils"; import {Utils} from "../../../Utils";
import {BBox} from "../../GeoOperations";
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer";
import TileHierarchy from "./TileHierarchy"; import TileHierarchy from "./TileHierarchy";
import {feature} from "@turf/turf"; import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
/** /**
* Contains all features in a tiled fashion. * Contains all features in a tiled fashion.
@ -41,12 +41,12 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
this.x = x; this.x = x;
this.y = y; this.y = y;
this.bbox = BBox.fromTile(z, x, y) this.bbox = BBox.fromTile(z, x, y)
this.tileIndex = Utils.tile_index(z, x, y) this.tileIndex = Tiles.tile_index(z, x, y)
this.name = `TiledFeatureSource(${z},${x},${y})` this.name = `TiledFeatureSource(${z},${x},${y})`
this.parent = parent; this.parent = parent;
this.layer = options.layer this.layer = options.layer
options = options ?? {} options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 500; this.maxFeatureCount = options?.maxFeatureCount ?? 250;
this.maxzoom = options.maxZoomLevel ?? 18 this.maxzoom = options.maxZoomLevel ?? 18
this.options = options; this.options = options;
if (parent === undefined) { if (parent === undefined) {
@ -61,7 +61,7 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
} else { } else {
this.root = this.parent.root; this.root = this.parent.root;
this.loadedTiles = this.root.loadedTiles; this.loadedTiles = this.root.loadedTiles;
const i = Utils.tile_index(z, x, y) const i = Tiles.tile_index(z, x, y)
this.root.loadedTiles.set(i, this) this.root.loadedTiles.set(i, this)
} }
this.features = new UIEventSource<any[]>([]) this.features = new UIEventSource<any[]>([])
@ -109,7 +109,6 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
// To much features - we split // To much features - we split
return featureCount > this.maxFeatureCount return featureCount > this.maxFeatureCount
} }
/*** /***
@ -143,9 +142,20 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
for (const feature of features) { for (const feature of features) {
const bbox = BBox.get(feature.feature) const bbox = BBox.get(feature.feature)
if (this.options.minZoomLevel === undefined) {
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)) { if (bbox.isContainedIn(this.upper_left.bbox)) {
ulf.push(feature) ulf.push(feature)
} else if (bbox.isContainedIn(this.upper_right.bbox)) { } else if (bbox.isContainedIn(this.upper_right.bbox)) {
@ -186,6 +196,11 @@ export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number, readonly maxFeatureCount?: number,
readonly maxZoomLevel?: number, readonly maxZoomLevel?: number,
readonly minZoomLevel?: 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 registerTile?: (tile: TiledFeatureSource & Tiled) => void,
readonly layer?: FilteredLayer readonly layer?: FilteredLayer
} }

View file

@ -3,13 +3,29 @@ import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource"; import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc"; import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy"; import TileHierarchy from "./TileHierarchy";
import {Utils} from "../../../Utils";
import SaveTileToLocalStorageActor from "../Actors/SaveTileToLocalStorageActor"; import SaveTileToLocalStorageActor from "../Actors/SaveTileToLocalStorageActor";
import {BBox} from "../../GeoOperations"; import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class TiledFromLocalStorageSource implements TileHierarchy<FeatureSourceForLayer & Tiled> { export default class TiledFromLocalStorageSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, 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, constructor(layer: FilteredLayer,
handleFeatureSource: (src: FeatureSourceForLayer & Tiled, index: number) => void, handleFeatureSource: (src: FeatureSourceForLayer & Tiled, index: number) => void,
state: { state: {
@ -17,17 +33,32 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
leafletMap: any leafletMap: any
}) { }) {
const undefinedTiles = new Set<number>()
const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-" const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-"
// @ts-ignore // @ts-ignore
const indexes: number[] = Object.keys(localStorage) const indexes: number[] = Object.keys(localStorage)
.filter(key => { .filter(key => {
return key.startsWith(prefix) && !key.endsWith("-time"); return key.startsWith(prefix) && !key.endsWith("-time") && !key.endsWith("-format");
}) })
.map(key => { .map(key => {
return Number(key.substring(prefix.length)); return Number(key.substring(prefix.length));
}) })
.filter(i => !isNaN(i))
console.log("Layer", layer.layerDef.id, "has following tiles in available in localstorage", indexes.map(i => Utils.tile_from_index(i).join("/")).join(", ")) 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 zLevels = indexes.map(i => i % 100)
const indexesSet = new Set(indexes) const indexesSet = new Set(indexes)
@ -57,9 +88,9 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
const needed = [] const needed = []
for (let z = minZoom; z <= maxZoom; z++) { for (let z = minZoom; z <= maxZoom; z++) {
const tileRange = Utils.TileRangeBetween(z, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest()) const tileRange = Tiles.TileRangeBetween(z, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
const neededZ = Utils.MapRange(tileRange, (x, y) => Utils.tile_index(z, x, y)) const neededZ = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(z, x, y))
.filter(i => !self.loadedTiles.has(i) && indexesSet.has(i)) .filter(i => !self.loadedTiles.has(i) && !undefinedTiles.has(i) && indexesSet.has(i))
needed.push(...neededZ) needed.push(...neededZ)
} }
@ -70,8 +101,6 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
} }
, [layer.isDisplayed, state.leafletMap]).stabilized(50); , [layer.isDisplayed, state.leafletMap]).stabilized(50);
neededTiles.addCallbackAndRun(t => console.log("Tiles to load from localstorage:", t))
neededTiles.addCallbackAndRunD(neededIndexes => { neededTiles.addCallbackAndRunD(neededIndexes => {
for (const neededIndex of neededIndexes) { for (const neededIndex of neededIndexes) {
// We load the features from localStorage // We load the features from localStorage
@ -84,12 +113,13 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
features: new UIEventSource<{ feature: any; freshness: Date }[]>(features), features: new UIEventSource<{ feature: any; freshness: Date }[]>(features),
name: "FromLocalStorage(" + key + ")", name: "FromLocalStorage(" + key + ")",
tileIndex: neededIndex, tileIndex: neededIndex,
bbox: BBox.fromTile(...Utils.tile_from_index(neededIndex)) bbox: BBox.fromTileIndex(neededIndex)
} }
handleFeatureSource(src, neededIndex) handleFeatureSource(src, neededIndex)
self.loadedTiles.set(neededIndex, src) self.loadedTiles.set(neededIndex, src)
} catch (e) { } catch (e) {
console.error("Could not load data tile from local storage due to", e) console.error("Could not load data tile from local storage due to", e)
undefinedTiles.add(neededIndex)
} }
} }

View file

@ -1,5 +1,5 @@
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
import {Utils} from "../Utils"; import {BBox} from "./BBox";
export class GeoOperations { export class GeoOperations {
@ -8,7 +8,7 @@ export class GeoOperations {
} }
/** /**
* Converts a GeoJSon feature to a point feature * Converts a GeoJson feature to a point GeoJson feature
* @param feature * @param feature
*/ */
static centerpoint(feature: any) { static centerpoint(feature: any) {
@ -378,112 +378,3 @@ export class GeoOperations {
} }
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 = 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): 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;
}
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) {
return new BBox(Utils.tile_bounds_lon_lat(z, x, y))
}
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]]
}
}

View file

@ -1,9 +1,61 @@
import {Mapillary} from "./Mapillary"; import {Mapillary} from "./Mapillary";
import {Wikimedia} from "./Wikimedia"; import {WikimediaImageProvider} from "./WikimediaImageProvider";
import {Imgur} from "./Imgur"; import {Imgur} from "./Imgur";
import GenericImageProvider from "./GenericImageProvider";
import {UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import {WikidataImageProvider} from "./WikidataImageProvider";
/**
* A generic 'from the interwebz' image picker, without attribution
*/
export default class AllImageProviders { export default class AllImageProviders {
public static ImageAttributionSource = [Imgur.singleton, Mapillary.singleton, Wikimedia.singleton] public static ImageAttributionSource: ImageProvider[] = [
Imgur.singleton,
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
new GenericImageProvider([].concat(...Imgur.defaultValuePrefix, WikimediaImageProvider.commonsPrefix))]
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) {
const singleSource = imageProvider.GetRelevantUrls(tags, {
prefixes: imagePrefix !== undefined ? [imagePrefix] : undefined
})
allSources.push(singleSource)
singleSource.addCallbackAndRunD(_ => {
const all : ProvidedImage[] = [].concat(...allSources.map(source => source.data))
const uniq = []
const seen = new Set<string>()
for (const img of all) {
if(seen.has(img.url)){
continue
}
seen.add(img.url)
uniq.push(img)
}
source.setData(uniq)
})
}
return source;
}
} }

View file

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

View file

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

View file

@ -0,0 +1,64 @@
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 {
protected abstract readonly defaultKeyPrefixes : string[]
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
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) {
promise.then(providedImage => {
relevantUrls.data.push(providedImage)
relevantUrls.ping()
})
}
})
}
})
return relevantUrls
}
public abstract ExtractUrls(key: string, value: string) : Promise<Promise<ProvidedImage>[]>;
}

View file

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

View file

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

View file

@ -1,19 +1,19 @@
import {LicenseInfo} from "./Wikimedia"; import ImageProvider, {ProvidedImage} from "./ImageProvider";
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg"; import Svg from "../../Svg";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
import Constants from "../../Models/Constants";
export class Mapillary extends ImageAttributionSource { export class Mapillary extends ImageProvider {
defaultKeyPrefixes = ["mapillary"]
public static readonly singleton = new Mapillary(); public static readonly singleton = new Mapillary();
private static readonly v4_cached_urls = new Map<string, UIEventSource<string>>(); 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 constructor() { private constructor() {
super(); super();
} }
@ -56,30 +56,35 @@ export class Mapillary extends ImageAttributionSource {
return Svg.mapillary_svg(); return Svg.mapillary_svg();
} }
PrepareUrl(value: string): string | UIEventSource<string> { async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.PrepareUrlAsync(key, value)]
}
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
const keyV = Mapillary.ExtractKeyFromURL(value) const keyV = Mapillary.ExtractKeyFromURL(value)
if (!keyV.isApiv4) { if (!keyV.isApiv4) {
return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}` const url = `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Constants.mapillary_client_token_v3}`
return {
url: url,
provider: this,
key: key
}
} else { } else {
const key = keyV.key; const key = keyV.key;
if(Mapillary.v4_cached_urls.has(key)){ const metadataUrl = 'https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
return Mapillary.v4_cached_urls.get(key)
}
const metadataUrl ='https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Mapillary.client_token_v4;
const source = new UIEventSource<string>(undefined) const source = new UIEventSource<string>(undefined)
Mapillary.v4_cached_urls.set(key, source) Mapillary.v4_cached_urls.set(key, source)
Utils.downloadJson(metadataUrl).then( const response = await Utils.downloadJson(metadataUrl)
json => { const url = <string> response["thumb_1024_url"];
console.warn("Got response on mapillary image", json, json["thumb_1024_url"]) return {
return source.setData(json["thumb_1024_url"]); url: url,
provider: this,
key: key
} }
)
return source
} }
} }
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> { protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
const keyV = Mapillary.ExtractKeyFromURL(url) const keyV = Mapillary.ExtractKeyFromURL(url)
if (keyV.isApiv4) { if (keyV.isApiv4) {
@ -88,22 +93,19 @@ export class Mapillary extends ImageAttributionSource {
license.license = "CC BY-SA 4.0"; license.license = "CC BY-SA 4.0";
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; // license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true; license.attributionRequired = true;
return new UIEventSource<LicenseInfo>(license) return license
} }
const key = keyV.key const key = keyV.key
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined) const data = await Utils.downloadJson(metadataURL)
Utils.downloadJson(metadataURL).then(data => {
const license = new LicenseInfo(); const license = new LicenseInfo();
license.artist = data.properties?.username; license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0"; license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true; license.attributionRequired = true;
source.setData(license);
})
return source return license
} }
} }

View file

@ -0,0 +1,51 @@
import {Utils} from "../../Utils";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
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 wikidataUrl = "https://www.wikidata.org/wiki/"
if (value.startsWith(wikidataUrl)) {
value = value.substring(wikidataUrl.length)
}
if (!value.startsWith("Q")) {
value = "Q" + value
}
const url = "https://www.wikidata.org/wiki/Special:EntityData/" + value + ".json";
const response = await Utils.downloadJson(url)
const entity = response.entities[value];
const commons = entity.sitelinks.commonswiki;
// P18 is the claim 'depicted in this image'
const image = entity.claims.P18?.[0]?.mainsnak?.datavalue?.value;
const allImages = []
if (image !== undefined) {
// We found a 'File://'
const promises = await WikimediaImageProvider.singleton.ExtractUrls(key, image)
allImages.push(...promises)
}
if (commons !== undefined) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(commons, image)
allImages.push(...promises)
}
return allImages
}
}

View file

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

View file

@ -0,0 +1,170 @@
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}`;
}
console.log("Loading a wikimedia category: ", url)
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.log(`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 !== this.commons_key && !value.startsWith(WikimediaImageProvider.commonsPrefix)){
return []
}
if (value.startsWith(WikimediaImageProvider.commonsPrefix)) {
value = value.substring(WikimediaImageProvider.commonsPrefix.length)
} else if (value.startsWith("https://upload.wikimedia.org")) {
const result: ProvidedImage = {
key: undefined,
url: value,
provider: this
}
return [Promise.resolve(result)]
}
if (value.startsWith("Category:")) {
const urls = await WikimediaImageProvider.GetImagesInCategory(value)
return urls.map(image => this.UrlForImage(image))
}
if (value.startsWith("File:")) {
return [this.UrlForImage(value)]
}
if (value.startsWith("http")) {
// PRobably an error
return []
}
// We do a last effort and assume this is a file
return [this.UrlForImage("File:" + value)]
}
}

View file

@ -2,6 +2,7 @@ import SimpleMetaTagger from "./SimpleMetaTagger";
import {ExtraFuncParams, ExtraFunction} from "./ExtraFunction"; import {ExtraFuncParams, ExtraFunction} from "./ExtraFunction";
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import State from "../State";
/** /**
@ -31,39 +32,57 @@ export default class MetaTagging {
return; return;
} }
for (const metatag of SimpleMetaTagger.metatags) {
try { const metatagsToApply: SimpleMetaTagger [] = []
for (const metatag of SimpleMetaTagger.metatags) {
if (metatag.includesDates) { if (metatag.includesDates) {
if (options.includeDates ?? true) { if (options.includeDates ?? true) {
metatag.addMetaTags(features); metatagsToApply.push(metatag)
} }
} else { } else {
if (options.includeNonDates ?? true) { if (options.includeNonDates ?? true) {
metatag.addMetaTags(features); metatagsToApply.push(metatag)
}
} }
} }
} catch (e) { // The calculated functions - per layer - which add the new keys
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e)
}
}
// The functions - per layer - which add the new keys
const layerFuncs = this.createRetaggingFunc(layer) const layerFuncs = this.createRetaggingFunc(layer)
if (layerFuncs !== undefined) {
for (const feature of features) {
for (let i = 0; i < features.length; i++) {
const ff = features[i];
const feature = ff.feature
const freshness = ff.freshness
let somethingChanged = false
for (const metatag of metatagsToApply) {
try { try {
layerFuncs(params, feature.feature) if(!metatag.keys.some(key => feature.properties[key] === undefined)){
// All keys are already defined, we probably already ran this one
continue
}
somethingChanged = somethingChanged || metatag.applyMetaTagsOnFeature(feature, freshness)
} catch (e) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e, e.stack)
}
}
if(layerFuncs !== undefined){
try {
layerFuncs(params, feature)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
somethingChanged = true
}
if(somethingChanged){
State.state.allElements.getEventSourceById(feature.properties.id).ping()
} }
} }
} }
private static createRetaggingFunc(layer: LayerConfig): private static createRetaggingFunc(layer: LayerConfig):
((params: ExtraFuncParams, feature: any) => void) { ((params: ExtraFuncParams, feature: any) => void) {
const calculatedTags: [string, string][] = layer.calculatedTags; const calculatedTags: [string, string][] = layer.calculatedTags;
@ -106,7 +125,7 @@ export default class MetaTagging {
feature.properties[key] = result; feature.properties[key] = result;
} catch (e) { } catch (e) {
if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) { if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) {
console.warn("Could not calculate a calculated tag defined by " + code + " due to " + e + ". This is code defined in the theme. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features", e) console.warn("Could not calculate a calculated tag defined by " + code + " due to " + e + ". This is code defined in the theme. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features", e,e.stack)
MetaTagging.errorPrintCount++; MetaTagging.errorPrintCount++;
if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) { if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) {
console.error("Got ", MetaTagging.stopErrorOutputAt, " errors calculating this metatagging - stopping output now") console.error("Got ", MetaTagging.stopErrorOutputAt, " errors calculating this metatagging - stopping output now")
@ -131,6 +150,7 @@ export default class MetaTagging {
for (const f of functions) { for (const f of functions) {
f(params, feature); f(params, feature);
} }
State.state.allElements.getEventSourceById(feature.properties.id).ping();
} catch (e) { } catch (e) {
console.error("While calculating a tag value: ", e) console.error("While calculating a tag value: ", e)
} }

View file

@ -62,7 +62,7 @@ export default class DeleteAction {
} }
State.state.osmConnection.changesetHandler.DeleteElement( State.state.osmConnection.changesetHandler.DeleteElement(
obj, obj,
State.state.layoutToUse.data, State.state.layoutToUse,
reason, reason,
State.state.allElements, State.state.allElements,
() => { () => {

View file

@ -32,6 +32,9 @@ export class Changes {
this.allChanges.setData([...this.pendingChanges.data]) this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that // If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? []) this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? [])
// Note: a changeset might be reused which was opened just before and might have already used some ids
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
} }
private static createChangesetFor(csId: string, private static createChangesetFor(csId: string,
@ -90,74 +93,69 @@ export class Changes {
if (this.pendingChanges.data.length === 0) { if (this.pendingChanges.data.length === 0) {
return; return;
} }
if (this.isUploading.data) { if (this.isUploading.data) {
console.log("Is already uploading... Abort") console.log("Is already uploading... Abort")
return; return;
} }
this.isUploading.setData(true) this.isUploading.setData(true)
this.flushChangesAsync(flushreason)
.then(_ => {
this.isUploading.setData(false)
console.log("Changes flushed!");
})
.catch(e => {
this.isUploading.setData(false)
console.error("Flushing changes failed due to", e);
})
}
private async flushChangesAsync(flushreason: string = undefined): Promise<void> {
console.log("Beginning upload... " + flushreason ?? ""); console.log("Beginning upload... " + flushreason ?? "");
// At last, we build the changeset and upload // At last, we build the changeset and upload
const self = this; const self = this;
const pending = self.pendingChanges.data; const pending = self.pendingChanges.data;
const neededIds = Changes.GetNeededIds(pending) const neededIds = Changes.GetNeededIds(pending)
console.log("Needed ids", neededIds) const osmObjects = await Promise.all(neededIds.map(id => OsmObject.DownloadObjectAsync(id)));
OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => {
console.log("Got the fresh objects!", osmObjects, "pending: ", pending) console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
try { try {
const changes: { const changes: {
newObjects: OsmObject[], newObjects: OsmObject[],
modifiedObjects: OsmObject[] modifiedObjects: OsmObject[]
deletedObjects: OsmObject[] deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects) } = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made") console.log("No changes to be made")
self.pendingChanges.setData([]) self.pendingChanges.setData([])
self.isUploading.setData(false) self.isUploading.setData(false)
return true; // Unregister the callback
} }
await State.state.osmConnection.UploadChangeset(
State.state.osmConnection.UploadChangeset( State.state.layoutToUse,
State.state.layoutToUse.data,
State.state.allElements, State.state.allElements,
(csId) => Changes.createChangesetFor(csId, changes), (csId) => Changes.createChangesetFor(csId, changes),
() => {
console.log("Upload successfull!")
self.pendingChanges.setData([]);
self.isUploading.setData(false)
},
() => {
console.log("Upload failed - trying again later")
return self.isUploading.setData(false);
} // Failed - mark to try again
) )
console.log("Upload successfull!")
this.pendingChanges.setData([]);
this.isUploading.setData(false)
} catch (e) { } catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e) console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
self.pendingChanges.setData([]) self.pendingChanges.setData([])
self.isUploading.setData(false) self.isUploading.setData(false)
} }
return true;
});
} }
public applyAction(action: OsmChangeAction) { public async applyAction(action: OsmChangeAction): Promise<void> {
action.Perform(this).then(changes => { const changes = await action.Perform(this)
console.log("Received changes:", changes) console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes); this.pendingChanges.data.push(...changes);
this.pendingChanges.ping(); this.pendingChanges.ping();
this.allChanges.data.push(...changes) this.allChanges.data.push(...changes)
this.allChanges.ping() this.allChanges.ping()
})
} }
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
@ -311,4 +309,8 @@ export class Changes {
return result return result
} }
public registerIdRewrites(mappings: Map<string, string>): void {
}
} }

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json"; import * as polygon_features from "../../assets/polygon-features.json";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {BBox} from "../GeoOperations"; import {BBox} from "../BBox";
export abstract class OsmObject { export abstract class OsmObject {
@ -157,23 +157,6 @@ export abstract class OsmObject {
const elements: any[] = data.elements; const elements: any[] = data.elements;
return OsmObject.ParseObjects(elements); return OsmObject.ParseObjects(elements);
} }
public static DownloadAll(neededIds, forceRefresh = true): UIEventSource<OsmObject[]> {
// local function which downloads all the objects one by one
// this is one big loop, running one download, then rerunning the entire function
const allSources: UIEventSource<OsmObject> [] = neededIds.map(id => OsmObject.DownloadObject(id, forceRefresh))
const allCompleted = new UIEventSource(undefined).map(_ => {
return !allSources.some(uiEventSource => uiEventSource.data === undefined)
}, allSources)
return allCompleted.map(completed => {
if (completed) {
return allSources.map(src => src.data)
}
return undefined
});
}
protected static isPolygon(tags: any): boolean { protected static isPolygon(tags: any): boolean {
for (const tagsKey in tags) { for (const tagsKey in tags) {
if (!tags.hasOwnProperty(tagsKey)) { if (!tags.hasOwnProperty(tagsKey)) {

View file

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

View file

@ -31,7 +31,7 @@ export default class SimpleMetaTagger {
"_version_number"], "_version_number"],
doc: "Information about the last edit of this object." doc: "Information about the last edit of this object."
}, },
(feature) => {/*Note: also handled by 'UpdateTagsFromOsmAPI'*/ (feature) => {/*Note: also called by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties; const tgs = feature.properties;
@ -48,6 +48,7 @@ export default class SimpleMetaTagger {
move("changeset", "_last_edit:changeset") move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp") move("timestamp", "_last_edit:timestamp")
move("version", "_version_number") move("version", "_version_number")
return true;
} }
) )
private static latlon = new SimpleMetaTagger({ private static latlon = new SimpleMetaTagger({
@ -62,6 +63,7 @@ export default class SimpleMetaTagger {
feature.properties["_lon"] = "" + lon; feature.properties["_lon"] = "" + lon;
feature._lon = lon; // This is dirty, I know feature._lon = lon; // This is dirty, I know
feature._lat = lat; feature._lat = lat;
return true;
}) })
); );
private static surfaceArea = new SimpleMetaTagger( private static surfaceArea = new SimpleMetaTagger(
@ -74,6 +76,7 @@ export default class SimpleMetaTagger {
feature.properties["_surface"] = "" + sqMeters; feature.properties["_surface"] = "" + sqMeters;
feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10; feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10;
feature.area = sqMeters; feature.area = sqMeters;
return true;
}) })
); );
@ -84,7 +87,7 @@ export default class SimpleMetaTagger {
}, },
(feature => { (feature => {
const units = Utils.NoNull([].concat(...State.state?.layoutToUse?.data?.layers?.map(layer => layer.units ?? []))); const units = Utils.NoNull([].concat(...State.state?.layoutToUse?.layers?.map(layer => layer.units ?? [])));
if (units.length == 0) { if (units.length == 0) {
return; return;
} }
@ -94,6 +97,9 @@ export default class SimpleMetaTagger {
continue; continue;
} }
for (const unit of units) { for (const unit of units) {
if (unit === undefined) {
continue
}
if (unit.appliesToKeys === undefined) { if (unit.appliesToKeys === undefined) {
console.error("The unit ", unit, "has no appliesToKey defined") console.error("The unit ", unit, "has no appliesToKey defined")
continue continue
@ -102,7 +108,12 @@ export default class SimpleMetaTagger {
continue; continue;
} }
const value = feature.properties[key] const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value) const denom = unit.findDenomination(value)
if (denom === undefined) {
// no valid value found
break;
}
const [, denomination] = denom;
let canonical = denomination?.canonicalValue(value) ?? undefined; let canonical = denomination?.canonicalValue(value) ?? undefined;
if (canonical === value) { if (canonical === value) {
break; break;
@ -118,9 +129,7 @@ export default class SimpleMetaTagger {
} }
} }
if (rewritten) { return rewritten
State.state.allElements.getEventSourceById(feature.id).ping();
}
}) })
) )
@ -135,6 +144,7 @@ export default class SimpleMetaTagger {
const km = Math.floor(l / 1000) const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100) const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km + "." + kmRest feature.properties["_length:km"] = "" + km + "." + kmRest
return true;
}) })
) )
private static country = new SimpleMetaTagger( private static country = new SimpleMetaTagger(
@ -144,7 +154,6 @@ export default class SimpleMetaTagger {
}, },
feature => { feature => {
let centerPoint: any = GeoOperations.centerpoint(feature); let centerPoint: any = GeoOperations.centerpoint(feature);
const lat = centerPoint.geometry.coordinates[1]; const lat = centerPoint.geometry.coordinates[1];
const lon = centerPoint.geometry.coordinates[0]; const lon = centerPoint.geometry.coordinates[0];
@ -157,11 +166,11 @@ export default class SimpleMetaTagger {
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping(); tagsSource.ping();
} }
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
} }
}) })
return false;
} }
) )
private static isOpen = new SimpleMetaTagger( private static isOpen = new SimpleMetaTagger(
@ -174,7 +183,7 @@ export default class SimpleMetaTagger {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache // We are running from console, thus probably creating a cache
// isOpen is irrelevant // isOpen is irrelevant
return return false
} }
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
@ -199,7 +208,7 @@ export default class SimpleMetaTagger {
if (oldNextChange > (new Date()).getTime() && if (oldNextChange > (new Date()).getTime() &&
tags["_isOpen:oldvalue"] === tags["opening_hours"]) { tags["_isOpen:oldvalue"] === tags["opening_hours"]) {
// Already calculated and should not yet be triggered // Already calculated and should not yet be triggered
return; return false;
} }
tags["_isOpen"] = oh.getState() ? "yes" : "no"; tags["_isOpen"] = oh.getState() ? "yes" : "no";
@ -227,6 +236,7 @@ export default class SimpleMetaTagger {
} }
} }
updateTags(); updateTags();
return true;
} catch (e) { } catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e); console.warn("Error while parsing opening hours of ", tags.id, e);
tags["_isOpen"] = "parse_error"; tags["_isOpen"] = "parse_error";
@ -244,11 +254,11 @@ export default class SimpleMetaTagger {
const tags = feature.properties; const tags = feature.properties;
const direction = tags["camera:direction"] ?? tags["direction"]; const direction = tags["camera:direction"] ?? tags["direction"];
if (direction === undefined) { if (direction === undefined) {
return; return false;
} }
const n = cardinalDirections[direction] ?? Number(direction); const n = cardinalDirections[direction] ?? Number(direction);
if (isNaN(n)) { if (isNaN(n)) {
return; return false;
} }
// The % operator has range (-360, 360). We apply a trick to get [0, 360). // The % operator has range (-360, 360). We apply a trick to get [0, 360).
@ -256,126 +266,17 @@ export default class SimpleMetaTagger {
tags["_direction:numerical"] = normalized; tags["_direction:numerical"] = normalized;
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"; tags["_direction:leftright"] = normalized <= 180 ? "right" : "left";
return true;
}) })
) )
private static carriageWayWidth = new SimpleMetaTagger(
{
keys: ["_width:needed", "_width:needed:no_pedestrians", "_width:difference"],
doc: "Legacy for a specific project calculating the needed width for safe traffic on a road. Only activated if 'width:carriageway' is present"
},
feature => {
const properties = feature.properties;
if (properties["width:carriageway"] === undefined) {
return;
}
const carWidth = 2;
const cyclistWidth = 1.5;
const pedestrianWidth = 0.75;
const _leftSideParking =
new And([new Tag("parking:lane:left", "parallel"), new Tag("parking:lane:right", "no_parking")]);
const _rightSideParking =
new And([new Tag("parking:lane:right", "parallel"), new Tag("parking:lane:left", "no_parking")]);
const _bothSideParking = new Tag("parking:lane:both", "parallel");
const _noSideParking = new Tag("parking:lane:both", "no_parking");
const _otherParkingMode =
new Or([
new Tag("parking:lane:both", "perpendicular"),
new Tag("parking:lane:left", "perpendicular"),
new Tag("parking:lane:right", "perpendicular"),
new Tag("parking:lane:both", "diagonal"),
new Tag("parking:lane:left", "diagonal"),
new Tag("parking:lane:right", "diagonal"),
])
const _sidewalkBoth = new Tag("sidewalk", "both");
const _sidewalkLeft = new Tag("sidewalk", "left");
const _sidewalkRight = new Tag("sidewalk", "right");
const _sidewalkNone = new Tag("sidewalk", "none");
let parallelParkingCount = 0;
const _oneSideParking = new Or([_leftSideParking, _rightSideParking]);
if (_oneSideParking.matchesProperties(properties)) {
parallelParkingCount = 1;
} else if (_bothSideParking.matchesProperties(properties)) {
parallelParkingCount = 2;
} else if (_noSideParking.matchesProperties(properties)) {
parallelParkingCount = 0;
} else if (_otherParkingMode.matchesProperties(properties)) {
parallelParkingCount = 0;
} else {
console.log("No parking data for ", properties.name, properties.id)
}
let pedestrianFlowNeeded;
if (_sidewalkBoth.matchesProperties(properties)) {
pedestrianFlowNeeded = 0;
} else if (_sidewalkNone.matchesProperties(properties)) {
pedestrianFlowNeeded = 2;
} else if (_sidewalkLeft.matchesProperties(properties) || _sidewalkRight.matchesProperties(properties)) {
pedestrianFlowNeeded = 1;
} else {
pedestrianFlowNeeded = -1;
}
let onewayCar = properties.oneway === "yes";
let onewayBike = properties["oneway:bicycle"] === "yes" ||
(onewayCar && properties["oneway:bicycle"] === undefined)
let cyclingAllowed =
!(properties.bicycle === "use_sidepath"
|| properties.bicycle === "no");
let carWidthUsed = (onewayCar ? 1 : 2) * carWidth;
properties["_width:needed:cars"] = Utils.Round(carWidthUsed);
properties["_width:needed:parking"] = Utils.Round(parallelParkingCount * carWidth)
let cyclistWidthUsed = 0;
if (cyclingAllowed) {
cyclistWidthUsed = (onewayBike ? 1 : 2) * cyclistWidth;
}
properties["_width:needed:cyclists"] = Utils.Round(cyclistWidthUsed)
const width = parseFloat(properties["width:carriageway"]);
const targetWidthIgnoringPedestrians =
carWidthUsed +
cyclistWidthUsed +
parallelParkingCount * carWidthUsed;
properties["_width:needed:no_pedestrians"] = Utils.Round(targetWidthIgnoringPedestrians);
const pedestriansNeed = Math.max(0, pedestrianFlowNeeded) * pedestrianWidth;
const targetWidth = targetWidthIgnoringPedestrians + pedestriansNeed;
properties["_width:needed"] = Utils.Round(targetWidth);
properties["_width:needed:pedestrians"] = Utils.Round(pedestriansNeed)
properties["_width:difference"] = Utils.Round(targetWidth - width);
properties["_width:difference:no_pedestrians"] = Utils.Round(targetWidthIgnoringPedestrians - width);
}
);
private static currentTime = new SimpleMetaTagger( private static currentTime = new SimpleMetaTagger(
{ {
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"], keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely", doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely",
includesDates: true includesDates: true
}, },
(feature, _, freshness) => { (feature, freshness) => {
const now = new Date(); const now = new Date();
if (typeof freshness === "string") { if (typeof freshness === "string") {
@ -394,7 +295,7 @@ export default class SimpleMetaTagger {
feature.properties["_now:datetime"] = datetime(now); feature.properties["_now:datetime"] = datetime(now);
feature.properties["_loaded:date"] = date(freshness); feature.properties["_loaded:date"] = date(freshness);
feature.properties["_loaded:datetime"] = datetime(freshness); feature.properties["_loaded:datetime"] = datetime(freshness);
return true;
} }
) )
public static metatags = [ public static metatags = [
@ -404,7 +305,6 @@ export default class SimpleMetaTagger {
SimpleMetaTagger.canonicalize, SimpleMetaTagger.canonicalize,
SimpleMetaTagger.country, SimpleMetaTagger.country,
SimpleMetaTagger.isOpen, SimpleMetaTagger.isOpen,
SimpleMetaTagger.carriageWayWidth,
SimpleMetaTagger.directionSimplified, SimpleMetaTagger.directionSimplified,
SimpleMetaTagger.currentTime, SimpleMetaTagger.currentTime,
SimpleMetaTagger.objectMetaInfo SimpleMetaTagger.objectMetaInfo
@ -413,12 +313,18 @@ export default class SimpleMetaTagger {
public readonly keys: string[]; public readonly keys: string[];
public readonly doc: string; public readonly doc: string;
public readonly includesDates: boolean public readonly includesDates: boolean
private readonly _f: (feature: any, index: number, freshness: Date) => void; public readonly applyMetaTagsOnFeature: (feature: any, freshness: Date) => boolean;
constructor(docs: { keys: string[], doc: string, includesDates?: boolean }, f: ((feature: any, index: number, freshness: Date) => void)) { /***
* A function that adds some extra data to a feature
* @param docs: what does this extra data do?
* @param f: apply the changes. Returns true if something changed
*/
constructor(docs: { keys: string[], doc: string, includesDates?: boolean },
f: ((feature: any, freshness: Date) => boolean)) {
this.keys = docs.keys; this.keys = docs.keys;
this.doc = docs.doc; this.doc = docs.doc;
this._f = f; this.applyMetaTagsOnFeature = f;
this.includesDates = docs.includesDates ?? false; this.includesDates = docs.includesDates ?? false;
for (const key of docs.keys) { for (const key of docs.keys) {
if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) { if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) {
@ -450,12 +356,4 @@ export default class SimpleMetaTagger {
return new Combine(subElements).SetClass("flex-col") return new Combine(subElements).SetClass("flex-col")
} }
public addMetaTags(features: { feature: any, freshness: Date }[]) {
for (let i = 0; i < features.length; i++) {
let feature = features[i];
this._f(feature.feature, i, feature.freshness);
}
}
} }

View file

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

View file

@ -64,7 +64,7 @@ export class UIEventSource<T> {
public static FromPromise<T>(promise : Promise<T>): UIEventSource<T>{ public static FromPromise<T>(promise : Promise<T>): UIEventSource<T>{
const src = new UIEventSource<T>(undefined) const src = new UIEventSource<T>(undefined)
promise.then(d => src.setData(d)) promise?.then(d => src.setData(d))
return src return src
} }

View file

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

View file

@ -2,7 +2,23 @@ import {Utils} from "../Utils";
export default class Constants { export default class Constants {
public static vNumber = "0.10.0-alpha-0"; public static vNumber = "0.10.0-rc2";
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",
// "https://overpass.nchc.org.tw/api/interpreter",
"https://overpass.openstreetmap.ru/cgi/interpreter",
// The french api, only 1000 per day per project allowed, so we put it as last resort
"https://overpass.openstreetmap.fr/api/interpreter"
]
// The user journey states thresholds when a new feature gets unlocked // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

View file

@ -1,9 +1,10 @@
import {UIEventSource} from "../Logic/UIEventSource"; import {UIEventSource} from "../Logic/UIEventSource";
import LayerConfig from "./ThemeConfig/LayerConfig"; import LayerConfig from "./ThemeConfig/LayerConfig";
import {And} from "../Logic/Tags/And"; import {And} from "../Logic/Tags/And";
import FilterConfig from "./ThemeConfig/FilterConfig";
export default interface FilteredLayer { export default interface FilteredLayer {
readonly isDisplayed: UIEventSource<boolean>; readonly isDisplayed: UIEventSource<boolean>;
readonly appliedFilters: UIEventSource<And>; readonly appliedFilters: UIEventSource<{filter: FilterConfig, selected: number}[]>;
readonly layerDef: LayerConfig; readonly layerDef: LayerConfig;
} }

View file

@ -5,7 +5,8 @@ import Translations from "../../UI/i18n/Translations";
import {TagUtils} from "../../Logic/Tags/TagUtils"; import {TagUtils} from "../../Logic/Tags/TagUtils";
export default class FilterConfig { export default class FilterConfig {
readonly options: { public readonly id: string
public readonly options: {
question: Translation; question: Translation;
osmTags: TagsFilter; osmTags: TagsFilter;
}[]; }[];
@ -14,11 +15,18 @@ export default class FilterConfig {
if (json.options === undefined) { if (json.options === undefined) {
throw `A filter without options was given at ${context}` throw `A filter without options was given at ${context}`
} }
if (json.id === undefined) {
throw `A filter without id was found at ${context}`
}
if(json.id.match(/^[a-zA-Z0-9_-]*$/) === null){
throw `A filter with invalid id was found at ${context}. Ids should only contain letters, numbers or - _`
}
if (json.options.map === undefined) { if (json.options.map === undefined) {
throw `A filter was given where the options aren't a list at ${context}` throw `A filter was given where the options aren't a list at ${context}`
} }
this.id = json.id;
this.options = json.options.map((option, i) => { this.options = json.options.map((option, i) => {
const question = Translations.T( const question = Translations.T(
option.question, option.question,
@ -34,5 +42,9 @@ export default class FilterConfig {
return {question: question, osmTags: osmTags}; return {question: question, osmTags: osmTags};
}); });
if(this.options.length > 1 && this.options[0].osmTags["and"]?.length !== 0){
throw "Error in "+context+"."+this.id+": the first option of a multi-filter should always be the 'reset' option and not have any filters"
}
} }
} }

View file

@ -1,6 +1,10 @@
import {AndOrTagConfigJson} from "./TagConfigJson"; import {AndOrTagConfigJson} from "./TagConfigJson";
export default interface FilterConfigJson { export default interface FilterConfigJson {
/**
* An id/name for this filter, used to set the URL parameters
*/
id: string,
/** /**
* The options for a filter * The options for a filter
* If there are multiple options these will be a list of radio buttons * If there are multiple options these will be a list of radio buttons

View file

@ -228,8 +228,8 @@ export interface LayoutConfigJson {
*/ */
maxZoom?: number, maxZoom?: number,
/** /**
* The number of elements that should be showed (in total) before clustering starts to happen. * The number of elements per tile needed to start clustering
* If clustering is defined, defaults to 0 * If clustering is defined, defaults to 25
*/ */
minNeededElements?: number minNeededElements?: number
}, },
@ -263,9 +263,9 @@ export interface LayoutConfigJson {
enablePdfDownload?: boolean; enablePdfDownload?: boolean;
/** /**
* Set a different overpass URL. Default: https://overpass-api.de/api/interpreter * Set one or more overpass URLs to use for this theme..
*/ */
overpassUrl?: string; overpassUrl?: string | string[];
/** /**
* Set a different timeout for overpass queries - in seconds. Default: 30s * Set a different timeout for overpass queries - in seconds. Default: 30s
*/ */

View file

@ -5,6 +5,13 @@ import {AndOrTagConfigJson} from "./TagConfigJson";
* If the desired tags are missing and a question is defined, a question will be shown instead. * If the desired tags are missing and a question is defined, a question will be shown instead.
*/ */
export interface TagRenderingConfigJson { export interface TagRenderingConfigJson {
/**
* The id of the tagrendering, should be an unique string.
* Used to keep the translations in sync. Only used in the tagRenderings-array of a layerConfig, not requered otherwise
*/
id?: string,
/** /**
* Renders this value. Note that "{key}"-parts are substituted by the corresponding values of the element. * Renders this value. Note that "{key}"-parts are substituted by the corresponding values of the element.
* If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value. * If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value.

View file

@ -18,6 +18,7 @@ import FilterConfig from "./FilterConfig";
import {Unit} from "../Unit"; import {Unit} from "../Unit";
import DeleteConfig from "./DeleteConfig"; import DeleteConfig from "./DeleteConfig";
import Svg from "../../Svg"; import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
export default class LayerConfig { export default class LayerConfig {
static WAYHANDLING_DEFAULT = 0; static WAYHANDLING_DEFAULT = 0;
@ -154,6 +155,9 @@ export default class LayerConfig {
this.minzoom = json.minzoom ?? 0; this.minzoom = json.minzoom ?? 0;
this.minzoomVisible = json.minzoomVisible ?? this.minzoom; this.minzoomVisible = json.minzoomVisible ?? this.minzoom;
this.wayHandling = json.wayHandling ?? 0; this.wayHandling = json.wayHandling ?? 0;
if(json.presets !== undefined && json.presets?.map === undefined){
throw "Presets should be a list of items (at "+context+")"
}
this.presets = (json.presets ?? []).map((pr, i) => { this.presets = (json.presets ?? []).map((pr, i) => {
let preciseInput = undefined; let preciseInput = undefined;
@ -288,6 +292,13 @@ export default class LayerConfig {
this.tagRenderings = trs(json.tagRenderings, false); this.tagRenderings = trs(json.tagRenderings, false);
const missingIds = json.tagRenderings?.filter(tr => typeof tr !== "string" && tr["builtin"] === undefined && tr["id"] === undefined) ?? [];
if(missingIds.length > 0 && official){
console.error("Some tagRenderings of", this.id, "are missing an id:", missingIds)
throw "Missing ids in tagrenderings"
}
this.filters = (json.filter ?? []).map((option, i) => { this.filters = (json.filter ?? []).map((option, i) => {
return new FilterConfig(option, `${context}.filter-[${i}]`) return new FilterConfig(option, `${context}.filter-[${i}]`)
}); });
@ -499,12 +510,13 @@ export default class LayerConfig {
); );
const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/); const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/);
if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) {
html = new Combine([ html = new Img(
(Svg.All[match[1] + ".svg"] as string).replace( (Svg.All[match[1] + ".svg"] as string).replace(
/#000000/g, /#000000/g,
match[2] match[2]
), ),
]).SetStyle(style); true
).SetStyle(style);
} }
return html; return html;
} }

View file

@ -5,8 +5,8 @@ import SharedTagRenderings from "../../Customizations/SharedTagRenderings";
import AllKnownLayers from "../../Customizations/AllKnownLayers"; import AllKnownLayers from "../../Customizations/AllKnownLayers";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import LayerConfig from "./LayerConfig"; import LayerConfig from "./LayerConfig";
import {Unit} from "../Unit";
import {LayerConfigJson} from "./Json/LayerConfigJson"; import {LayerConfigJson} from "./Json/LayerConfigJson";
import Constants from "../Constants";
export default class LayoutConfig { export default class LayoutConfig {
public readonly id: string; public readonly id: string;
@ -51,7 +51,7 @@ export default class LayoutConfig {
How long is the cache valid, in seconds? How long is the cache valid, in seconds?
*/ */
public readonly cacheTimeout?: number; public readonly cacheTimeout?: number;
public readonly overpassUrl: string; public readonly overpassUrl: string[];
public readonly overpassTimeout: number; public readonly overpassTimeout: number;
public readonly official: boolean; public readonly official: boolean;
@ -87,6 +87,9 @@ export default class LayoutConfig {
this.startZoom = json.startZoom; this.startZoom = json.startZoom;
this.startLat = json.startLat; this.startLat = json.startLat;
this.startLon = json.startLon; this.startLon = json.startLon;
if(json.widenFactor < 1){
throw "Widenfactor too small"
}
this.widenFactor = json.widenFactor ?? 1.5; this.widenFactor = json.widenFactor ?? 1.5;
this.roamingRenderings = (json.roamingRenderings ?? []).map((tr, i) => { this.roamingRenderings = (json.roamingRenderings ?? []).map((tr, i) => {
if (typeof tr === "string") { if (typeof tr === "string") {
@ -127,17 +130,12 @@ export default class LayoutConfig {
this.clustering = { this.clustering = {
maxZoom: 16, maxZoom: 16,
minNeededElements: 500 minNeededElements: 25
}; };
if (json.clustering) { if (json.clustering) {
this.clustering = { this.clustering = {
maxZoom: json.clustering.maxZoom ?? 18, maxZoom: json.clustering.maxZoom ?? 18,
minNeededElements: json.clustering.minNeededElements ?? 1 minNeededElements: json.clustering.minNeededElements ?? 25
}
for (const layer of this.layers) {
if (layer.wayHandling !== LayerConfig.WAYHANDLING_CENTER_ONLY) {
console.debug("WARNING: In order to allow clustering, every layer must be set to CENTER_ONLY. Layer", layer.id, "does not respect this for layout", this.id);
}
} }
} }
@ -160,7 +158,14 @@ export default class LayoutConfig {
this.enablePdfDownload = json.enablePdfDownload ?? false; this.enablePdfDownload = json.enablePdfDownload ?? false;
this.customCss = json.customCss; this.customCss = json.customCss;
this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60) this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60)
this.overpassUrl = json.overpassUrl ?? "https://overpass-api.de/api/interpreter" this.overpassUrl = Constants.defaultOverpassUrls
if(json.overpassUrl !== undefined){
if(typeof json.overpassUrl === "string"){
this.overpassUrl = [json.overpassUrl]
}else{
this.overpassUrl = json.overpassUrl
}
}
this.overpassTimeout = json.overpassTimeout ?? 30 this.overpassTimeout = json.overpassTimeout ?? 30
} }

View file

@ -13,6 +13,7 @@ import {Utils} from "../../Utils";
*/ */
export default class TagRenderingConfig { export default class TagRenderingConfig {
readonly id: string;
readonly render?: Translation; readonly render?: Translation;
readonly question?: Translation; readonly question?: Translation;
readonly condition?: TagsFilter; readonly condition?: TagsFilter;
@ -56,6 +57,8 @@ export default class TagRenderingConfig {
return; return;
} }
this.id = json.id ?? "";
this.render = Translations.T(json.render, context + ".render"); this.render = Translations.T(json.render, context + ".render");
this.question = Translations.T(json.question, context + ".question"); this.question = Translations.T(json.question, context + ".question");
this.roaming = json.roaming ?? false; this.roaming = json.roaming ?? false;

View file

@ -6,3 +6,106 @@ export interface TileRange {
total: number, total: number,
zoomlevel: number zoomlevel: number
} }
export class Tiles {
public static MapRange<T>(tileRange: TileRange, f: (x: number, y: number) => T): T[] {
const result: T[] = []
for (let x = tileRange.xstart; x <= tileRange.xend; x++) {
for (let y = tileRange.ystart; y <= tileRange.yend; y++) {
const t = f(x, y);
result.push(t)
}
}
return result;
}
private static tile2long(x, z) {
return (x / Math.pow(2, z) * 360 - 180);
}
private static tile2lat(y, z) {
const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
return (180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))));
}
private static lon2tile(lon, zoom) {
return (Math.floor((lon + 180) / 360 * Math.pow(2, zoom)));
}
private static lat2tile(lat, zoom) {
return (Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, zoom)));
}
/**
* Calculates the tile bounds of the
* @param z
* @param x
* @param y
* @returns [[maxlat, minlon], [minlat, maxlon]]
*/
static tile_bounds(z: number, x: number, y: number): [[number, number], [number, number]] {
return [[Tiles.tile2lat(y, z), Tiles.tile2long(x, z)], [Tiles.tile2lat(y + 1, z), Tiles.tile2long(x + 1, z)]]
}
static tile_bounds_lon_lat(z: number, x: number, y: number): [[number, number], [number, number]] {
return [[Tiles.tile2long(x, z), Tiles.tile2lat(y, z)], [Tiles.tile2long(x + 1, z), Tiles.tile2lat(y + 1, z)]]
}
/**
* Returns the centerpoint [lon, lat] of the specified tile
* @param z
* @param x
* @param y
*/
static centerPointOf(z: number, x: number, y: number): [number, number]{
return [(Tiles.tile2long(x, z) + Tiles.tile2long(x+1, z)) / 2, (Tiles.tile2lat(y, z) + Tiles.tile2lat(y+1, z)) / 2]
}
static tile_index(z: number, x: number, y: number): number {
return ((x * (2 << z)) + y) * 100 + z
}
/**
* Given a tile index number, returns [z, x, y]
* @param index
* @returns 'zxy'
*/
static tile_from_index(index: number): [number, number, number] {
const z = index % 100;
const factor = 2 << z
index = Math.floor(index / 100)
const x = Math.floor(index / factor)
return [z, x, index % factor]
}
/**
* Return x, y of the tile containing (lat, lon) on the given zoom level
*/
static embedded_tile(lat: number, lon: number, z: number): { x: number, y: number, z: number } {
return {x: Tiles.lon2tile(lon, z), y: Tiles.lat2tile(lat, z), z: z}
}
static TileRangeBetween(zoomlevel: number, lat0: number, lon0: number, lat1: number, lon1: number): TileRange {
const t0 = Tiles.embedded_tile(lat0, lon0, zoomlevel)
const t1 = Tiles.embedded_tile(lat1, lon1, zoomlevel)
const xstart = Math.min(t0.x, t1.x)
const xend = Math.max(t0.x, t1.x)
const ystart = Math.min(t0.y, t1.y)
const yend = Math.max(t0.y, t1.y)
const total = (1 + xend - xstart) * (1 + yend - ystart)
return {
xstart: xstart,
xend: xend,
ystart: ystart,
yend: yend,
total: total,
zoomlevel: zoomlevel
}
}
}

View file

@ -17,7 +17,7 @@ import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
import FilteredLayer from "./Models/FilteredLayer"; import FilteredLayer from "./Models/FilteredLayer";
import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor"; import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor";
import LayoutConfig from "./Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "./Models/ThemeConfig/LayoutConfig";
import {BBox} from "./Logic/GeoOperations"; import {BBox} from "./Logic/BBox";
/** /**
* Contains the global state: a bunch of UI-event sources * Contains the global state: a bunch of UI-event sources
@ -27,16 +27,16 @@ export default class State {
// The singleton of the global state // The singleton of the global state
public static state: State; public static state: State;
public readonly layoutToUse = new UIEventSource<LayoutConfig>(undefined, "layoutToUse"); public readonly layoutToUse : LayoutConfig;
/** /**
The mapping from id -> UIEventSource<properties> The mapping from id -> UIEventSource<properties>
*/ */
public allElements: ElementStorage; public allElements: ElementStorage = new ElementStorage();
/** /**
THe change handler THe change handler
*/ */
public changes: Changes; public changes: Changes = new Changes();
/** /**
The leaflet instance of the big basemap The leaflet instance of the big basemap
*/ */
@ -81,9 +81,11 @@ export default class State {
public readonly featureSwitchEnableExport: UIEventSource<boolean>; public readonly featureSwitchEnableExport: UIEventSource<boolean>;
public readonly featureSwitchFakeUser: UIEventSource<boolean>; public readonly featureSwitchFakeUser: UIEventSource<boolean>;
public readonly featureSwitchExportAsPdf: UIEventSource<boolean>; public readonly featureSwitchExportAsPdf: UIEventSource<boolean>;
public readonly overpassUrl: UIEventSource<string>; public readonly overpassUrl: UIEventSource<string[]>;
public readonly overpassTimeout: UIEventSource<number>; public readonly overpassTimeout: UIEventSource<number>;
public readonly overpassMaxZoom: UIEventSource<number> = new UIEventSource<number>(undefined);
public readonly overpassMaxZoom: UIEventSource<number> = new UIEventSource<number>(17, "overpass-max-zoom: point to switch between OSM-api and overpass");
public featurePipeline: FeaturePipeline; public featurePipeline: FeaturePipeline;
@ -155,8 +157,7 @@ export default class State {
constructor(layoutToUse: LayoutConfig) { constructor(layoutToUse: LayoutConfig) {
const self = this; const self = this;
this.layoutToUse = layoutToUse;
this.layoutToUse.setData(layoutToUse);
// -- Location control initialization // -- Location control initialization
{ {
@ -194,13 +195,6 @@ export default class State {
lon.setData(latlonz.lon); lon.setData(latlonz.lon);
}); });
this.layoutToUse.addCallback((layoutToUse) => {
const lcd = self.locationControl.data;
lcd.zoom = lcd.zoom ?? layoutToUse?.startZoom;
lcd.lat = lcd.lat ?? layoutToUse?.startLat;
lcd.lon = lcd.lon ?? layoutToUse?.startLon;
self.locationControl.ping();
});
} }
// Helper function to initialize feature switches // Helper function to initialize feature switches
@ -209,28 +203,19 @@ export default class State {
deflt: (layout: LayoutConfig) => boolean, deflt: (layout: LayoutConfig) => boolean,
documentation: string documentation: string
): UIEventSource<boolean> { ): UIEventSource<boolean> {
const queryParameterSource = QueryParameters.GetQueryParameter(
key,
undefined,
documentation
);
// I'm so sorry about someone trying to decipher this
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened const defaultValue = deflt(self.layoutToUse);
return UIEventSource.flatten(
self.layoutToUse.map((layout) => {
const defaultValue = deflt(layout);
const queryParam = QueryParameters.GetQueryParameter( const queryParam = QueryParameters.GetQueryParameter(
key, key,
"" + defaultValue, "" + defaultValue,
documentation documentation
); );
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
return queryParam.map((str) => return queryParam.map((str) =>
str === undefined ? defaultValue : str !== "false" str === undefined ? defaultValue : str !== "false"
); )
}),
[queryParameterSource]
);
} }
// Feature switch initialization - not as a function as the UIEventSources are readonly // Feature switch initialization - not as a function as the UIEventSources are readonly
@ -336,9 +321,9 @@ export default class State {
); );
this.overpassUrl = QueryParameters.GetQueryParameter("overpassUrl", this.overpassUrl = QueryParameters.GetQueryParameter("overpassUrl",
layoutToUse?.overpassUrl, (layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(",") ,
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter" "Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
) ).map(param => param.split(","), [], urls => urls.join(","))
this.overpassTimeout = QueryParameters.GetQueryParameter("overpassTimeout", this.overpassTimeout = QueryParameters.GetQueryParameter("overpassTimeout",
"" + layoutToUse?.overpassTimeout, "" + layoutToUse?.overpassTimeout,
@ -373,22 +358,20 @@ export default class State {
return; return;
} }
this.osmConnection = new OsmConnection( this.osmConnection = new OsmConnection({
this.featureSwitchIsTesting.data, changes: this.changes,
this.featureSwitchFakeUser.data, dryRun: this.featureSwitchIsTesting.data,
QueryParameters.GetQueryParameter( fakeUser: this.featureSwitchFakeUser.data,
allElements: this.allElements,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token", "oauth_token",
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), ),
layoutToUse?.id, layoutName: layoutToUse?.id,
true, osmConfiguration: <'osm' | 'osm-test'>this.featureSwitchApiURL.data
// @ts-ignore })
this.featureSwitchApiURL.data
);
this.allElements = new ElementStorage();
this.changes = new Changes();
new ChangeToElementsActor(this.changes, this.allElements) new ChangeToElementsActor(this.changes, this.allElements)
@ -415,11 +398,11 @@ export default class State {
Locale.language Locale.language
.addCallback((currentLanguage) => { .addCallback((currentLanguage) => {
const layoutToUse = self.layoutToUse.data; const layoutToUse = self.layoutToUse;
if (layoutToUse === undefined) { if (layoutToUse === undefined) {
return; return;
} }
if (this.layoutToUse.data.language.indexOf(currentLanguage) < 0) { if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
console.log( console.log(
"Resetting language to", "Resetting language to",
layoutToUse.language[0], layoutToUse.language[0],

View file

@ -1,8 +1,8 @@
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer";
import {BBox} from "../../Logic/GeoOperations";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
import {BBox} from "../../Logic/BBox";
export interface MinimapOptions { export interface MinimapOptions {
background?: UIEventSource<BaseLayer>, background?: UIEventSource<BaseLayer>,
@ -30,6 +30,8 @@ export default class Minimap {
/** /**
* Construct a minimap * Construct a minimap
*/ */
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => {
throw "CreateMinimap hasn't been initialized yet. Please call MinimapImplementation.initialize()"
}
} }

View file

@ -4,10 +4,10 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox} from "../../Logic/GeoOperations";
import * as L from "leaflet"; import * as L from "leaflet";
import {Map} from "leaflet"; import {Map} from "leaflet";
import Minimap, {MinimapObj, MinimapOptions} from "./Minimap"; import Minimap, {MinimapObj, MinimapOptions} from "./Minimap";
import {BBox} from "../../Logic/BBox";
export default class MinimapImplementation extends BaseUIElement implements MinimapObj { export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0; private static _nextId = 0;
@ -114,8 +114,12 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
const self = this; const self = this;
// @ts-ignore // @ts-ignore
const resizeObserver = new ResizeObserver(_ => { const resizeObserver = new ResizeObserver(_ => {
try {
self.InitMap(); self.InitMap();
self.leafletMap?.data?.invalidateSize() self.leafletMap?.data?.invalidateSize()
} catch (e) {
console.error("Could not construct a minimap:", e)
}
}); });
resizeObserver.observe(div); resizeObserver.observe(div);
@ -141,8 +145,12 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
const location = this._location; const location = this._location;
const self = this; const self = this;
let currentLayer = this._background.data.layer() let currentLayer = this._background.data.layer()
let latLon = <[number, number]>[location.data?.lat ?? 0, location.data?.lon ?? 0]
if(isNaN(latLon[0]) || isNaN(latLon[1])){
latLon = [0,0]
}
const options = { const options = {
center: <[number, number]>[location.data?.lat ?? 0, location.data?.lon ?? 0], center: latLon,
zoom: location.data?.zoom ?? 2, zoom: location.data?.zoom ?? 2,
layers: [currentLayer], layers: [currentLayer],
zoomControl: false, zoomControl: false,

View file

@ -36,6 +36,8 @@ export default class ScrollableFullScreen extends UIElement {
this._component = this.BuildComponent(title("desktop"), content("desktop"), isShown) this._component = this.BuildComponent(title("desktop"), content("desktop"), isShown)
.SetClass("hidden md:block"); .SetClass("hidden md:block");
this._fullscreencomponent = this.BuildComponent(title("mobile"), content("mobile"), isShown); this._fullscreencomponent = this.BuildComponent(title("mobile"), content("mobile"), isShown);
const self = this; const self = this;
isShown.addCallback(isShown => { isShown.addCallback(isShown => {
if (isShown) { if (isShown) {

View file

@ -31,7 +31,7 @@ export class TabbedComponent extends Combine {
tabs.push(tab) tabs.push(tab)
} }
const header = new Combine(tabs).SetClass("block tabs-header-bar") const header = new Combine(tabs).SetClass("tabs-header-bar")
const actualContent = new VariableUiElement( const actualContent = new VariableUiElement(
openedTabSrc.map(i => contentElements[i]) openedTabSrc.map(i => contentElements[i])
) )

View file

@ -2,22 +2,23 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
export class VariableUiElement extends BaseUIElement { export class VariableUiElement extends BaseUIElement {
private _element: HTMLElement; private readonly _contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>;
constructor( constructor(contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>) {
contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>
) {
super(); super();
this._contents = contents;
this._element = document.createElement("span"); }
const el = this._element;
contents.addCallbackAndRun((contents) => { protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span");
this._contents.addCallbackAndRun((contents) => {
while (el.firstChild) { while (el.firstChild) {
el.removeChild(el.lastChild); el.removeChild(el.lastChild);
} }
if (contents === undefined) { if (contents === undefined) {
return el; return
} }
if (typeof contents === "string") { if (typeof contents === "string") {
el.innerHTML = contents; el.innerHTML = contents;
@ -35,9 +36,6 @@ export class VariableUiElement extends BaseUIElement {
} }
} }
}); });
} return el;
protected InnerConstructElement(): HTMLElement {
return this._element;
} }
} }

View file

@ -100,6 +100,7 @@ export default abstract class BaseUIElement {
throw "ERROR! This is not a correct baseUIElement: " + this.constructor.name throw "ERROR! This is not a correct baseUIElement: " + this.constructor.name
} }
try { try {
const el = this.InnerConstructElement(); const el = this.InnerConstructElement();

View file

@ -7,7 +7,7 @@ import Constants from "../../Models/Constants";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import {VariableUiElement} from "../Base/VariableUIElement"; import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {BBox} from "../../Logic/GeoOperations"; import {BBox} from "../../Logic/BBox";
/** /**
* The bottom right attribution panel in the leaflet map * The bottom right attribution panel in the leaflet map
@ -16,13 +16,13 @@ export default class Attribution extends Combine {
constructor(location: UIEventSource<Loc>, constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>, userDetails: UIEventSource<UserDetails>,
layoutToUse: UIEventSource<LayoutConfig>, layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>) { currentBounds: UIEventSource<BBox>) {
const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true); const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true);
const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true); const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true);
const layoutId = layoutToUse?.data?.id; const layoutId = layoutToUse?.id;
const now = new Date() const now = new Date()
// Note: getMonth is zero-index, we want 1-index but with one substracted, so it checks out! // Note: getMonth is zero-index, we want 1-index but with one substracted, so it checks out!
const startDate = now.getFullYear() + "-" + now.getMonth() + "-" + now.getDate() const startDate = now.getFullYear() + "-" + now.getMonth() + "-" + now.getDate()

View file

@ -20,11 +20,11 @@ export default class AttributionPanel extends Combine {
private static LicenseObject = AttributionPanel.GenerateLicenses(); private static LicenseObject = AttributionPanel.GenerateLicenses();
constructor(layoutToUse: UIEventSource<LayoutConfig>, contributions: UIEventSource<Map<string, number>>) { constructor(layoutToUse: LayoutConfig, contributions: UIEventSource<Map<string, number>>) {
super([ super([
Translations.t.general.attribution.attributionContent, Translations.t.general.attribution.attributionContent,
((layoutToUse.data.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.data.maintainer}), ((layoutToUse.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.maintainer}),
layoutToUse.data.credits, layoutToUse.credits,
"<br/>", "<br/>",
new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.currentBounds), new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.currentBounds),
"<br/>", "<br/>",
@ -65,7 +65,7 @@ export default class AttributionPanel extends Combine {
"<br/>", "<br/>",
AttributionPanel.CodeContributors(), AttributionPanel.CodeContributors(),
"<h3>", Translations.t.general.attribution.iconAttribution.title.Clone().SetClass("pt-6 pb-3"), "</h3>", "<h3>", Translations.t.general.attribution.iconAttribution.title.Clone().SetClass("pt-6 pb-3"), "</h3>",
...Utils.NoNull(Array.from(layoutToUse.data.ExtractImages())) ...Utils.NoNull(Array.from(layoutToUse.ExtractImages()))
.map(AttributionPanel.IconAttribution) .map(AttributionPanel.IconAttribution)
]); ]);
this.SetClass("flex flex-col link-underline overflow-hidden") this.SetClass("flex flex-col link-underline overflow-hidden")

View file

@ -25,7 +25,9 @@ export default class BackgroundSelector extends VariableUiElement {
if (baseLayers.length <= 1) { if (baseLayers.length <= 1) {
return undefined; return undefined;
} }
return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer) return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer, {
select_class: 'bg-indigo-100 p-1 rounded hover:bg-indigo-200 w-full'
})
} }
) )
) )

View file

@ -5,7 +5,7 @@ import State from "../../State";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import CheckBoxes from "../Input/Checkboxes"; import CheckBoxes from "../Input/Checkboxes";
import {BBox, GeoOperations} from "../../Logic/GeoOperations"; import {GeoOperations} from "../../Logic/GeoOperations";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import Title from "../Base/Title"; import Title from "../Base/Title";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"; import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
@ -13,19 +13,20 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger"; import SimpleMetaTagger from "../../Logic/SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {meta} from "@turf/turf"; import {meta} from "@turf/turf";
import {BBox} from "../../Logic/BBox";
export class DownloadPanel extends Toggle { export class DownloadPanel extends Toggle {
constructor() { constructor() {
const state: { const state: {
featurePipeline: FeaturePipeline, featurePipeline: FeaturePipeline,
layoutToUse: UIEventSource<LayoutConfig>, layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox> currentBounds: UIEventSource<BBox>
} = State.state } = State.state
const t = Translations.t.general.download const t = Translations.t.general.download
const name = State.state.layoutToUse.data.id; const name = State.state.layoutToUse.id;
const includeMetaToggle = new CheckBoxes([t.includeMetaData.Clone()]) const includeMetaToggle = new CheckBoxes([t.includeMetaData.Clone()])
const metaisIncluded = includeMetaToggle.GetValue().map(selected => selected.length > 0) const metaisIncluded = includeMetaToggle.GetValue().map(selected => selected.length > 0)

View file

@ -7,8 +7,6 @@ import Combine from "../Base/Combine";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import {Translation} from "../i18n/Translation"; import {Translation} from "../i18n/Translation";
import Svg from "../../Svg"; import Svg from "../../Svg";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {And} from "../../Logic/Tags/And";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import State from "../../State"; import State from "../../State";
@ -16,11 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer";
import BackgroundSelector from "./BackgroundSelector"; import BackgroundSelector from "./BackgroundSelector";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
/**
* Shows the filter
*/
export default class FilterView extends VariableUiElement { export default class FilterView extends VariableUiElement {
constructor(filteredLayer: UIEventSource<FilteredLayer[]>) { constructor(filteredLayer: UIEventSource<FilteredLayer[]>) {
const backgroundSelector = new Toggle( const backgroundSelector = new Toggle(
@ -101,26 +94,52 @@ export default class FilterView extends VariableUiElement {
return undefined; return undefined;
} }
let listFilterElements: [BaseUIElement, UIEventSource<TagsFilter>][] = layer.filters.map( const filterIndexes = new Map<string, number>()
layer.filters.forEach((f, i) => filterIndexes.set(f.id, i))
let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map(
FilterView.createFilter FilterView.createFilter
); );
const update = () => { listFilterElements.forEach((inputElement, i) =>
let listTagsFilters = Utils.NoNull( inputElement[1].addCallback((changed) => {
const oldValue = flayer.appliedFilters.data
if(changed === undefined){
// Lets figure out which filter should be removed
// We know this inputElement corresponds with layer.filters[i]
// SO, if there is a value in 'oldValue' with this filter, we have to recalculated
if(!oldValue.some(f => f.filter === layer.filters[i])){
// The filter to remove is already gone, we can stop
return;
}
}else if(oldValue.some(f => f.filter === changed.filter && f.selected === changed.selected)){
// The changed value is already there
return;
}
const listTagsFilters = Utils.NoNull(
listFilterElements.map((input) => input[1].data) listFilterElements.map((input) => input[1].data)
); );
flayer.appliedFilters.setData(new And(listTagsFilters));
};
listFilterElements.forEach((inputElement) => console.log(listTagsFilters, oldValue)
inputElement[1].addCallback((_) => update()) flayer.appliedFilters.setData(listTagsFilters);
})
); );
flayer.appliedFilters.addCallbackAndRun(appliedFilters => { flayer.appliedFilters.addCallbackAndRun(appliedFilters => {
if (appliedFilters === undefined || appliedFilters.and.length === 0) { for (let i = 0; i < layer.filters.length; i++){
listFilterElements.forEach(filter => filter[1].setData(undefined)) const filter = layer.filters[i];
return let foundMatch = undefined
for (const appliedFilter of appliedFilters) {
if(appliedFilter.filter === filter){
foundMatch = appliedFilter
break;
} }
}
listFilterElements[i][1].setData(foundMatch)
}
}) })
return new Combine(listFilterElements.map(input => input[0].SetClass("mt-3"))) return new Combine(listFilterElements.map(input => input[0].SetClass("mt-3")))
@ -128,7 +147,7 @@ export default class FilterView extends VariableUiElement {
} }
private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<TagsFilter>] { private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>] {
if (filterConfig.options.length === 1) { if (filterConfig.options.length === 1) {
let option = filterConfig.options[0]; let option = filterConfig.options[0];
@ -136,26 +155,42 @@ export default class FilterView extends VariableUiElement {
const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2"); const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2");
const toggle = new Toggle( const toggle = new Toggle(
new Combine([icon, option.question.Clone()]).SetClass("flex"), new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone()]).SetClass("flex") new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex")
) )
.ToggleOnClick() .ToggleOnClick()
.SetClass("block m-1") .SetClass("block m-1")
return [toggle, toggle.isEnabled.map(enabled => enabled ? option.osmTags : undefined, [], tags => tags !== undefined)] const selected = {
filter: filterConfig,
selected: 0
}
return [toggle, toggle.isEnabled.map(enabled => enabled ? selected : undefined, [],
f => f?.filter === filterConfig && f?.selected === 0)
]
} }
let options = filterConfig.options; let options = filterConfig.options;
const values = options.map((f, i) => ({
filter: filterConfig, selected: i
}))
const radio = new RadioButton( const radio = new RadioButton(
options.map( options.map(
(option) => (option, i) =>
new FixedInputElement(option.question.Clone(), option.osmTags) new FixedInputElement(option.question.Clone().SetClass("block"), i)
), ),
{ {
dontStyle: true dontStyle: true
} }
); );
return [radio, radio.GetValue()] return [radio,
radio.GetValue().map(
i => values[i],
[],
selected => {
return selected?.selected
}
)]
} }
} }

View file

@ -1,7 +1,5 @@
import State from "../../State"; import State from "../../State";
import ThemeIntroductionPanel from "./ThemeIntroductionPanel"; import ThemeIntroductionPanel from "./ThemeIntroductionPanel";
import * as personal from "../../assets/themes/personal/personal.json";
import PersonalLayersPanel from "./PersonalLayersPanel";
import Svg from "../../Svg"; import Svg from "../../Svg";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import ShareScreen from "./ShareScreen"; import ShareScreen from "./ShareScreen";
@ -21,7 +19,7 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
constructor(isShown: UIEventSource<boolean>) { constructor(isShown: UIEventSource<boolean>) {
const layoutToUse = State.state.layoutToUse.data; const layoutToUse = State.state.layoutToUse;
super( super(
() => layoutToUse.title.Clone(), () => layoutToUse.title.Clone(),
() => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails, isShown), () => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails, isShown),
@ -32,9 +30,7 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
private static ConstructBaseTabs(layoutToUse: LayoutConfig, isShown: UIEventSource<boolean>): { header: string | BaseUIElement; content: BaseUIElement }[] { private static ConstructBaseTabs(layoutToUse: LayoutConfig, isShown: UIEventSource<boolean>): { header: string | BaseUIElement; content: BaseUIElement }[] {
let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown); let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown);
if (layoutToUse.id === personal.id) {
welcome = new PersonalLayersPanel();
}
const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [ const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [
{header: `<img src='${layoutToUse.icon}'>`, content: welcome}, {header: `<img src='${layoutToUse.icon}'>`, content: welcome},
{ {

View file

@ -31,14 +31,14 @@ export default class ImportButton extends Toggle {
const button = new SubtleButton(imageUrl, message) const button = new SubtleButton(imageUrl, message)
button.onClick(() => { button.onClick(async () => {
if (isImported.data) { if (isImported.data) {
return return
} }
originalTags.data["_imported"] = "yes" originalTags.data["_imported"] = "yes"
originalTags.ping() // will set isImported as per its definition originalTags.ping() // will set isImported as per its definition
const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon) const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon)
State.state.changes.applyAction(newElementAction) await State.state.changes.applyAction(newElementAction)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
newElementAction.newElementId newElementAction.newElementId
)) ))

View file

@ -11,8 +11,8 @@ import AllDownloads from "./AllDownloads";
import FilterView from "./FilterView"; import FilterView from "./FilterView";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"; import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {BBox} from "../../Logic/GeoOperations";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import {BBox} from "../../Logic/BBox";
export default class LeftControls extends Combine { export default class LeftControls extends Combine {

View file

@ -126,7 +126,7 @@ export default class MoreScreen extends Combine {
if (layout.hideFromOverview) { if (layout.hideFromOverview) {
return undefined; return undefined;
} }
if (layout.id === State.state.layoutToUse.data?.id) { if (layout.id === State.state.layoutToUse?.id) {
return undefined; return undefined;
} }

View file

@ -1,120 +0,0 @@
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import Svg from "../../Svg";
import State from "../../State";
import Combine from "../Base/Combine";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class PersonalLayersPanel extends VariableUiElement {
constructor() {
super(
State.state.installedThemes.map(installedThemes => {
const t = Translations.t.favourite;
// Lets get all the layers
const allThemes = AllKnownLayouts.layoutsList.concat(installedThemes.map(layout => layout.layout))
.filter(theme => !theme.hideFromOverview)
const allLayers = []
{
const seenLayers = new Set<string>()
for (const layers of allThemes.map(theme => theme.layers)) {
for (const layer of layers) {
if (seenLayers.has(layer.id)) {
continue
}
seenLayers.add(layer.id)
allLayers.push(layer)
}
}
}
// Time to create a panel based on them!
const panel: BaseUIElement = new Combine(allLayers.map(PersonalLayersPanel.CreateLayerToggle));
return new Toggle(
new Combine([
t.panelIntro.Clone(),
panel
]).SetClass("flex flex-col"),
new SubtleButton(
Svg.osm_logo_ui(),
t.loginNeeded.Clone().SetClass("text-center")
).onClick(() => State.state.osmConnection.AttemptLogin()),
State.state.osmConnection.isLoggedIn
)
})
)
}
/***
* Creates a toggle for the given layer, which'll update State.state.favouriteLayers right away
* @param layer
* @constructor
* @private
*/
private static CreateLayerToggle(layer: LayerConfig): Toggle {
let icon: BaseUIElement = new Combine([layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false
).icon.html]).SetClass("relative")
let iconUnset = new Combine([layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false
).icon.html]).SetClass("relative")
iconUnset.SetStyle("opacity:0.1")
let name = layer.name;
if (name === undefined) {
return undefined;
}
const content = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col")
const contentUnselected = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col line-through")
return new Toggle(
new SubtleButton(
icon,
content),
new SubtleButton(
iconUnset,
contentUnselected
),
State.state.favouriteLayers.map(favLayers => {
return favLayers.indexOf(layer.id) >= 0
}, [], (selected, current) => {
if (!selected && current.indexOf(layer.id) <= 0) {
// Not selected and not contained: nothing to change: we return current as is
return current;
}
if (selected && current.indexOf(layer.id) >= 0) {
// Selected and contained: this is fine!
return current;
}
const clone = [...current]
if (selected) {
clone.push(layer.id)
} else {
clone.splice(clone.indexOf(layer.id), 1)
}
return clone
})
).ToggleOnClick();
}
}

View file

@ -17,7 +17,7 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class ShareScreen extends Combine { export default class ShareScreen extends Combine {
constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) { constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) {
layout = layout ?? State.state?.layoutToUse?.data; layout = layout ?? State.state?.layoutToUse;
layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition; layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition;
const tr = Translations.t.general.sharescreen; const tr = Translations.t.general.sharescreen;

View file

@ -19,8 +19,7 @@ import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig"; import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer"; import FilteredLayer from "../../Models/FilteredLayer";
import {And} from "../../Logic/Tags/And"; import {BBox} from "../../Logic/BBox";
import {BBox} from "../../Logic/GeoOperations";
/* /*
* The SimpleAddUI is a single panel, which can have multiple states: * The SimpleAddUI is a single panel, which can have multiple states:
@ -56,10 +55,9 @@ export default class SimpleAddUI extends Toggle {
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) { async function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
console.trace("Creating a new point")
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {snapOnto: snapOntoWay}) const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {snapOnto: snapOntoWay})
State.state.changes.applyAction(newElementAction) await State.state.changes.applyAction(newElementAction)
selectedPreset.setData(undefined) selectedPreset.setData(undefined)
isShown.setData(false) isShown.setData(false)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
@ -224,14 +222,32 @@ export default class SimpleAddUI extends Toggle {
] ]
).SetClass("flex flex-col") ).SetClass("flex flex-col")
).onClick(() => { ).onClick(() => {
preset.layerToAddTo.appliedFilters.setData(new And([])) preset.layerToAddTo.appliedFilters.setData([])
cancel() cancel()
}) })
const disableFiltersOrConfirm = new Toggle( const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm, openLayerOrConfirm,
disableFilter, disableFilter,
preset.layerToAddTo.appliedFilters.map(filters => filters === undefined || filters.normalize().and.length === 0) preset.layerToAddTo.appliedFilters.map(filters => {
if(filters === undefined || filters.length === 0){
return true;
}
for (const filter of filters) {
if(filter.selected === 0 && filter.filter.options.length === 1){
return false;
}
if(filter.selected !== undefined){
const tags = filter.filter.options[filter.selected].osmTags
if(tags !== undefined && tags["and"]?.length !== 0){
// This actually doesn't filter anything at all
return false;
}
}
}
return true
})
) )

View file

@ -2,21 +2,17 @@ import State from "../../State";
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import LanguagePicker from "../LanguagePicker"; import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton"; import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg"; import Svg from "../../Svg";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
export default class ThemeIntroductionPanel extends VariableUiElement { export default class ThemeIntroductionPanel extends Combine {
constructor(isShown: UIEventSource<boolean>) { constructor(isShown: UIEventSource<boolean>) {
const layout = State.state.layoutToUse
const languagePicker = const languagePicker = LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone())
new VariableUiElement(
State.state.layoutToUse.map(layout => LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone()))
)
;
const toTheMap = new SubtleButton( const toTheMap = new SubtleButton(
undefined, undefined,
@ -53,8 +49,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
State.state.featureSwitchUserbadge State.state.featureSwitchUserbadge
) )
super([
super(State.state.layoutToUse.map(layout => new Combine([
layout.description.Clone(), layout.description.Clone(),
"<br/><br/>", "<br/><br/>",
toTheMap, toTheMap,
@ -63,7 +58,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
"<br/>", "<br/>",
languagePicker, languagePicker,
...layout.CustomCodeSnippets() ...layout.CustomCodeSnippets()
]))) ])
this.SetClass("link-underline") this.SetClass("link-underline")
} }

View file

@ -47,7 +47,7 @@ export default class UserBadge extends Toggle {
}); });
const linkStyle = "flex items-baseline" const linkStyle = "flex items-baseline"
const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement("")) const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.language) ?? new FixedUiElement(""))
.SetStyle("width:min-content;"); .SetStyle("width:min-content;");
let messageSpan = let messageSpan =

View file

@ -5,7 +5,6 @@ import {SimpleMapScreenshoter} from "leaflet-simple-map-screenshoter";
import {UIEventSource} from "../Logic/UIEventSource"; import {UIEventSource} from "../Logic/UIEventSource";
import Minimap from "./Base/Minimap"; import Minimap from "./Base/Minimap";
import Loc from "../Models/Loc"; import Loc from "../Models/Loc";
import {BBox} from "../Logic/GeoOperations";
import BaseLayer from "../Models/BaseLayer"; import BaseLayer from "../Models/BaseLayer";
import {FixedUiElement} from "./Base/FixedUiElement"; import {FixedUiElement} from "./Base/FixedUiElement";
import Translations from "./i18n/Translations"; import Translations from "./i18n/Translations";
@ -14,6 +13,7 @@ import Constants from "../Models/Constants";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline"; import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer"; import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import {BBox} from "../Logic/BBox";
/** /**
* Creates screenshoter to take png screenshot * Creates screenshoter to take png screenshot
* Creates jspdf and downloads it * Creates jspdf and downloads it

View file

@ -1,30 +1,19 @@
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import Attribution from "./Attribution"; import Attribution from "./Attribution";
import Img from "../Base/Img"; import Img from "../Base/Img";
import ImageAttributionSource from "../../Logic/ImageProviders/ImageAttributionSource"; import {ProvidedImage} from "../../Logic/ImageProviders/ImageProvider";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
export class AttributedImage extends Combine { export class AttributedImage extends Combine {
constructor(urlSource: string, imgSource: ImageAttributionSource) { constructor(imageInfo: ProvidedImage) {
const preparedUrl = imgSource.PrepareUrl(urlSource)
let img: BaseUIElement; let img: BaseUIElement;
let attr: BaseUIElement let attr: BaseUIElement
if (typeof preparedUrl === "string") { img = new Img(imageInfo.url);
img = new Img(urlSource); attr = new Attribution(imageInfo.provider.GetAttributionFor(imageInfo.url),
attr = new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon()) imageInfo.provider.SourceIcon(),
} else { )
img = new VariableUiElement(preparedUrl.map(url => {
if(url === undefined){
return new Loading()
}
return new Img(url, false, {fallbackImage: './assets/svg/blocked.svg'});
}))
attr = new VariableUiElement(preparedUrl.map(_ => new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon())))
}
super([img, attr]); super([img, attr]);

View file

@ -3,7 +3,7 @@ import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement"; import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/ImageProviders/Wikimedia"; import {LicenseInfo} from "../../Logic/ImageProviders/LicenseInfo";
export default class Attribution extends VariableUiElement { export default class Attribution extends VariableUiElement {
@ -13,17 +13,16 @@ export default class Attribution extends VariableUiElement {
} }
super( super(
license.map((license: LicenseInfo) => { license.map((license: LicenseInfo) => {
if(license === undefined){
if (license?.artist === undefined) { return undefined
return undefined;
} }
return new Combine([ return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"), icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([ new Combine([
Translations.W(license.artist).SetClass("block font-bold"), Translations.W(license?.artist ?? ".").SetClass("block font-bold"),
Translations.W((license.license ?? "") === "" ? "CC0" : (license.license ?? "")) Translations.W((license?.license ?? "") === "" ? "CC0" : (license?.license ?? ""))
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg") ]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg")

View file

@ -15,15 +15,15 @@ export default class DeleteImage extends Toggle {
const isDeletedBadge = Translations.t.image.isDeleted.Clone() const isDeletedBadge = Translations.t.image.isDeleted.Clone()
.SetClass("rounded-full p-1") .SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c") .SetStyle("color:white;background:#ff8c8c")
.onClick(() => { .onClick(async() => {
State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data)) await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data))
}); });
const deleteButton = Translations.t.image.doDelete.Clone() const deleteButton = Translations.t.image.doDelete.Clone()
.SetClass("block w-full pl-4 pr-4") .SetClass("block w-full pl-4 pr-4")
.SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;") .SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;")
.onClick(() => { .onClick( async() => {
State.state?.changes?.applyAction( await State.state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data) new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data)
) )
}); });
@ -48,7 +48,7 @@ export default class DeleteImage extends Toggle {
tags.map(tags => (tags[key] ?? "") !== "") tags.map(tags => (tags[key] ?? "") !== "")
), ),
undefined /*Login (and thus editing) is disabled*/, undefined /*Login (and thus editing) is disabled*/,
State.state?.featureSwitchUserbadge ?? new UIEventSource<boolean>(true) State.state.osmConnection.isLoggedIn
) )
this.SetClass("cursor-pointer") this.SetClass("cursor-pointer")
} }

View file

@ -4,19 +4,20 @@ import Combine from "../Base/Combine";
import DeleteImage from "./DeleteImage"; import DeleteImage from "./DeleteImage";
import {AttributedImage} from "./AttributedImage"; import {AttributedImage} from "./AttributedImage";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import Img from "../Base/Img";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import {Wikimedia} from "../../Logic/ImageProviders/Wikimedia"; import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
import {Imgur} from "../../Logic/ImageProviders/Imgur";
import {Mapillary} from "../../Logic/ImageProviders/Mapillary";
export class ImageCarousel extends Toggle { export class ImageCarousel extends Toggle {
constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource<any>) { constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string }[]) => { const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = []; const uiElements: BaseUIElement[] = [];
for (const url of imageURLS) { for (const url of imageURLS) {
let image = ImageCarousel.CreateImageElement(url.url)
try {
let image = new AttributedImage(url)
if (url.key !== undefined) { if (url.key !== undefined) {
image = new Combine([ image = new Combine([
image, image,
@ -27,6 +28,11 @@ export class ImageCarousel extends Toggle {
.SetClass("w-full block") .SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;") .SetStyle("min-width: 50px; background: grey;")
uiElements.push(image); uiElements.push(image);
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
} }
return uiElements; return uiElements;
}); });
@ -38,33 +44,4 @@ export class ImageCarousel extends Toggle {
) )
this.SetClass("block w-full"); this.SetClass("block w-full");
} }
/***
* Creates either a 'simpleimage' or a 'wikimediaimage' based on the string
* @param url
* @constructor
*/
private static CreateImageElement(url: string): BaseUIElement {
// @ts-ignore
let attrSource: ImageAttributionSource = undefined;
if (url.startsWith("File:")) {
attrSource = Wikimedia.singleton
} else if (url.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
attrSource = Wikimedia.singleton;
} else if (url.toLowerCase().startsWith("https://i.imgur.com/")) {
attrSource = Imgur.singleton
} else if (url.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
attrSource = Mapillary.singleton
} else {
return new Img(url);
}
try {
return new AttributedImage(url, attrSource)
} catch (e) {
console.error("Could not create an image: ", e)
return undefined;
}
}
} }

View file

@ -29,10 +29,10 @@ export class ImageUploadFlow extends Toggle {
key = imagePrefix + ":" + freeIndex; key = imagePrefix + ":" + freeIndex;
} }
console.log("Adding image:" + key, url); console.log("Adding image:" + key, url);
State.state.changes Promise.resolve(State.state.changes
.applyAction(new ChangeTagAction( .applyAction(new ChangeTagAction(
tags.id, new Tag(key, url), tagsSource.data tags.id, new Tag(key, url), tagsSource.data
)) )))
}) })
@ -55,7 +55,7 @@ export class ImageUploadFlow extends Toggle {
const tags = tagsSource.data; const tags = tagsSource.data;
const layout = State.state?.layoutToUse?.data const layout = State.state?.layoutToUse
let matchingLayer: LayerConfig = undefined let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) { for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) { if (layer.source.osmTags.matchesProperties(tags)) {

View file

@ -7,12 +7,13 @@ import Combine from "../Base/Combine";
import Svg from "../../Svg"; import Svg from "../../Svg";
import State from "../../State"; import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox, GeoOperations} from "../../Logic/GeoOperations"; import {GeoOperations} from "../../Logic/GeoOperations";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import * as L from "leaflet";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"; import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../Logic/BBox";
import {FixedUiElement} from "../Base/FixedUiElement";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
export default class LocationInput extends InputElement<Loc> { export default class LocationInput extends InputElement<Loc> {
@ -39,7 +40,7 @@ export default class LocationInput extends InputElement<Loc> {
private readonly _maxSnapDistance: number private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any; private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>; private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: UIEventSource<LayerConfig>; public readonly _matching_layer: LayerConfig;
constructor(options: { constructor(options: {
mapBackground?: UIEventSource<BaseLayer>, mapBackground?: UIEventSource<BaseLayer>,
@ -63,18 +64,17 @@ export default class LocationInput extends InputElement<Loc> {
if (self._snappedPointTags !== undefined) { if (self._snappedPointTags !== undefined) {
this._matching_layer = State.state.layoutToUse.map(layout => { const layout = State.state.layoutToUse
let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) { for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) { if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
return layer matchingLayer = layer
} }
} }
console.error("No matching layer found for tags ", self._snappedPointTags) this._matching_layer = matchingLayer;
return LocationInput.matchLayer
})
} else { } else {
this._matching_layer = new UIEventSource<LayerConfig>(LocationInput.matchLayer) this._matching_layer = LocationInput.matchLayer
} }
this._snappedPoint = options.centerLocation.map(loc => { this._snappedPoint = options.centerLocation.map(loc => {
@ -125,7 +125,7 @@ export default class LocationInput extends InputElement<Loc> {
}) })
} }
this.mapBackground = options.mapBackground ?? State.state.backgroundLayer ?? new UIEventSource(AvailableBaseLayers.osmCarto) this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer ?? new UIEventSource(AvailableBaseLayers.osmCarto)
this.SetClass("block h-full") this.SetClass("block h-full")
} }
@ -144,7 +144,7 @@ export default class LocationInput extends InputElement<Loc> {
{ {
location: this._centerLocation, location: this._centerLocation,
background: this.mapBackground, background: this.mapBackground,
attribution: this.mapBackground !== State.state.backgroundLayer, attribution: this.mapBackground !== State.state?.backgroundLayer,
lastClickLocation: clickLocation, lastClickLocation: clickLocation,
bounds: this._bounds bounds: this._bounds
} }
@ -176,11 +176,10 @@ export default class LocationInput extends InputElement<Loc> {
enablePopups: false, enablePopups: false,
zoomToFeatures: false, zoomToFeatures: false,
leafletMap: map.leafletMap, leafletMap: map.leafletMap,
layerToShow: this._matching_layer.data layerToShow: this._matching_layer
}) })
} }
this.mapBackground.map(layer => { this.mapBackground.map(layer => {
const leaflet = map.leafletMap.data const leaflet = map.leafletMap.data
if (leaflet === undefined || layer === undefined) { if (leaflet === undefined || layer === undefined) {
@ -192,20 +191,31 @@ export default class LocationInput extends InputElement<Loc> {
leaflet.setZoom(layer.max_zoom - 1) leaflet.setZoom(layer.max_zoom - 1)
}, [map.leafletMap]) }, [map.leafletMap])
const animatedHand = Svg.hand_ui()
.SetStyle("width: 2rem; height: unset;")
.SetClass("hand-drag-animation block pointer-events-none")
return new Combine([ return new Combine([
new Combine([ new Combine([
Svg.move_arrows_ui() Svg.move_arrows_ui()
.SetClass("block relative pointer-events-none") .SetClass("block relative pointer-events-none")
.SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem") .SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem")
]).SetClass("block w-0 h-0 z-10 relative") ]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"), .SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%; opacity: 0.5"),
new Combine([
animatedHand])
.SetClass("block w-0 h-0 z-10 relative")
.SetStyle("left: calc(50% + 3rem); top: calc(50% + 2rem); opacity: 0.7"),
map map
.SetClass("z-0 relative block w-full h-full bg-gray-100") .SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement(); ]).ConstructElement();
} catch (e) { } catch (e) {
console.error("Could not generate LocationInputElement:", e) console.error("Could not generate LocationInputElement:", e)
return undefined; return new FixedUiElement("Constructing a locationInput failed due to" + e).SetClass("alert").ConstructElement();
} }
} }

View file

@ -51,14 +51,14 @@ export default class DeleteWizard extends Toggle {
const confirm = new UIEventSource<boolean>(false) const confirm = new UIEventSource<boolean>(false)
function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) { async function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
if (reason !== undefined) { if (reason !== undefined) {
tagsToApply.splice(0, 0, { tagsToApply.splice(0, 0, {
k: "fixme", k: "fixme",
v: `A mapcomplete user marked this feature to be deleted (${reason})` v: `A mapcomplete user marked this feature to be deleted (${reason})`
}) })
} }
(State.state?.changes ?? new Changes()) await (State.state?.changes ?? new Changes())
.applyAction(new ChangeTagAction( .applyAction(new ChangeTagAction(
id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data
)) ))

View file

@ -31,8 +31,10 @@ export default class EditableTagRendering extends Toggle {
const answerWithEditButton = new Combine([answer, const answerWithEditButton = new Combine([answer,
new Toggle(editButton, undefined, State.state.osmConnection.isLoggedIn)]) new Toggle(editButton,
.SetClass("flex justify-between w-full") undefined,
State.state.osmConnection.isLoggedIn)
]).SetClass("flex justify-between w-full")
const cancelbutton = const cancelbutton =

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