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
uses: actions/setup-node@v1.2.0
with:
node-version: '15'
node-version: '16'
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ import Link from "./UI/Base/Link";
import * as personal from "./assets/themes/personal/personal.json";
import * as L from "leaflet";
import Img from "./UI/Base/Img";
import UserDetails from "./Logic/Osm/OsmConnection";
import Attribution from "./UI/BigComponents/Attribution";
import BackgroundLayerResetter from "./Logic/Actors/BackgroundLayerResetter";
import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs";
@ -26,16 +25,22 @@ import ScrollableFullScreen from "./UI/Base/ScrollableFullScreen";
import Translations from "./UI/i18n/Translations";
import MapControlButton from "./UI/MapControlButton";
import LZString from "lz-string";
import AllKnownLayers from "./Customizations/AllKnownLayers";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
import {TagsFilter} from "./Logic/Tags/TagsFilter";
import LeftControls from "./UI/BigComponents/LeftControls";
import RightControls from "./UI/BigComponents/RightControls";
import {LayoutConfigJson} from "./Models/ThemeConfig/Json/LayoutConfigJson";
import LayoutConfig from "./Models/ThemeConfig/LayoutConfig";
import LayerConfig from "./Models/ThemeConfig/LayerConfig";
import Minimap from "./UI/Base/Minimap";
import SelectedFeatureHandler from "./Logic/Actors/SelectedFeatureHandler";
import Combine from "./UI/Base/Combine";
import {SubtleButton} from "./UI/Base/SubtleButton";
import ShowTileInfo from "./UI/ShowDataLayer/ShowTileInfo";
import {Tiles} from "./Models/TileRange";
import {TileHierarchyAggregator} from "./UI/ShowDataLayer/TileHierarchyAggregator";
import FilterConfig from "./Models/ThemeConfig/FilterConfig";
import FilteredLayer from "./Models/FilteredLayer";
import {BBox} from "./Logic/BBox";
import {AllKnownLayouts} from "./Customizations/AllKnownLayouts";
export class InitUiElements {
static InitAll(
@ -62,10 +67,24 @@ export class InitUiElements {
"LayoutFromBase64 is ",
layoutFromBase64
);
if(layoutToUse.id === personal.id){
layoutToUse.layers = AllKnownLayouts.AllPublicLayers()
for (const layer of layoutToUse.layers) {
layer.minzoomVisible = Math.max(layer.minzoomVisible, layer.minzoom)
layer.minzoom = Math.max(16, layer.minzoom)
}
}
State.state = new State(layoutToUse);
// This 'leaks' the global state via the window object, useful for debugging
if(layoutToUse.id === personal.id) {
// Disable overpass all together
State.state.overpassMaxZoom.setData(0)
}
// This 'leaks' the global state via the window object, useful for debugging
// @ts-ignore
window.mapcomplete_state = State.state;
@ -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) {
Utils.LoadCustomCss(layoutToUse.customCss);
}
@ -165,35 +145,42 @@ export class InitUiElements {
).AttachTo("messagesbox");
}
State.state.osmConnection.userDetails
.map((userDetails: UserDetails) => userDetails?.home)
.addCallbackAndRunD((home) => {
const color = getComputedStyle(document.body).getPropertyValue(
"--subtle-detail-color"
);
const icon = L.icon({
iconUrl: Img.AsData(
Svg.home_white_bg.replace(/#ffffff/g, color)
),
iconSize: [30, 30],
iconAnchor: [15, 15],
});
const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(State.state.leafletMap.data);
function addHomeMarker() {
const userDetails = State.state.osmConnection.userDetails.data;
if (userDetails === undefined) {
return false;
}
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(
"--subtle-detail-color"
);
const icon = L.icon({
iconUrl: Img.AsData(
Svg.home_white_bg.replace(/#ffffff/g, color)
),
iconSize: [30, 30],
iconAnchor: [15, 15],
});
if (layoutToUse.id === personal.id) {
updateFavs();
const marker = L.marker([home.lat, home.lon], {icon: icon});
marker.addTo(leaflet);
return true;
}
State.state.osmConnection.userDetails
.addCallbackAndRunD(_ => addHomeMarker());
State.state.leafletMap.addCallbackAndRunD(_ => addHomeMarker())
InitUiElements.setupAllLayerElements();
if (layoutToUse.id === personal.id) {
State.state.favouriteLayers.addCallback(updateFavs);
State.state.installedThemes.addCallback(updateFavs);
} else {
State.state.locationControl.ping();
}
new SelectedFeatureHandler(Hash.hash, State.state)
@ -207,8 +194,8 @@ export class InitUiElements {
static LoadLayoutFromHash(
userLayoutParam: UIEventSource<string>
): [LayoutConfig, string] {
let hash = location.hash.substr(1);
try {
let hash = location.hash.substr(1);
const layoutFromBase64 = userLayoutParam.data;
// 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);
return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))];
} catch (e) {
new FixedUiElement(
"Error: could not parse the custom layout:<br/> " + e
).AttachTo("centermessage");
if (hash === undefined || hash.length < 10) {
e = "Did you effectively add a theme? It seems no data could be found."
}
new Combine([
"Error: could not parse the custom layout:",
new FixedUiElement("" + e).SetClass("alert"),
new SubtleButton("./assets/svg/mapcomplete_logo.svg",
"Go back to the theme overview",
{url: window.location.protocol + "//" + window.location.hostname + "/index.html", newTab: false})
])
.SetClass("flex flex-col")
.AttachTo("centermessage");
throw e;
}
}
@ -322,9 +321,7 @@ export class InitUiElements {
State.state.backgroundLayer,
State.state.locationControl,
State.state.availableBackgroundLayers,
State.state.layoutToUse.map(
(layout: LayoutConfig) => layout.defaultBackgroundId
)
State.state.layoutToUse.defaultBackgroundId
);
const attr = new Attribution(
@ -344,15 +341,15 @@ export class InitUiElements {
}).SetClass("w-full h-full")
.AttachTo("leafletDiv")
const layout = State.state.layoutToUse.data;
const layout = State.state.layoutToUse;
if (layout.lockLocation) {
if (layout.lockLocation === true) {
const tile = Utils.embedded_tile(
const tile = Tiles.embedded_tile(
layout.startLat,
layout.startLon,
layout.startZoom - 1
);
const bounds = Utils.tile_bounds(tile.z, tile.x, tile.y);
const bounds = Tiles.tile_bounds(tile.z, tile.x, tile.y);
// We use the bounds to get a sense of distance for this zoom level
const latDiff = bounds[0][0] - bounds[1][0];
const lonDiff = bounds[0][1] - bounds[1][1];
@ -372,41 +369,137 @@ export class InitUiElements {
private static InitLayers(): void {
const state = State.state;
state.filteredLayers = state.layoutToUse.map((layoutToUse) => {
const flayers = [];
const empty = []
for (const layer of layoutToUse.layers) {
const isDisplayed = QueryParameters.GetQueryParameter(
const flayers: FilteredLayer[] = [];
for (const layer of state.layoutToUse.layers) {
let defaultShown = "true"
if(state.layoutToUse.id === personal.id){
defaultShown = "false"
}
let isDisplayed: UIEventSource<boolean>
if(state.layoutToUse.id === personal.id){
isDisplayed = State.state.osmConnection.GetPreference("personal-theme-layer-" + layer.id + "-enabled")
.map(value => value === "yes", [], enabled => {
return enabled ? "yes" : "";
})
isDisplayed.addCallbackAndRun(d =>console.log("IsDisplayed for layer", layer.id, "is currently", d) )
}else{
isDisplayed = QueryParameters.GetQueryParameter(
"layer-" + layer.id,
"true",
defaultShown,
"Wether or not layer " + layer.id + " is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => b.toString()
);
const flayer = {
isDisplayed: isDisplayed,
layerDef: layer,
appliedFilters: new UIEventSource<TagsFilter>(undefined),
};
flayers.push(flayer);
}
return flayers;
});
const flayer = {
isDisplayed: isDisplayed,
layerDef: layer,
appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]),
};
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);
}
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(
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(
{
features: source,
leafletMap: State.state.leafletMap,
layerToShow: source.layer.layerDef
layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures
}
);
}, state
);
}
private static setupAllLayerElements() {

View file

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

View file

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

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 Loc from "../../Models/Loc";
import {Or} from "../Tags/Or";
import {Overpass} from "../Osm/Overpass";
import Bounds from "../../Models/Bounds";
import FeatureSource, {FeatureSourceState} from "../FeatureSource/FeatureSource";
import FeatureSource from "../FeatureSource/FeatureSource";
import {Utils} from "../../Utils";
import {TagsFilter} from "../Tags/TagsFilter";
import SimpleMetaTagger from "../SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import RelationsTracker from "../Osm/RelationsTracker";
import {BBox} from "../BBox";
import Loc from "../../Models/Loc";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class OverpassFeatureSource implements FeatureSource, FeatureSourceState {
export default class OverpassFeatureSource implements FeatureSource {
public readonly name = "OverpassFeatureSource"
@ -21,124 +22,54 @@ export default class OverpassFeatureSource implements FeatureSource, FeatureSour
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]> = new UIEventSource<any[]>(undefined);
public readonly sufficientlyZoomed: UIEventSource<boolean>;
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false);
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0);
public readonly relationsTracker: RelationsTracker;
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0);
/**
* The previous bounds for which the query has been run at the given zoom level
*
* Note that some layers only activate on a certain zoom level.
* If the map location changes, we check for each layer if it is loaded:
* we start checking the bounds at the first zoom level the layer might operate. If in bounds - no reload needed, otherwise we continue walking down
*/
private readonly _previousBounds: Map<number, Bounds[]> = new Map<number, Bounds[]>();
private readonly state: {
readonly locationControl: UIEventSource<Loc>,
readonly layoutToUse: UIEventSource<LayoutConfig>,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string>;
readonly layoutToUse: LayoutConfig,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly currentBounds: UIEventSource<BBox>
}
private readonly _isActive: UIEventSource<boolean>;
private readonly onBboxLoaded: (bbox: BBox, date: Date, layers: LayerConfig[]) => void;
/**
* The most important layer should go first, as that one gets first pick for the questions
*/
constructor(
state: {
readonly locationControl: UIEventSource<Loc>,
readonly layoutToUse: UIEventSource<LayoutConfig>,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string>;
readonly layoutToUse: LayoutConfig,
readonly overpassUrl: UIEventSource<string[]>;
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.relationsTracker = new RelationsTracker()
const location = state.locationControl
this._isActive = options.isActive;
this.onBboxLoaded = options.onBboxLoaded
this.relationsTracker = options.relationTracker
const self = this;
this.sufficientlyZoomed = location.map(location => {
if (location?.zoom === undefined) {
return false;
}
let minzoom = Math.min(...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(() => {
state.currentBounds.addCallback(_ => {
self.update()
});
location.addCallback(() => {
self.update()
});
state.leafletMap.addCallbackAndRunD(_ => {
self.update();
})
}
public ForceRefresh() {
for (let i = 0; i < 25; i++) {
this._previousBounds.set(i, []);
}
this.update();
}
private GetFilter(): Overpass {
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = [];
let extraScripts: string[] = [];
for (const layer of this.state.layoutToUse.data.layers) {
if (typeof (layer) === "string") {
throw "A layer was not expanded!"
}
if (this.state.locationControl.data.zoom < layer.minzoom) {
continue;
}
if (layer.doNotDownload) {
continue;
}
if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer!
continue;
}
// Check if data for this layer has already been loaded
let previouslyLoaded = false;
for (let z = layer.minzoom; z < 25 && !previouslyLoaded; z++) {
const previousLoadedBounds = this._previousBounds.get(z);
if (previousLoadedBounds === undefined) {
continue;
}
for (const previousLoadedBound of previousLoadedBounds) {
previouslyLoaded = previouslyLoaded || this.IsInBounds(previousLoadedBound);
if (previouslyLoaded) {
break;
}
}
}
if (previouslyLoaded) {
continue;
}
for (const layer of layersToDownload) {
if (layer.source.overpassScript !== undefined) {
extraScripts.push(layer.source.overpassScript)
} else {
@ -150,98 +81,113 @@ export default class OverpassFeatureSource implements FeatureSource, FeatureSour
if (filters.length + extraScripts.length === 0) {
return undefined;
}
return new Overpass(new Or(filters), extraScripts, this.state.overpassUrl, this.state.overpassTimeout, this.relationsTracker);
return new Overpass(new Or(filters), extraScripts, interpreterUrl, this.state.overpassTimeout, this.relationsTracker);
}
private update(): void {
private update() {
if (!this._isActive.data) {
return;
}
const self = this;
this.updateAsync().then(bboxDate => {
if(bboxDate === undefined || self.onBboxLoaded === undefined){
return;
}
const [bbox, date, layers] = bboxDate
self.onBboxLoaded(bbox, date, layers)
})
}
private async updateAsync(): Promise<[BBox, Date, LayerConfig[]]> {
if (this.runningQuery.data) {
console.log("Still running a query, not updating");
return;
return undefined;
}
if (this.timeout.data > 0) {
console.log("Still in timeout - not updating")
return;
return undefined;
}
const bounds = this.state.leafletMap.data?.getBounds()?.pad(this.state.layoutToUse.data.widenFactor);
const bounds = this.state.currentBounds.data?.pad(this.state.layoutToUse.widenFactor)?.expandToTileBounds(14);
if (bounds === undefined) {
return;
return undefined;
}
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) {
return;
const layersToDownload = []
for (const layer of this.state.layoutToUse.layers) {
if (typeof (layer) === "string") {
throw "A layer was not expanded!"
}
if(this.state.locationControl.data.zoom < layer.minzoom){
continue;
}
if (layer.doNotDownload) {
continue;
}
if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer!
continue;
}
layersToDownload.push(layer)
}
this.runningQuery.setData(true);
overpass.queryGeoJson(queryBounds).
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)
try{
self.features.setData(features);
}catch(e){
console.error("Got the overpass response, but could not process it: ", e, e.stack)
let data: any = undefined
let date: Date = undefined
const overpassUrls = self.state.overpassUrl.data
let lastUsed = 0;
do {
try {
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload);
if (overpass === undefined) {
return undefined;
}
self.runningQuery.setData(false);
})
.catch((reason) => {
this.runningQuery.setData(true);
[data, date] = await overpass.queryGeoJson(bounds)
console.log("Querying overpass is done", data)
} catch (e) {
self.retries.data++;
self.ForceRefresh();
self.timeout.setData(self.retries.data * 5);
console.error(`QUERY FAILED (retrying in ${5 * self.retries.data} sec) due to`, reason);
self.retries.ping();
self.runningQuery.setData(false);
console.error(`QUERY FAILED due to`, e);
function countDown() {
window?.setTimeout(
function () {
if (self.timeout.data > 1) {
self.timeout.setData(self.timeout.data - 1);
window.setTimeout(
countDown,
1000
)
} else {
self.timeout.setData(0);
self.update()
}
}, 1000
)
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();
}
}
countDown();
}
);
} while (data === undefined);
}
private IsInBounds(bounds: Bounds): boolean {
if (this._previousBounds === undefined) {
return false;
self.retries.setData(0);
try {
data.features.forEach(feature => SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(feature, date));
self.features.setData(data.features.map(f => ({feature: f, freshness: date})));
return [bounds, date, layersToDownload];
} catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally {
self.runningQuery.setData(false);
}
const b = this.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 TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../ElementStorage";
export default class TitleHandler {
constructor(state) {
constructor(state : {
selectedElement: UIEventSource<any>,
layoutToUse: LayoutConfig,
allElements: ElementStorage
}) {
const currentTitle: UIEventSource<string> = state.selectedElement.map(
selected => {
const layout = state.layoutToUse.data
const layout = state.layoutToUse
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
if (selected === undefined) {
@ -23,11 +29,11 @@ export default class TitleHandler {
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = state.allElements.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? 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 FeaturePipeline from "./FeatureSource/FeaturePipeline";
import Loc from "../Models/Loc";
import {BBox} from "./GeoOperations";
import {BBox} from "./BBox";
export default class ContributorCount {

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
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 {UIEventSource} from "../UIEventSource";
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
@ -17,76 +17,111 @@ import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFea
import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource";
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor";
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource";
import {BBox} from "../GeoOperations";
import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger";
import RelationsTracker from "../Osm/RelationsTracker";
import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource";
import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator";
import {BBox} from "../BBox";
import OsmFeatureSource from "./TiledFeatureSource/OsmFeatureSource";
import {OsmConnection} from "../Osm/OsmConnection";
import {Tiles} from "../../Models/TileRange";
import TileFreshnessCalculator from "./TileFreshnessCalculator";
export default class FeaturePipeline implements FeatureSourceState {
export default class FeaturePipeline {
public readonly sufficientlyZoomed: UIEventSource<boolean>;
public readonly runningQuery: UIEventSource<boolean>;
public readonly timeout: UIEventSource<number>;
public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
private readonly overpassUpdater: OverpassFeatureSource
private state: {
readonly filteredLayers: UIEventSource<FilteredLayer[]>,
readonly locationControl: UIEventSource<Loc>,
readonly selectedElement: UIEventSource<any>,
readonly changes: Changes,
readonly layoutToUse: LayoutConfig,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>;
readonly osmConnection: OsmConnection
readonly currentBounds: UIEventSource<BBox>
};
private readonly relationTracker: RelationsTracker
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>;
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(
handleFeatureSource: (source: FeatureSourceForLayer) => void,
handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
state: {
filteredLayers: UIEventSource<FilteredLayer[]>,
locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>,
changes: Changes,
layoutToUse: UIEventSource<LayoutConfig>,
leafletMap: any,
readonly overpassUrl: UIEventSource<string>;
readonly filteredLayers: UIEventSource<FilteredLayer[]>,
readonly locationControl: UIEventSource<Loc>,
readonly selectedElement: UIEventSource<any>,
readonly changes: Changes,
readonly layoutToUse: LayoutConfig,
readonly leafletMap: any,
readonly overpassUrl: UIEventSource<string[]>;
readonly overpassTimeout: UIEventSource<number>;
readonly overpassMaxZoom: UIEventSource<number>;
readonly osmConnection: OsmConnection
readonly currentBounds: UIEventSource<BBox>
}) {
this.state = state;
const self = this
const updater = new OverpassFeatureSource(state);
updater.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(updater))
this.overpassUpdater = updater;
this.sufficientlyZoomed = updater.sufficientlyZoomed
this.runningQuery = updater.runningQuery
this.timeout = updater.timeout
this.relationTracker = updater.relationsTracker
// Register everything in the state' 'AllElements'
new RegisteringAllFromFeatureSourceActor(updater)
// milliseconds
const useOsmApi = state.locationControl.map(l => l.zoom > (state.overpassMaxZoom.data ?? 12))
this.relationTracker = new RelationsTracker()
const neededTilesFromOsm = this.getNeededTilesFromOsm()
this.sufficientlyZoomed = state.locationControl.map(location => {
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>()
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
const srcFiltered =
new FilteringFeatureSource(state,
new FilteringFeatureSource(state, src.tileIndex,
new WayHandlingApplyingFeatureSource(
new ChangeGeometryApplicator(src, state.changes)
)
)
handleFeatureSource(srcFiltered)
self.somethingLoaded.setData(true)
self.freshnesses.get(src.layer.layerDef.id).addTileLoad(src.tileIndex, new Date())
};
function addToHierarchy(src: FeatureSource & Tiled, layerId: string) {
perLayerHierarchy.get(layerId).registerTile(src)
}
for (const filteredLayer of state.filteredLayers.data) {
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile))
const id = filteredLayer.layerDef.id
perLayerHierarchy.set(id, hierarchy)
const source = filteredLayer.layerDef.source
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile))
perLayerHierarchy.set(id, hierarchy)
this.freshnesses.set(id, new TileFreshnessCalculator())
if (source.geojsonSource === undefined) {
// This is an OSM layer
// We load the cached values and register them
@ -97,49 +132,81 @@ export default class FeaturePipeline implements FeatureSourceState {
hierarchy.registerTile(src);
src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src))
}, state)
TiledFromLocalStorageSource.GetFreshnesses(id).forEach((value, key) => {
self.freshnesses.get(id).addTileLoad(key, value)
})
continue
}
if (source.geojsonZoomLevel === undefined) {
// This is a 'load everything at once' geojson layer
// We split them up into tiles
// We split them up into tiles anyway
const src = new GeoJsonSource(filteredLayer)
TiledFeatureSource.createHierarchy(src, {
layer: src.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
})
} else {
new DynamicGeoJsonTileSource(
filteredLayer,
src => TiledFeatureSource.createHierarchy(src, {
layer: src.layer,
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
}),
tile => {
new RegisteringAllFromFeatureSourceActor(tile)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
},
state
)
}
}
const osmFeatureSource = new OsmFeatureSource({
isActive: useOsmApi,
neededTiles: neededTilesFromOsm,
handleTile: tile => {
new RegisteringAllFromFeatureSourceActor(tile)
new SaveTileToLocalStorageActor(tile, tile.tileIndex)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
},
state: state,
markTileVisited: (tileId) =>
state.filteredLayers.data.forEach(flayer => {
SaveTileToLocalStorageActor.MarkVisited(flayer.layerDef.id, tileId, new Date())
})
})
const updater = this.initOverpassUpdater(state, useOsmApi)
this.overpassUpdater = updater;
this.timeout = updater.timeout
// Actually load data from the overpass source
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(source) => TiledFeatureSource.createHierarchy(source, {
layer: source.layer,
minZoomLevel: 14,
dontEnforceMinZoom: true,
maxFeatureCount: state.layoutToUse.clustering.minNeededElements,
maxZoomLevel: state.layoutToUse.clustering.maxZoom,
registerTile: (tile) => {
// We save the tile data for the given layer to local storage
new SaveTileToLocalStorageActor(tile, tile.tileIndex)
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.
@ -149,9 +216,11 @@ export default class FeaturePipeline implements FeatureSourceState {
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(perLayer) => {
// We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
addToHierarchy(perLayer, perLayer.layer.layerDef.id)
perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer)
// AT last, we always apply the metatags whenever possible
perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer))
perLayer.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(perLayer))
},
newGeometry
)
@ -162,27 +231,141 @@ export default class FeaturePipeline implements FeatureSourceState {
self.updateAllMetaTagging()
})
}
private applyMetaTags(src: FeatureSourceForLayer){
const self = this
MetaTagging.addMetatags(
src.features.data,
{
memberships: this.relationTracker,
getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox)
},
src.layer.layerDef,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer
}
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) {
const self = this
console.debug("Applying metatagging onto ", src.name)
window.setTimeout(
() => {
MetaTagging.addMetatags(
src.features.data,
{
memberships: this.relationTracker,
getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox)
},
src.layer.layerDef,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer
}
)
},
15
)
}
private updateAllMetaTagging() {
const self = this;
console.log("Reupdating all metatagging")
this.perLayerHierarchy.forEach(hierarchy => {
hierarchy.loadedTiles.forEach(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 {Utils} from "../../Utils";
import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../GeoOperations";
import {BBox} from "../BBox";
export default interface FeatureSource {
features: UIEventSource<{ feature: any, freshness: Date }[]>;

View file

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

View file

@ -5,8 +5,9 @@
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {BBox} from "../../GeoOperations";
import {Utils} from "../../../Utils";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
@ -23,7 +24,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
this.bbox = bbox;
this._sources = sources;
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 handledSources = new Set<FeatureSource>();

View file

@ -1,29 +1,36 @@
import {UIEventSource} from "../../UIEventSource";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer} from "../FeatureSource";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
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 }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
public readonly layer: FilteredLayer;
public readonly tileIndex: number
public readonly bbox: BBox
constructor(
state: {
locationControl: UIEventSource<{ zoom: number }>,
selectedElement: UIEventSource<any>,
},
tileIndex,
upstream: FeatureSourceForLayer
) {
const self = this;
this.name = "FilteringFeatureSource("+upstream.name+")"
this.name = "FilteringFeatureSource(" + upstream.name + ")"
this.tileIndex = tileIndex
this.bbox = BBox.fromTileIndex(tileIndex)
this.layer = upstream.layer;
const layer = upstream.layer;
function update() {
const features: { feature: any; freshness: Date }[] = upstream.features.data;
const newFeatures = features.filter((f) => {
if (
@ -45,17 +52,15 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
}
const tagsFilter = layer.appliedFilters.data;
if (tagsFilter) {
if (!tagsFilter.matchesProperties(f.feature.properties)) {
for (const filter of tagsFilter ?? []) {
const neededTags = filter.filter.options[filter.selected].osmTags
if (!neededTags.matchesProperties(f.feature.properties)) {
// Hidden by the filter on the layer itself - we want to hide it no matter wat
return false;
}
}
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;
});
@ -66,23 +71,8 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
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(_ => {
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()
})
@ -93,10 +83,8 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer {
layer: {
isDisplayed: UIEventSource<boolean>;
layerDef: LayerConfig;
},
location: { zoom: number }) {
return layer.isDisplayed.data &&
layer.layerDef.minzoomVisible <= location.zoom;
}) {
return layer.isDisplayed.data;
}
}

View file

@ -5,7 +5,8 @@ import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
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 {
@ -20,14 +21,26 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
public readonly tileIndex
public readonly bbox;
/**
* Only used if the actual source is a tiled geojson.
* A big feature might be contained in multiple tiles.
* However, we only want to load them once. The blacklist thus contains all ids of all features previously seen
* @private
*/
private readonly featureIdBlacklist?: UIEventSource<Set<string>>
public constructor(flayer: FilteredLayer,
zxy?: [number, number, number]) {
zxy?: [number, number, number],
options?: {
featureIdBlacklist?: UIEventSource<Set<string>>
}) {
if (flayer.layerDef.source.geojsonZoomLevel !== undefined && zxy === undefined) {
throw "Dynamic layers are not supported. Use 'DynamicGeoJsonTileSource instead"
}
this.layer = flayer;
this.featureIdBlacklist = options?.featureIdBlacklist
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
if (zxy !== undefined) {
const [z, x, y] = zxy;
@ -35,10 +48,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
.replace('{z}', "" + z)
.replace('{x}', "" + x)
.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)
} else {
this.tileIndex = Utils.tile_index(0, 0, 0)
this.tileIndex = Tiles.tile_index(0, 0, 0)
this.bbox = BBox.global;
}
@ -67,6 +80,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
const props = feature.properties
for (const key in props) {
if (typeof props[key] !== "string") {
// Make sure all the values are string, it crashes stuff otherwise
props[key] = "" + props[key]
}
}
@ -81,6 +95,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
continue;
}
self.seenids.add(props.id)
if(self.featureIdBlacklist?.data?.has(props.id)){
continue;
}
let freshness: Date = time;
if (feature.properties["_last_edit:timestamp"] !== undefined) {
@ -89,7 +107,6 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
newFeatures.push({feature: feature, freshness: freshness})
}
console.debug("Downloaded " + newFeatures.length + " new features and " + skipped + " already seen features from " + url);
if (newFeatures.length == 0) {
return;

View file

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

View file

@ -2,17 +2,23 @@
* Every previously added point is remembered, but new points are added.
* Data coming from upstream will always overwrite a previous value
*/
import FeatureSource from "../FeatureSource";
import FeatureSource, {Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {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 name;
constructor(source: FeatureSource) {
public readonly tileIndex : number
public readonly bbox : BBox
constructor(source: FeatureSource & Tiled) {
const self = this;
this.name = "RememberingSource of " + source.name;
this.tileIndex= source.tileIndex
this.bbox = source.bbox;
const empty = [];
this.features = source.features.map(features => {
const oldFeatures = self.features?.data ?? empty;

View file

@ -1,19 +1,22 @@
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {BBox} from "../../GeoOperations";
import {Utils} from "../../../Utils";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "SimpleFeatureSource";
public readonly layer: FilteredLayer;
public readonly bbox: BBox = BBox.global;
public readonly tileIndex: number = 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.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 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();
if(useFeaturesDirectly){
if (useFeaturesDirectly) {
// @ts-ignore
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})))
} else {
this.features = new UIEventSource(features.map(f => ({

View file

@ -12,10 +12,11 @@ export default class WayHandlingApplyingFeatureSource implements FeatureSourceFo
public readonly layer;
constructor(upstream: FeatureSourceForLayer) {
this.name = "Wayhandling(" + upstream.name+")";
this.name = "Wayhandling(" + upstream.name + ")";
this.layer = upstream.layer
const layer = upstream.layer.layerDef;
if (layer.wayHandling === LayerConfig.WAYHANDLING_DEFAULT) {
// We don't have to do anything fancy
// lets just wire up the upstream

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

View file

@ -6,6 +6,7 @@ import {Utils} from "../../../Utils";
import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
/***
* A tiled source which dynamically loads the required tiles at a fixed zoom level
@ -46,9 +47,9 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
// We'll retry later
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) {
return undefined
}
@ -63,7 +64,7 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
}
for (const neededIndex of neededIndexes) {
self._loadedTiles.add(neededIndex)
const src = constructTile( Utils.tile_from_index(neededIndex))
const src = constructTile(Tiles.tile_from_index(neededIndex))
if(src !== undefined){
self.loadedTiles.set(neededIndex, src)
}

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {Utils} from "../../../Utils";
import {BBox} from "../../GeoOperations";
import FilteredLayer from "../../../Models/FilteredLayer";
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.
@ -41,12 +41,12 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
this.x = x;
this.y = 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.parent = parent;
this.layer = options.layer
options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 500;
this.maxFeatureCount = options?.maxFeatureCount ?? 250;
this.maxzoom = options.maxZoomLevel ?? 18
this.options = options;
if (parent === undefined) {
@ -61,7 +61,7 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
} else {
this.root = this.parent.root;
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.features = new UIEventSource<any[]>([])
@ -109,7 +109,6 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
// To much features - we split
return featureCount > this.maxFeatureCount
}
/***
@ -143,9 +142,20 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
for (const feature of features) {
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)) {
ulf.push(feature)
} else if (bbox.isContainedIn(this.upper_right.bbox)) {
@ -186,6 +196,11 @@ export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number,
readonly maxZoomLevel?: number,
readonly minZoomLevel?: number,
/**
* IF minZoomLevel is set, and if a feature runs through a tile boundary, it would normally be duplicated.
* Setting 'dontEnforceMinZoomLevel' will still allow bigger zoom levels for those features
*/
readonly dontEnforceMinZoom?: boolean,
readonly registerTile?: (tile: TiledFeatureSource & Tiled) => void,
readonly layer?: FilteredLayer
}

View file

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

View file

@ -1,5 +1,5 @@
import * as turf from '@turf/turf'
import {Utils} from "../Utils";
import {BBox} from "./BBox";
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
*/
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 {Wikimedia} from "./Wikimedia";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
import {Imgur} from "./Imgur";
import GenericImageProvider from "./GenericImageProvider";
import {UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import {WikidataImageProvider} from "./WikidataImageProvider";
/**
* A generic 'from the interwebz' image picker, without attribution
*/
export default class AllImageProviders {
public static ImageAttributionSource = [Imgur.singleton, Mapillary.singleton, Wikimedia.singleton]
public static ImageAttributionSource: ImageProvider[] = [
Imgur.singleton,
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
new GenericImageProvider([].concat(...Imgur.defaultValuePrefix, WikimediaImageProvider.commonsPrefix))]
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 {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import {UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import {Utils} from "../../Utils";
import Constants from "../../Models/Constants";
import {LicenseInfo} from "./LicenseInfo";
export class Imgur extends ImageAttributionSource {
export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public readonly defaultKeyPrefixes: string[] = ["image"];
public static readonly singleton = new Imgur();
@ -86,51 +89,39 @@ export class Imgur extends ImageAttributionSource {
return undefined;
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
const src = new UIEventSource<LicenseInfo>(undefined)
protected DownloadAttribution: (url: string) => Promise<LicenseInfo> = async (url: string) => {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
const apiUrl = 'https://api.imgur.com/3/image/' + hash;
const apiKey = '7070e7167f0a25a';
const response = await Utils.downloadJson(apiUrl, {Authorization: 'Client-ID ' + Constants.ImgurApiKey})
const settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'GET',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
};
// @ts-ignore
$.ajax(settings).done(function (response) {
const descr: string = response.data.description ?? "";
const data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1].replace("\r", "");
}
const descr: string = response.data.description ?? "";
const data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1]?.replace("\r", "");
}
const licenseInfo = new LicenseInfo();
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
src.setData(licenseInfo)
}).fail((reason) => {
console.log("Getting metadata from to IMGUR failed", reason)
});
return src;
return licenseInfo
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) {
return [Promise.resolve({
url: value,
key: key,
provider: this
})]
}
return []
}
}

View file

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

View file

@ -1,18 +1,18 @@
import {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
import Constants from "../../Models/Constants";
export class Mapillary extends ImageAttributionSource {
export class Mapillary extends ImageProvider {
public static readonly singleton = new Mapillary();
defaultKeyPrefixes = ["mapillary"]
private static readonly v4_cached_urls = new Map<string, UIEventSource<string>>();
public static readonly singleton = new Mapillary();
private static readonly client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2'
private static readonly client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
private static readonly v4_cached_urls = new Map<string, UIEventSource<string>>();
private constructor() {
super();
@ -23,8 +23,8 @@ export class Mapillary extends ImageAttributionSource {
isApiv4?: boolean
} {
if (value.startsWith("https://a.mapillary.com")) {
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
return {key:key, isApiv4: !isNaN(Number(key))};
const key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
return {key: key, isApiv4: !isNaN(Number(key))};
}
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
if (newApiFormat !== null) {
@ -32,11 +32,11 @@ export class Mapillary extends ImageAttributionSource {
}
const mapview = value.match(/https?:\/\/www.mapillary.com\/map\/im\/(.*)/)
if(mapview !== null){
if (mapview !== null) {
const key = mapview[1]
return {key:key, isApiv4: !isNaN(Number(key))};
return {key: key, isApiv4: !isNaN(Number(key))};
}
if (value.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
// Extract the key of the image
@ -56,54 +56,56 @@ export class Mapillary extends ImageAttributionSource {
return Svg.mapillary_svg();
}
PrepareUrl(value: string): string | UIEventSource<string> {
const keyV = Mapillary.ExtractKeyFromURL(value)
if (!keyV.isApiv4) {
return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}`
} else {
const key = keyV.key;
if(Mapillary.v4_cached_urls.has(key)){
return Mapillary.v4_cached_urls.get(key)
}
const metadataUrl ='https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Mapillary.client_token_v4;
const source = new UIEventSource<string>(undefined)
Mapillary.v4_cached_urls.set(key, source)
Utils.downloadJson(metadataUrl).then(
json => {
console.warn("Got response on mapillary image", json, json["thumb_1024_url"])
return source.setData(json["thumb_1024_url"]);
}
)
return source
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.PrepareUrlAsync(key, value)]
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
const keyV = Mapillary.ExtractKeyFromURL(value)
if (!keyV.isApiv4) {
const url = `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Constants.mapillary_client_token_v3}`
return {
url: url,
provider: this,
key: key
}
} else {
const key = keyV.key;
const metadataUrl = 'https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
const source = new UIEventSource<string>(undefined)
Mapillary.v4_cached_urls.set(key, source)
const response = await Utils.downloadJson(metadataUrl)
const url = <string> response["thumb_1024_url"];
return {
url: url,
provider: this,
key: key
}
}
}
protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
const keyV = Mapillary.ExtractKeyFromURL(url)
if(keyV.isApiv4){
if (keyV.isApiv4) {
const license = new LicenseInfo()
license.artist = "Contributor name unavailable";
license.license = "CC BY-SA 4.0";
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
return new UIEventSource<LicenseInfo>(license)
return license
}
const key = keyV.key
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined)
Utils.downloadJson(metadataURL).then(data => {
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
source.setData(license);
})
return source
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const data = await Utils.downloadJson(metadataURL)
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
return license
}
}

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import {LocalStorageSource} from "../Web/LocalStorageSource";
export class Changes {
private _nextId : number = -1; // Newly assigned ID's are negative
private _nextId: number = -1; // Newly assigned ID's are negative
public readonly name = "Newly added features"
/**
* All the newly created features as featureSource + all the modified features
@ -31,7 +31,10 @@ export class Changes {
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? [])
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,
@ -90,74 +93,69 @@ export class Changes {
if (this.pendingChanges.data.length === 0) {
return;
}
if (this.isUploading.data) {
console.log("Is already uploading... Abort")
return;
}
this.isUploading.setData(true)
this.flushChangesAsync(flushreason)
.then(_ => {
this.isUploading.setData(false)
console.log("Changes flushed!");
})
.catch(e => {
this.isUploading.setData(false)
console.error("Flushing changes failed due to", e);
})
}
private async flushChangesAsync(flushreason: string = undefined): Promise<void> {
console.log("Beginning upload... " + flushreason ?? "");
// At last, we build the changeset and upload
const self = this;
const pending = self.pendingChanges.data;
const neededIds = Changes.GetNeededIds(pending)
console.log("Needed ids", neededIds)
OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => {
console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
try {
const changes: {
newObjects: OsmObject[],
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made")
self.pendingChanges.setData([])
self.isUploading.setData(false)
return true; // Unregister the callback
}
State.state.osmConnection.UploadChangeset(
State.state.layoutToUse.data,
State.state.allElements,
(csId) => Changes.createChangesetFor(csId, changes),
() => {
console.log("Upload successfull!")
self.pendingChanges.setData([]);
self.isUploading.setData(false)
},
() => {
console.log("Upload failed - trying again later")
return self.isUploading.setData(false);
} // Failed - mark to try again
)
} catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
const osmObjects = await Promise.all(neededIds.map(id => OsmObject.DownloadObjectAsync(id)));
console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
try {
const changes: {
newObjects: OsmObject[],
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects)
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
console.log("No changes to be made")
self.pendingChanges.setData([])
self.isUploading.setData(false)
}
return true;
});
await State.state.osmConnection.UploadChangeset(
State.state.layoutToUse,
State.state.allElements,
(csId) => Changes.createChangesetFor(csId, changes),
)
console.log("Upload successfull!")
this.pendingChanges.setData([]);
this.isUploading.setData(false)
} catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
self.pendingChanges.setData([])
self.isUploading.setData(false)
}
}
public applyAction(action: OsmChangeAction) {
action.Perform(this).then(changes => {
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
this.allChanges.data.push(...changes)
this.allChanges.ping()
})
public async applyAction(action: OsmChangeAction): Promise<void> {
const changes = await action.Perform(this)
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
this.allChanges.data.push(...changes)
this.allChanges.ping()
}
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
@ -311,4 +309,8 @@ export class Changes {
return result
}
public registerIdRewrites(mappings: Map<string, string>): void {
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ export default class SimpleMetaTagger {
"_version_number"],
doc: "Information about the last edit of this object."
},
(feature) => {/*Note: also handled by 'UpdateTagsFromOsmAPI'*/
(feature) => {/*Note: also called by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties;
@ -48,6 +48,7 @@ export default class SimpleMetaTagger {
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
return true;
}
)
private static latlon = new SimpleMetaTagger({
@ -62,6 +63,7 @@ export default class SimpleMetaTagger {
feature.properties["_lon"] = "" + lon;
feature._lon = lon; // This is dirty, I know
feature._lat = lat;
return true;
})
);
private static surfaceArea = new SimpleMetaTagger(
@ -74,6 +76,7 @@ export default class SimpleMetaTagger {
feature.properties["_surface"] = "" + sqMeters;
feature.properties["_surface:ha"] = "" + Math.floor(sqMeters / 1000) / 10;
feature.area = sqMeters;
return true;
})
);
@ -84,7 +87,7 @@ export default class SimpleMetaTagger {
},
(feature => {
const units = Utils.NoNull([].concat(...State.state?.layoutToUse?.data?.layers?.map(layer => layer.units ?? [])));
const units = Utils.NoNull([].concat(...State.state?.layoutToUse?.layers?.map(layer => layer.units ?? [])));
if (units.length == 0) {
return;
}
@ -94,6 +97,9 @@ export default class SimpleMetaTagger {
continue;
}
for (const unit of units) {
if (unit === undefined) {
continue
}
if (unit.appliesToKeys === undefined) {
console.error("The unit ", unit, "has no appliesToKey defined")
continue
@ -102,7 +108,12 @@ export default class SimpleMetaTagger {
continue;
}
const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value)
const denom = unit.findDenomination(value)
if (denom === undefined) {
// no valid value found
break;
}
const [, denomination] = denom;
let canonical = denomination?.canonicalValue(value) ?? undefined;
if (canonical === value) {
break;
@ -118,9 +129,7 @@ export default class SimpleMetaTagger {
}
}
if (rewritten) {
State.state.allElements.getEventSourceById(feature.id).ping();
}
return rewritten
})
)
@ -135,6 +144,7 @@ export default class SimpleMetaTagger {
const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km + "." + kmRest
return true;
})
)
private static country = new SimpleMetaTagger(
@ -144,7 +154,6 @@ export default class SimpleMetaTagger {
},
feature => {
let centerPoint: any = GeoOperations.centerpoint(feature);
const lat = centerPoint.geometry.coordinates[1];
const lon = centerPoint.geometry.coordinates[0];
@ -157,11 +166,11 @@ export default class SimpleMetaTagger {
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping();
}
} catch (e) {
console.warn(e)
}
})
return false;
}
)
private static isOpen = new SimpleMetaTagger(
@ -174,7 +183,7 @@ export default class SimpleMetaTagger {
if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache
// isOpen is irrelevant
return
return false
}
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
@ -199,7 +208,7 @@ export default class SimpleMetaTagger {
if (oldNextChange > (new Date()).getTime() &&
tags["_isOpen:oldvalue"] === tags["opening_hours"]) {
// Already calculated and should not yet be triggered
return;
return false;
}
tags["_isOpen"] = oh.getState() ? "yes" : "no";
@ -227,6 +236,7 @@ export default class SimpleMetaTagger {
}
}
updateTags();
return true;
} catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e);
tags["_isOpen"] = "parse_error";
@ -244,11 +254,11 @@ export default class SimpleMetaTagger {
const tags = feature.properties;
const direction = tags["camera:direction"] ?? tags["direction"];
if (direction === undefined) {
return;
return false;
}
const n = cardinalDirections[direction] ?? Number(direction);
if (isNaN(n)) {
return;
return false;
}
// The % operator has range (-360, 360). We apply a trick to get [0, 360).
@ -256,126 +266,17 @@ export default class SimpleMetaTagger {
tags["_direction:numerical"] = normalized;
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(
{
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely",
includesDates: true
},
(feature, _, freshness) => {
(feature, freshness) => {
const now = new Date();
if (typeof freshness === "string") {
@ -394,7 +295,7 @@ export default class SimpleMetaTagger {
feature.properties["_now:datetime"] = datetime(now);
feature.properties["_loaded:date"] = date(freshness);
feature.properties["_loaded:datetime"] = datetime(freshness);
return true;
}
)
public static metatags = [
@ -404,7 +305,6 @@ export default class SimpleMetaTagger {
SimpleMetaTagger.canonicalize,
SimpleMetaTagger.country,
SimpleMetaTagger.isOpen,
SimpleMetaTagger.carriageWayWidth,
SimpleMetaTagger.directionSimplified,
SimpleMetaTagger.currentTime,
SimpleMetaTagger.objectMetaInfo
@ -413,12 +313,18 @@ export default class SimpleMetaTagger {
public readonly keys: string[];
public readonly doc: string;
public readonly includesDates: boolean
private readonly _f: (feature: any, index: number, freshness: Date) => void;
public readonly applyMetaTagsOnFeature: (feature: any, freshness: Date) => boolean;
constructor(docs: { keys: string[], doc: string, includesDates?: boolean }, f: ((feature: any, index: number, freshness: Date) => void)) {
/***
* A function that adds some extra data to a feature
* @param docs: what does this extra data do?
* @param f: apply the changes. Returns true if something changed
*/
constructor(docs: { keys: string[], doc: string, includesDates?: boolean },
f: ((feature: any, freshness: Date) => boolean)) {
this.keys = docs.keys;
this.doc = docs.doc;
this._f = f;
this.applyMetaTagsOnFeature = f;
this.includesDates = docs.includesDates ?? false;
for (const key of docs.keys) {
if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) {
@ -450,12 +356,4 @@ export default class SimpleMetaTagger {
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) {
return;
}
if(typeof fromTag === "number"){
fromTag = "" + fromTag;
}
if (typeof possibleRegex === "string") {
return fromTag === possibleRegex;
}

View file

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

View file

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

View file

@ -5,7 +5,8 @@ import Translations from "../../UI/i18n/Translations";
import {TagUtils} from "../../Logic/Tags/TagUtils";
export default class FilterConfig {
readonly options: {
public readonly id: string
public readonly options: {
question: Translation;
osmTags: TagsFilter;
}[];
@ -14,11 +15,18 @@ export default class FilterConfig {
if (json.options === undefined) {
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) {
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) => {
const question = Translations.T(
option.question,
@ -34,5 +42,9 @@ export default class FilterConfig {
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";
export default interface FilterConfigJson {
/**
* An id/name for this filter, used to set the URL parameters
*/
id: string,
/**
* The options for a filter
* If there are multiple options these will be a list of radio buttons

View file

@ -228,8 +228,8 @@ export interface LayoutConfigJson {
*/
maxZoom?: number,
/**
* The number of elements that should be showed (in total) before clustering starts to happen.
* If clustering is defined, defaults to 0
* The number of elements per tile needed to start clustering
* If clustering is defined, defaults to 25
*/
minNeededElements?: number
},
@ -263,9 +263,9 @@ export interface LayoutConfigJson {
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
*/

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.
*/
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.
* 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 DeleteConfig from "./DeleteConfig";
import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
export default class LayerConfig {
static WAYHANDLING_DEFAULT = 0;
@ -154,6 +155,9 @@ export default class LayerConfig {
this.minzoom = json.minzoom ?? 0;
this.minzoomVisible = json.minzoomVisible ?? this.minzoom;
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) => {
let preciseInput = undefined;
@ -287,7 +291,14 @@ export default class LayerConfig {
}
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) => {
return new FilterConfig(option, `${context}.filter-[${i}]`)
});
@ -499,12 +510,13 @@ export default class LayerConfig {
);
const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/);
if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) {
html = new Combine([
html = new Img(
(Svg.All[match[1] + ".svg"] as string).replace(
/#000000/g,
match[2]
),
]).SetStyle(style);
true
).SetStyle(style);
}
return html;
}

View file

@ -5,8 +5,8 @@ import SharedTagRenderings from "../../Customizations/SharedTagRenderings";
import AllKnownLayers from "../../Customizations/AllKnownLayers";
import {Utils} from "../../Utils";
import LayerConfig from "./LayerConfig";
import {Unit} from "../Unit";
import {LayerConfigJson} from "./Json/LayerConfigJson";
import Constants from "../Constants";
export default class LayoutConfig {
public readonly id: string;
@ -51,7 +51,7 @@ export default class LayoutConfig {
How long is the cache valid, in seconds?
*/
public readonly cacheTimeout?: number;
public readonly overpassUrl: string;
public readonly overpassUrl: string[];
public readonly overpassTimeout: number;
public readonly official: boolean;
@ -87,6 +87,9 @@ export default class LayoutConfig {
this.startZoom = json.startZoom;
this.startLat = json.startLat;
this.startLon = json.startLon;
if(json.widenFactor < 1){
throw "Widenfactor too small"
}
this.widenFactor = json.widenFactor ?? 1.5;
this.roamingRenderings = (json.roamingRenderings ?? []).map((tr, i) => {
if (typeof tr === "string") {
@ -127,17 +130,12 @@ export default class LayoutConfig {
this.clustering = {
maxZoom: 16,
minNeededElements: 500
minNeededElements: 25
};
if (json.clustering) {
this.clustering = {
maxZoom: json.clustering.maxZoom ?? 18,
minNeededElements: json.clustering.minNeededElements ?? 1
}
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);
}
minNeededElements: json.clustering.minNeededElements ?? 25
}
}
@ -160,7 +158,14 @@ export default class LayoutConfig {
this.enablePdfDownload = json.enablePdfDownload ?? false;
this.customCss = json.customCss;
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
}

View file

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

View file

@ -5,4 +5,107 @@ export interface TileRange {
yend: number,
total: 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 ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor";
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
@ -27,16 +27,16 @@ export default class State {
// The singleton of the global state
public static state: State;
public readonly layoutToUse = new UIEventSource<LayoutConfig>(undefined, "layoutToUse");
public readonly layoutToUse : LayoutConfig;
/**
The mapping from id -> UIEventSource<properties>
*/
public allElements: ElementStorage;
public allElements: ElementStorage = new ElementStorage();
/**
THe change handler
*/
public changes: Changes;
public changes: Changes = new Changes();
/**
The leaflet instance of the big basemap
*/
@ -81,9 +81,11 @@ export default class State {
public readonly featureSwitchEnableExport: UIEventSource<boolean>;
public readonly featureSwitchFakeUser: UIEventSource<boolean>;
public readonly featureSwitchExportAsPdf: UIEventSource<boolean>;
public readonly overpassUrl: UIEventSource<string>;
public readonly overpassUrl: UIEventSource<string[]>;
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;
@ -97,7 +99,7 @@ export default class State {
* The current visible extent of the screen
*/
public readonly currentBounds = new UIEventSource<BBox>(undefined)
public backgroundLayer;
public readonly backgroundLayerId: UIEventSource<string>;
@ -155,8 +157,7 @@ export default class State {
constructor(layoutToUse: LayoutConfig) {
const self = this;
this.layoutToUse.setData(layoutToUse);
this.layoutToUse = layoutToUse;
// -- Location control initialization
{
@ -193,14 +194,7 @@ export default class State {
lat.setData(latlonz.lat);
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
@ -209,28 +203,19 @@ export default class State {
deflt: (layout: LayoutConfig) => boolean,
documentation: string
): UIEventSource<boolean> {
const queryParameterSource = QueryParameters.GetQueryParameter(
const defaultValue = deflt(self.layoutToUse);
const queryParam = QueryParameters.GetQueryParameter(
key,
undefined,
"" + defaultValue,
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
return UIEventSource.flatten(
self.layoutToUse.map((layout) => {
const defaultValue = deflt(layout);
const queryParam = QueryParameters.GetQueryParameter(
key,
"" + defaultValue,
documentation
);
return queryParam.map((str) =>
str === undefined ? defaultValue : str !== "false"
);
}),
[queryParameterSource]
);
return queryParam.map((str) =>
str === undefined ? defaultValue : str !== "false"
)
}
// 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",
layoutToUse?.overpassUrl,
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(",") ,
"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",
"" + layoutToUse?.overpassTimeout,
@ -373,22 +358,20 @@ export default class State {
return;
}
this.osmConnection = new OsmConnection(
this.featureSwitchIsTesting.data,
this.featureSwitchFakeUser.data,
QueryParameters.GetQueryParameter(
this.osmConnection = new OsmConnection({
changes: this.changes,
dryRun: this.featureSwitchIsTesting.data,
fakeUser: this.featureSwitchFakeUser.data,
allElements: this.allElements,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
layoutToUse?.id,
true,
// @ts-ignore
this.featureSwitchApiURL.data
);
layoutName: layoutToUse?.id,
osmConfiguration: <'osm' | 'osm-test'>this.featureSwitchApiURL.data
})
this.allElements = new ElementStorage();
this.changes = new Changes();
new ChangeToElementsActor(this.changes, this.allElements)
@ -415,11 +398,11 @@ export default class State {
Locale.language
.addCallback((currentLanguage) => {
const layoutToUse = self.layoutToUse.data;
const layoutToUse = self.layoutToUse;
if (layoutToUse === undefined) {
return;
}
if (this.layoutToUse.data.language.indexOf(currentLanguage) < 0) {
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],

View file

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

View file

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

View file

@ -31,7 +31,7 @@ export class TabbedComponent extends Combine {
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(
openedTabSrc.map(i => contentElements[i])
)

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import Constants from "../../Models/Constants";
import Loc from "../../Models/Loc";
import {VariableUiElement} from "../Base/VariableUIElement";
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
@ -16,13 +16,13 @@ export default class Attribution extends Combine {
constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: UIEventSource<LayoutConfig>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>) {
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 layoutId = layoutToUse?.data?.id;
const layoutId = layoutToUse?.id;
const now = new Date()
// 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()

View file

@ -20,11 +20,11 @@ export default class AttributionPanel extends Combine {
private static LicenseObject = AttributionPanel.GenerateLicenses();
constructor(layoutToUse: UIEventSource<LayoutConfig>, contributions: UIEventSource<Map<string, number>>) {
constructor(layoutToUse: LayoutConfig, contributions: UIEventSource<Map<string, number>>) {
super([
Translations.t.general.attribution.attributionContent,
((layoutToUse.data.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.data.maintainer}),
layoutToUse.data.credits,
((layoutToUse.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.maintainer}),
layoutToUse.credits,
"<br/>",
new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.currentBounds),
"<br/>",
@ -65,7 +65,7 @@ export default class AttributionPanel extends Combine {
"<br/>",
AttributionPanel.CodeContributors(),
"<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)
]);
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) {
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 Combine from "../Base/Combine";
import CheckBoxes from "../Input/Checkboxes";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import {GeoOperations} from "../../Logic/GeoOperations";
import Toggle from "../Input/Toggle";
import Title from "../Base/Title";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
@ -13,19 +13,20 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {meta} from "@turf/turf";
import {BBox} from "../../Logic/BBox";
export class DownloadPanel extends Toggle {
constructor() {
const state: {
featurePipeline: FeaturePipeline,
layoutToUse: UIEventSource<LayoutConfig>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>
} = State.state
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 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 {Translation} from "../i18n/Translation";
import Svg from "../../Svg";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {And} from "../../Logic/Tags/And";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import State from "../../State";
@ -16,11 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer";
import BackgroundSelector from "./BackgroundSelector";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
/**
* Shows the filter
*/
export default class FilterView extends VariableUiElement {
constructor(filteredLayer: UIEventSource<FilteredLayer[]>) {
const backgroundSelector = new Toggle(
@ -101,26 +94,52 @@ export default class FilterView extends VariableUiElement {
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
);
const update = () => {
let listTagsFilters = Utils.NoNull(
listFilterElements.map((input) => input[1].data)
);
flayer.appliedFilters.setData(new And(listTagsFilters));
};
listFilterElements.forEach((inputElement, i) =>
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.forEach((inputElement) =>
inputElement[1].addCallback((_) => update())
console.log(listTagsFilters, oldValue)
flayer.appliedFilters.setData(listTagsFilters);
})
);
flayer.appliedFilters.addCallbackAndRun(appliedFilters => {
if (appliedFilters === undefined || appliedFilters.and.length === 0) {
listFilterElements.forEach(filter => filter[1].setData(undefined))
return
for (let i = 0; i < layer.filters.length; i++){
const filter = layer.filters[i];
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")))
@ -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) {
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 toggle = new Toggle(
new Combine([icon, option.question.Clone()]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone()]).SetClass("flex")
new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex")
)
.ToggleOnClick()
.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;
const values = options.map((f, i) => ({
filter: filterConfig, selected: i
}))
const radio = new RadioButton(
options.map(
(option) =>
new FixedInputElement(option.question.Clone(), option.osmTags)
(option, i) =>
new FixedInputElement(option.question.Clone().SetClass("block"), i)
),
{
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 ThemeIntroductionPanel from "./ThemeIntroductionPanel";
import * as personal from "../../assets/themes/personal/personal.json";
import PersonalLayersPanel from "./PersonalLayersPanel";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import ShareScreen from "./ShareScreen";
@ -21,7 +19,7 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
constructor(isShown: UIEventSource<boolean>) {
const layoutToUse = State.state.layoutToUse.data;
const layoutToUse = State.state.layoutToUse;
super(
() => layoutToUse.title.Clone(),
() => 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 }[] {
let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown);
if (layoutToUse.id === personal.id) {
welcome = new PersonalLayersPanel();
}
const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [
{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)
button.onClick(() => {
button.onClick(async () => {
if (isImported.data) {
return
}
originalTags.data["_imported"] = "yes"
originalTags.ping() // will set isImported as per its definition
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(
newElementAction.newElementId
))

View file

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

View file

@ -126,7 +126,7 @@ export default class MoreScreen extends Combine {
if (layout.hideFromOverview) {
return undefined;
}
if (layout.id === State.state.layoutToUse.data?.id) {
if (layout.id === State.state.layoutToUse?.id) {
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 {
constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) {
layout = layout ?? State.state?.layoutToUse?.data;
layout = layout ?? State.state?.layoutToUse;
layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition;
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 PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {And} from "../../Logic/Tags/And";
import {BBox} from "../../Logic/GeoOperations";
import {BBox} from "../../Logic/BBox";
/*
* 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)
function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
console.trace("Creating a new point")
async function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
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)
isShown.setData(false)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
@ -224,14 +222,32 @@ export default class SimpleAddUI extends Toggle {
]
).SetClass("flex flex-col")
).onClick(() => {
preset.layerToAddTo.appliedFilters.setData(new And([]))
preset.layerToAddTo.appliedFilters.setData([])
cancel()
})
const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm,
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 LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class ThemeIntroductionPanel extends VariableUiElement {
export default class ThemeIntroductionPanel extends Combine {
constructor(isShown: UIEventSource<boolean>) {
const layout = State.state.layoutToUse
const languagePicker =
new VariableUiElement(
State.state.layoutToUse.map(layout => LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone()))
)
;
const languagePicker = LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone())
const toTheMap = new SubtleButton(
undefined,
@ -53,8 +49,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
State.state.featureSwitchUserbadge
)
super(State.state.layoutToUse.map(layout => new Combine([
super([
layout.description.Clone(),
"<br/><br/>",
toTheMap,
@ -63,7 +58,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
"<br/>",
languagePicker,
...layout.CustomCodeSnippets()
])))
])
this.SetClass("link-underline")
}

View file

@ -47,7 +47,7 @@ export default class UserBadge extends Toggle {
});
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;");
let messageSpan =

View file

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

View file

@ -1,30 +1,19 @@
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
import Img from "../Base/Img";
import ImageAttributionSource from "../../Logic/ImageProviders/ImageAttributionSource";
import {ProvidedImage} from "../../Logic/ImageProviders/ImageProvider";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
export class AttributedImage extends Combine {
constructor(urlSource: string, imgSource: ImageAttributionSource) {
const preparedUrl = imgSource.PrepareUrl(urlSource)
constructor(imageInfo: ProvidedImage) {
let img: BaseUIElement;
let attr: BaseUIElement
if (typeof preparedUrl === "string") {
img = new Img(urlSource);
attr = new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.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())))
}
img = new Img(imageInfo.url);
attr = new Attribution(imageInfo.provider.GetAttributionFor(imageInfo.url),
imageInfo.provider.SourceIcon(),
)
super([img, attr]);

View file

@ -3,7 +3,7 @@ import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/ImageProviders/Wikimedia";
import {LicenseInfo} from "../../Logic/ImageProviders/LicenseInfo";
export default class Attribution extends VariableUiElement {
@ -13,17 +13,16 @@ export default class Attribution extends VariableUiElement {
}
super(
license.map((license: LicenseInfo) => {
if (license?.artist === undefined) {
return undefined;
if(license === undefined){
return undefined
}
return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([
Translations.W(license.artist).SetClass("block font-bold"),
Translations.W((license.license ?? "") === "" ? "CC0" : (license.license ?? ""))
Translations.W(license?.artist ?? ".").SetClass("block font-bold"),
Translations.W((license?.license ?? "") === "" ? "CC0" : (license?.license ?? ""))
]).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")

View file

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

View file

@ -4,29 +4,35 @@ import Combine from "../Base/Combine";
import DeleteImage from "./DeleteImage";
import {AttributedImage} from "./AttributedImage";
import BaseUIElement from "../BaseUIElement";
import Img from "../Base/Img";
import Toggle from "../Input/Toggle";
import {Wikimedia} from "../../Logic/ImageProviders/Wikimedia";
import {Imgur} from "../../Logic/ImageProviders/Imgur";
import {Mapillary} from "../../Logic/ImageProviders/Mapillary";
import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
export class ImageCarousel extends Toggle {
constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string }[]) => {
constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = [];
for (const url of imageURLS) {
let image = ImageCarousel.CreateImageElement(url.url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
try {
let image = new AttributedImage(url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
}
return uiElements;
});
@ -38,33 +44,4 @@ export class ImageCarousel extends Toggle {
)
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;
}
console.log("Adding image:" + key, url);
State.state.changes
Promise.resolve(State.state.changes
.applyAction(new ChangeTagAction(
tags.id, new Tag(key, url), tagsSource.data
))
)))
})
@ -55,7 +55,7 @@ export class ImageUploadFlow extends Toggle {
const tags = tagsSource.data;
const layout = State.state?.layoutToUse?.data
const layout = State.state?.layoutToUse
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {

View file

@ -7,12 +7,13 @@ import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import * as L from "leaflet";
import {GeoOperations} from "../../Logic/GeoOperations";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../Logic/BBox";
import {FixedUiElement} from "../Base/FixedUiElement";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
export default class LocationInput extends InputElement<Loc> {
@ -39,7 +40,7 @@ export default class LocationInput extends InputElement<Loc> {
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: UIEventSource<LayerConfig>;
public readonly _matching_layer: LayerConfig;
constructor(options: {
mapBackground?: UIEventSource<BaseLayer>,
@ -63,18 +64,17 @@ export default class LocationInput extends InputElement<Loc> {
if (self._snappedPointTags !== undefined) {
this._matching_layer = State.state.layoutToUse.map(layout => {
const layout = State.state.layoutToUse
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
return layer
}
let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
matchingLayer = layer
}
console.error("No matching layer found for tags ", self._snappedPointTags)
return LocationInput.matchLayer
})
}
this._matching_layer = matchingLayer;
} else {
this._matching_layer = new UIEventSource<LayerConfig>(LocationInput.matchLayer)
this._matching_layer = LocationInput.matchLayer
}
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")
}
@ -144,7 +144,7 @@ export default class LocationInput extends InputElement<Loc> {
{
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state.backgroundLayer,
attribution: this.mapBackground !== State.state?.backgroundLayer,
lastClickLocation: clickLocation,
bounds: this._bounds
}
@ -176,11 +176,10 @@ export default class LocationInput extends InputElement<Loc> {
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: this._matching_layer.data
layerToShow: this._matching_layer
})
}
this.mapBackground.map(layer => {
const leaflet = map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
@ -192,20 +191,31 @@ export default class LocationInput extends InputElement<Loc> {
leaflet.setZoom(layer.max_zoom - 1)
}, [map.leafletMap])
const animatedHand = Svg.hand_ui()
.SetStyle("width: 2rem; height: unset;")
.SetClass("hand-drag-animation block pointer-events-none")
return new Combine([
new Combine([
Svg.move_arrows_ui()
.SetClass("block relative pointer-events-none")
.SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem")
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"),
]).SetClass("block w-0 h-0 z-10 relative")
.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
.SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement();
} catch (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)
function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
async function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
if (reason !== undefined) {
tagsToApply.splice(0, 0, {
k: "fixme",
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(
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,
new Toggle(editButton, undefined, State.state.osmConnection.isLoggedIn)])
.SetClass("flex justify-between w-full")
new Toggle(editButton,
undefined,
State.state.osmConnection.isLoggedIn)
]).SetClass("flex justify-between w-full")
const cancelbutton =

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