Merge branch 'develop'

This commit is contained in:
Pieter Vander Vennet 2025-06-03 23:48:34 +02:00
commit a1d05400b3
86 changed files with 53743 additions and 51297 deletions

View file

@ -5,7 +5,7 @@ on:
jobs:
create_community_index:
runs-on: [ ubuntu-latest, hetzner-access ]
runs-on: [ hetzner-access ]
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4
- uses: ./.forgejo/setup

View file

@ -7,7 +7,7 @@ on:
jobs:
deploy_on_hosted:
runs-on: [ubuntu-latest, hetzner-access]
runs-on: [ hetzner-access ]
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4
- uses: ./.forgejo/setup
@ -26,11 +26,8 @@ jobs:
- name: Run tests
run: |
# This is the same as `npm run test`, but `vitest` doesn't want to run within npm :shrug:
export NODE_OPTIONS="--max-old-space-size=8192"
npm run clean:tests
npm run generate:doctests 2>&1 | grep -v "No doctests found in"
vitest --run test && npm run clean:tests
npm run test
shell: bash
- name: Build files

View file

@ -5,7 +5,7 @@ on:
jobs:
deploy_single_theme:
runs-on: [ ubuntu-latest, hetzner-access ]
runs-on: [ hetzner-access ]
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4
- uses: ./.forgejo/setup

View file

@ -5,7 +5,7 @@ on:
jobs:
update_nsi_logos:
runs-on: [ ubuntu-latest, hetzner-access ]
runs-on: [ hetzner-access ]
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4
- uses: ./.forgejo/setup

View file

@ -5,7 +5,7 @@ on:
jobs:
build_android:
runs-on: ubuntu-latest
runs-on: hetzner-access
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4

View file

@ -6,7 +6,7 @@ on:
jobs:
daily_data_maintenance:
runs-on: [ lain ]
runs-on: [ osm-cache ]
steps:
- uses: https://source.mapcomplete.org/actions/checkout@v4
- uses: ./.forgejo/setup

View file

@ -2,7 +2,7 @@
## Setting up the SQL-server (only once):
`sudo docker run --name some-postgis -e POSTGRES_PASSWORD=password -e POSTGRES_USER=user -d -p 5444:5432 -v /home/pietervdvn/data/pgsql/:/var/lib/postgresql/data postgis/postgis`
`sudo docker run --name some-postgis -e POSTGRES_PASSWORD=password -e POSTGRES_USER=user -d -p 5444:5432 -v ~/data/pgsql/:/var/lib/postgresql/data postgis/postgis`
Increase the max number of connections. osm2pgsql needs connection one per table (and a few more), and since we are making one table per layer in MapComplete, this amounts to a lot.

View file

@ -1,14 +1,14 @@
{
"id": "ambulancestation",
"name": {
"en": "Map of ambulance stations",
"en": "Ambulance stations",
"ja": "救急ステーションの地図",
"ru": "Карта станций скорой помощи",
"fr": "Couche des ambulances",
"de": "Rettungswachen",
"it": "Carta delle stazioni delle ambulanze",
"hu": "Mentőállomás-térkép",
"nl": "Kaart van ambulancestations",
"nl": "Ambulancestations",
"zh_Hans": "救护车站地图",
"id": "Peta stasiun ambulans",
"es": "Mapa de estaciones de ambulancias",

View file

@ -1,14 +1,14 @@
{
"id": "extinguisher",
"name": {
"en": "Map of fire extinguishers",
"en": "Fire extinguishers",
"ja": "消火器の地図です。",
"nb_NO": "Kart over brannhydranter",
"ru": "Карта огнетушителей.",
"fr": "Couche des extincteurs",
"de": "Feuerlöscher",
"it": "Mappa degli estintori",
"nl": "Kaart van brandblussers",
"nl": "Brandblussers",
"es": "Mapa de extintores",
"ca": "Mapa d'extintors",
"pl": "Mapa gaśnic",

View file

@ -1,14 +1,14 @@
{
"id": "fire_station",
"name": {
"en": "Map of fire stations",
"en": "Fire stations",
"ja": "消防署の地図",
"nb_NO": "Kart over brannstasjoner",
"it": "Mappa delle caserme dei vigili del fuoco",
"ru": "Карта пожарных частей",
"fr": "Couche des stations de pompiers",
"de": "Feuerwachen",
"nl": "Kaart van de brandweerstations",
"nl": "Brandweerstations",
"es": "Mapa de estaciones de bomberos",
"ca": "Mapa de parcs de bombers",
"cs": "Mapa požárních stanic"

View file

@ -1,7 +1,7 @@
{
"id": "hydrant",
"name": {
"en": "Map of hydrants",
"en": "Hydrants",
"ja": "消火栓の地図",
"zh_Hant": "消防栓地圖",
"nb_NO": "Kart over brannhydranter",
@ -9,7 +9,7 @@
"fr": "Carte des bornes incendie",
"de": "Hydranten",
"it": "Mappa degli idranti",
"nl": "Kaart van brandkranen",
"nl": "Brandkranen",
"es": "Mapa de bocas de incendio",
"ca": "Mapa d'hidrants",
"cs": "Mapa hydrantů",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3084,20 +3084,20 @@
"classes": "flex items-center gap-x-2",
"render": {
"special": {
"type": "qr_code"
},
"after": {
"en": "Scan this code to open this location on another device",
"nl": "Scan deze code om deze locatie op een ander apparaat te zien",
"de": "QR Code scannen, um diesen Ort auf einem anderen Gerät zu öffnen",
"sl": "Skenirajte to kodo, da odprete ta kraj na drugi napravi",
"da": "Skan denne kode for at åbne dette sted på en anden enhed",
"hu": "Szkenneld be ezt a kódot, hogy egy másik eszközön is meg tudd nyitni a helyet",
"uk": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої",
"es": "Escanea este código para abrir esta ubicación en otro dispositivo",
"cs": "Naskenováním tohoto kódu otevřete toto umístění na jiném zařízení",
"ca": "Escaneja aquest codi per obrir aquesta ubicació en un altre dispositiu",
"it": "Scansiona questo codice per aprire questa posizione su un altro dispositivo"
"type": "qr_code",
"text": {
"en": "Scan this code to open this location on another device",
"nl": "Scan deze code om deze locatie op een ander apparaat te zien",
"de": "QR Code scannen, um diesen Ort auf einem anderen Gerät zu öffnen",
"sl": "Skenirajte to kodo, da odprete ta kraj na drugi napravi",
"da": "Skan denne kode for at åbne dette sted på en anden enhed",
"hu": "Szkenneld be ezt a kódot, hogy egy másik eszközön is meg tudd nyitni a helyet",
"uk": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої",
"es": "Escanea este código para abrir esta ubicación en otro dispositivo",
"cs": "Naskenováním tohoto kódu otevřete toto umístění na jiném zařízení",
"ca": "Escaneja aquest codi per obrir aquesta ubicació en un altre dispositiu",
"it": "Scansiona questo codice per aprire questa posizione su un altro dispositivo"
}
}
}
},

View file

@ -1531,6 +1531,67 @@
}
}
},
{
"id": "share-login-title",
"render": {
"en": "<h3>Login via QR code</h3>"
}
},
{
"id": "share-login-explanation",
"render": {
"en": "With the below QR-code, you can login on another device without having to share your password"
}
},
{
"id": "share-login-group",
"render": {
"special": {
"type": "group",
"header": "share-login-group-title",
"labels": "share-login-content"
}
}
},
{
"id": "share-login-group-title",
"labels": [
"hidden"
],
"render": {
"en": "Allow to log in and act as <b>{_name}</b>"
}
},
{
"id": "share-login-qr",
"labels": [
"hidden",
"share-login-content"
],
"render": {
"special": {
"type": "qr_login",
"text": "Everyone with this QR-code can act as {_name}",
"textClass": "alert h-fit"
}
}
},
{
"id": "share-login-revoke",
"labels": [
"hidden",
"share-login-content"
],
"render": {
"special": {
"type": "link",
"href": "https://www.openstreetmap.org/oauth2/authorized_applications",
"text": {
"en": "You can revoke access here"
}
}
}
},
{
"id": "debug-title",
"render": {

View file

@ -10,6 +10,16 @@
"ko": "MapComplete로 이루어진 변경 사항",
"it": "Modifiche fatte con MapComplete"
},
"shortDescription": {
"en": "Shows changes made by MapComplete",
"de": "Zeigt die von MapComplete vorgenommenen Änderungen an",
"cs": "Zobrazuje změny provedené nástrojem MapComplete",
"es": "Muestra los cambios realizados por MapComplete",
"fr": "Afficher les modifications faites avec MapComplete",
"nl": "Toont wijzigingen gemaakt met MapComplete",
"ko": "MapComplete를 통해 이루어진 변경 사항을 표시합니다",
"it": "Mostra le modifiche fatte con MapComplete"
},
"description": {
"en": "This maps shows all the changes made with MapComplete",
"de": "Diese Karte zeigt alle mit MapComplete vorgenommenen Änderungen",
@ -21,18 +31,11 @@
"ko": "이 지도는 MapComplete를 사용하여 이루어진 모든 변경 사항을 표시합니다",
"it": "Questa mappa mostra tutte le modifiche effettuate con MapComplete"
},
"shortDescription": {
"en": "Shows changes made by MapComplete",
"de": "Zeigt die von MapComplete vorgenommenen Änderungen an",
"cs": "Zobrazuje změny provedené nástrojem MapComplete",
"es": "Muestra los cambios realizados por MapComplete",
"fr": "Afficher les modifications faites avec MapComplete",
"nl": "Toont wijzigingen gemaakt met MapComplete",
"ko": "MapComplete를 통해 이루어진 변경 사항을 표시합니다",
"it": "Mostra le modifiche fatte con MapComplete"
},
"icon": "./assets/svg/logo.svg",
"hideFromOverview": true,
"startLat": 0,
"startLon": 0,
"startZoom": 1,
"layers": [
{
"id": "mapcomplete-changes",
@ -426,6 +429,14 @@
"if": "theme=healthcare",
"then": "./assets/layers/doctors/doctors.svg"
},
{
"if": "theme=historic_aircraft",
"then": "./assets/svg/airport.svg"
},
{
"if": "theme=historic_rolling_stock",
"then": "./assets/layers/historic_rolling_stock/steam_locomotive.svg"
},
{
"if": "theme=hotels",
"then": "./assets/layers/tourism_accomodation/hotel.svg"

View file

@ -251,7 +251,6 @@
{
"builtin": [
"entrance",
"elevator",
"waste_basket",
"atm",
"clock"
@ -260,6 +259,14 @@
"minzoom": 18
}
},
{
"builtin": [
"elevator"
],
"override": {
"minzoom": 16
}
},
{
"builtin": "bench",
"override": {

View file

@ -9157,7 +9157,9 @@
},
"qr_code": {
"render": {
"after": "Escaneja aquest codi per obrir aquesta ubicació en un altre dispositiu"
"special": {
"text": "Escaneja aquest codi per obrir aquesta ubicació en un altre dispositiu"
}
}
},
"repeated": {

View file

@ -8406,7 +8406,9 @@
},
"qr_code": {
"render": {
"after": "Naskenováním tohoto kódu otevřete toto umístění na jiném zařízení"
"special": {
"text": "Naskenováním tohoto kódu otevřete toto umístění na jiném zařízení"
}
}
},
"repeated": {

View file

@ -1835,7 +1835,9 @@
},
"qr_code": {
"render": {
"after": "Skan denne kode for at åbne dette sted på en anden enhed"
"special": {
"text": "Skan denne kode for at åbne dette sted på en anden enhed"
}
}
},
"service:electricity": {

View file

@ -9121,7 +9121,9 @@
},
"qr_code": {
"render": {
"after": "QR Code scannen, um diesen Ort auf einem anderen Gerät zu öffnen"
"special": {
"text": "QR Code scannen, um diesen Ort auf einem anderen Gerät zu öffnen"
}
}
},
"repeated": {

View file

@ -423,7 +423,7 @@
},
"ambulancestation": {
"description": "An ambulance station is an area for storage of ambulance vehicles, medical equipment, personal protective equipment, and other medical supplies.",
"name": "Map of ambulance stations",
"name": "Ambulance stations",
"presets": {
"0": {
"description": "Add an ambulance station to the map",
@ -2401,6 +2401,20 @@
"Name": {
"question": "What is the name of this business?",
"render": "This business is named {name}"
},
"pub_reusable_packaging": {
"mappings": {
"0": {
"then": "Accepts reusable cups"
},
"1": {
"then": "Does not accept reusable cups"
},
"2": {
"then": "<b>Only</b> serves to people who bring reusable cups"
}
},
"question": "Does {title()} accept bring-your-own reusable cups?"
}
},
"title": {
@ -5511,7 +5525,7 @@
},
"extinguisher": {
"description": "Map layer to show fire extinguishers.",
"name": "Map of fire extinguishers",
"name": "Fire extinguishers",
"presets": {
"0": {
"description": "A fire extinguisher is a small, portable device used to stop a fire",
@ -5703,7 +5717,7 @@
},
"fire_station": {
"description": "Map layer to show fire stations.",
"name": "Map of fire stations",
"name": "Fire stations",
"presets": {
"0": {
"description": "A fire station is a place where the fire trucks and firefighters are located when not in operation.",
@ -6828,6 +6842,10 @@
}
},
"question": "Does this also serve as a memorial?"
},
"model": {
"question": "What is the model of this rolling stock?",
"render": "Model <b>{model}</b>"
}
},
"title": {
@ -6880,7 +6898,7 @@
},
"hydrant": {
"description": "Map layer to show fire hydrants.",
"name": "Map of hydrants",
"name": "Hydrants",
"presets": {
"0": {
"description": "A hydrant is a connection point where firefighters can tap water. It might be located underground.",
@ -9692,7 +9710,9 @@
},
"qr_code": {
"render": {
"after": "Scan this code to open this location on another device"
"special": {
"text": "Scan this code to open this location on another device"
}
}
},
"ref": {
@ -13833,6 +13853,22 @@
}
}
},
"share-login-explanation": {
"render": "With the below QR-code, you can login on another device without having to share your password"
},
"share-login-group-title": {
"render": "Allow to log in and act as <b>{_name}</b>"
},
"share-login-revoke": {
"render": {
"special": {
"text": "You can revoke access here"
}
}
},
"share-login-title": {
"render": "<h3>Login via QR code</h3>"
},
"show_crosshair": {
"mappings": {
"0": {

View file

@ -8733,7 +8733,9 @@
},
"qr_code": {
"render": {
"after": "Escanea este código para abrir esta ubicación en otro dispositivo"
"special": {
"text": "Escanea este código para abrir esta ubicación en otro dispositivo"
}
}
},
"repeated": {

View file

@ -944,7 +944,9 @@
},
"qr_code": {
"render": {
"after": "Szkenneld be ezt a kódot, hogy egy másik eszközön is meg tudd nyitni a helyet"
"special": {
"text": "Szkenneld be ezt a kódot, hogy egy másik eszközön is meg tudd nyitni a helyet"
}
}
},
"service:electricity": {

View file

@ -9609,7 +9609,9 @@
},
"qr_code": {
"render": {
"after": "Scansiona questo codice per aprire questa posizione su un altro dispositivo"
"special": {
"text": "Scansiona questo codice per aprire questa posizione su un altro dispositivo"
}
}
},
"repeated": {

View file

@ -409,7 +409,7 @@
},
"ambulancestation": {
"description": "Een ambulancestation is een plaats waar ambulances, medisch materiaal, persoonlijk beschermingsmateriaal en aanverwanten worden bewaard.",
"name": "Kaart van ambulancestations",
"name": "Ambulancestations",
"presets": {
"0": {
"description": "Voeg een ambulancestation toe aan de kaart",
@ -5137,7 +5137,7 @@
},
"extinguisher": {
"description": "Kaartlaag met brandblussers.",
"name": "Kaart van brandblussers",
"name": "Brandblussers",
"presets": {
"0": {
"description": "Een brandblusser is een klein, draagbaar apparaat om een brand te blussen",
@ -5266,7 +5266,7 @@
},
"fire_station": {
"description": "Kaartlaag die de brandweerstations toont.",
"name": "Kaart van de brandweerstations",
"name": "Brandweerstations",
"presets": {
"0": {
"description": "Een brandweerstation is een plaats waar brandweerwagens en brandweerlieden gebaseerd zijn.",
@ -6007,7 +6007,7 @@
},
"hydrant": {
"description": "Kaartlaag met brandkranen.",
"name": "Kaart van brandkranen",
"name": "Brandkranen",
"presets": {
"0": {
"description": "Een brandkraan is een kraan waar brandweerlieden een brandslang kunnen aansluiten. Soms zit deze ondergronds.",
@ -8160,7 +8160,9 @@
},
"qr_code": {
"render": {
"after": "Scan deze code om deze locatie op een ander apparaat te zien"
"special": {
"text": "Scan deze code om deze locatie op een ander apparaat te zien"
}
}
},
"repeated": {

View file

@ -239,7 +239,9 @@
},
"qr_code": {
"render": {
"after": "Skenirajte to kodo, da odprete ta kraj na drugi napravi"
"special": {
"text": "Skenirajte to kodo, da odprete ta kraj na drugi napravi"
}
}
},
"single_level": {

View file

@ -1919,7 +1919,9 @@
},
"qr_code": {
"render": {
"after": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої"
"special": {
"text": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої"
}
}
},
"share": {

View file

@ -831,7 +831,7 @@
"stations": {
"description": "Veure, editar i afegir detalls a una estació de tren",
"layers": {
"16": {
"17": {
"description": "Pantalles que mostren els trens que sortiran de l'estació",
"name": "Taulers de sortides",
"presets": {

View file

@ -1189,7 +1189,7 @@
"stations": {
"description": "Zobrazení, úprava a přidání podrobností o vlakovém nádraží",
"layers": {
"16": {
"17": {
"description": "Zobrazuje vlaky odjíždějící z této stanice",
"name": "Odjezdové tabule",
"presets": {

View file

@ -659,7 +659,7 @@
},
"stations": {
"layers": {
"16": {
"17": {
"name": "Afgangstavler",
"presets": {
"0": {

View file

@ -1170,7 +1170,7 @@
"stations": {
"description": "Bahnhofsdetails ansehen, bearbeiten und hinzufügen",
"layers": {
"16": {
"17": {
"description": "Anzeigen der Züge, die von diesem Bahnhof abfahren",
"name": "Abfahrtstafeln",
"presets": {

View file

@ -1231,7 +1231,7 @@
"stations": {
"description": "View, edit and add details to a train station",
"layers": {
"16": {
"17": {
"description": "Displays showing the trains that will leave from this station",
"name": "Departures boards",
"presets": {

View file

@ -1114,7 +1114,7 @@
"stations": {
"description": "Ver, editar y agregar detalles a una estación de tren",
"layers": {
"16": {
"17": {
"description": "Pantallas que muestran los trenes que saldrán de esta estación",
"name": "Tableros de salidas",
"presets": {

View file

@ -914,7 +914,7 @@
"stations": {
"description": "Voir, modifier et ajouter des détails à une gare ferroviaire",
"layers": {
"16": {
"17": {
"description": "Panneau affichant les trains au départ depuis cette gare",
"name": "Panneaux des départs",
"presets": {

View file

@ -1214,7 +1214,7 @@
"stations": {
"description": "Visualizza, modifica e aggiungi dettagli a una stazione ferroviaria",
"layers": {
"16": {
"17": {
"description": "Display che mostrano i treni che partiranno da questa stazione",
"name": "Tabelloni delle partenze",
"presets": {

View file

@ -1100,7 +1100,7 @@
"stations": {
"description": "기차역 보기, 세부사항 편집 또는 추가하기",
"layers": {
"16": {
"17": {
"description": "이 역에서 출발하는 기차를 보여주는 안내 전광판",
"name": "출발 안내 전광판",
"presets": {

View file

@ -393,7 +393,7 @@
},
"stations": {
"layers": {
"16": {
"17": {
"tagRenderings": {
"type": {
"mappings": {

View file

@ -1157,7 +1157,7 @@
"stations": {
"description": "Bekijk, bewerk en voeg details to aan een treinstation",
"layers": {
"16": {
"17": {
"description": "Schermen die treinen tonen die van dit station vertrekken",
"name": "Vertrektijdenborden",
"presets": {

View file

@ -787,7 +787,7 @@
"stations": {
"description": "Przeglądaj, edytuj i dodawaj szczegóły do stacji kolejowej",
"layers": {
"16": {
"17": {
"description": "Ekrany wyświetlające pokazujące pociągi, które odjadą z tej stacji",
"name": "Tablice odjazdów",
"presets": {

View file

@ -750,7 +750,7 @@
},
"stations": {
"layers": {
"16": {
"17": {
"name": "出發板",
"presets": {
"0": {

2082
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -296,6 +296,6 @@
"typescript": "^4.7.4",
"vite": "^4.5.9",
"vite-node": "^3.0.5",
"vitest": "^3.0.5"
"vitest": "^3.2.1"
}
}

View file

@ -1,8 +1,5 @@
import Script from "./Script"
import NameSuggestionIndex, {
NamgeSuggestionWikidata,
NSIItem,
} from "../src/Logic/Web/NameSuggestionIndex"
import NameSuggestionIndex, { NamgeSuggestionWikidata, NSIItem } from "../src/Logic/Web/NameSuggestionIndex"
import * as nsiWD from "../node_modules/name-suggestion-index/dist/wikidata.min.json"
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs"
import ScriptUtils from "./ScriptUtils"
@ -218,7 +215,7 @@ class NsiLogos extends Script {
const config: LayerConfigJson = {
id: "nsi_" + type,
description: {
en: "Exposes part of the NSI to reuse in other themes, e.g. for rendering. Automatically generated and never directly loaded in a theme",
en: "Exposes part of the NSI to reuse in other themes, e.g. for rendering. Automatically generated and never directly loaded in a theme. Generated with scripts/nsiLogos.ts"
},
source: "special:library",
pointRendering: null,
@ -234,6 +231,7 @@ class NsiLogos extends Script {
allowMove: false,
"#dont-translate": "*",
}
config["generation_time"] = new Date().toISOString()
const path = "./assets/layers/nsi_" + type
mkdirSync(path, { recursive: true })
writeFileSync(path + "/nsi_" + type + ".json", JSON.stringify(config, null, " "))
@ -395,7 +393,7 @@ class NsiLogos extends Script {
download: { f: () => this.download(), doc: "Download all icons" },
generateRenderings: {
f: () => this.generateRenderings(),
doc: "Generates the layer files 'nsi_brand' and 'nsi_operator' which allows to reuse the icons in renderings",
doc: "Generates the layer files 'nsi_brand.json' and 'nsi_operator.json' which allows to reuse the icons in renderings"
},
prune: { f: () => NsiLogos.prune(), doc: "Remove no longer needed files" },
addExtensions: {

View file

@ -13,6 +13,7 @@ import ImageUploadQueue, { ImageUploadArguments } from "./ImageUploadQueue"
import { GeoOperations } from "../GeoOperations"
import NoteCommentElement from "../../UI/Popup/Notes/NoteCommentElement"
import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
import ExifReader from "exifreader"
/**
* The ImageUploadManager has a
@ -81,7 +82,7 @@ export class ImageUploadManager {
this._reportError = reportError
}
public canBeUploaded(file: File): true | { error: Translation } {
public async canBeUploaded(file: File): Promise<true | { error: Translation }> {
const sizeInBytes = file.size
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
const error = Translations.t.image.toBig.Subs({
@ -94,12 +95,19 @@ export class ImageUploadManager {
if (ext !== "jpg" && ext !== "jpeg") {
return { error: new Translation({ en: "Only JPG-files are allowed" }) }
}
const tags = await ExifReader.load(file)
if (tags.ProjectionType.value === "cylindrical") {
return { error: new Translation({ en: "Cylindrical images (typically created by a Panorama-app) are not supported" }) }
}
return true
}
/**
* Uploads the given image, applies the correct title and license for the known user.
* Will then add this image to the OSM-feature or the OSM-note automatically, based on the ID of the feature.
* Does _not_ check 'canBeUploaded'
* Note: the image will actually be added to the queue. If the image-upload fails, this will be attempted when visiting MC again
* @param file a jpg file to upload
* @param tagsStore The tags of the feature
@ -117,10 +125,6 @@ export class ImageUploadManager {
ignoreGPS: boolean | false
}
): void {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
throw canBeUploaded.error
}
const tags: OsmTags = tagsStore.data
const featureId = <OsmId | NoteId>tags.id
@ -286,7 +290,7 @@ export class ImageUploadManager {
let absoluteUrl: string
try {
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
({ key, value, absoluteUrl } = await this._uploader.uploadImage(
blob,
location,
author,

View file

@ -51,7 +51,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
new SvelteUIElement(Panoramax_bw),
p.createViewLink({
imageId: img?.id,
location,
location
}),
true
)
@ -65,14 +65,14 @@ export default class PanoramaxImageProvider extends ImageProvider {
const p = new Panoramax(host)
return p.createViewLink({
imageId: img?.id,
location,
location
})
}
public addKnownMeta(meta: ImageData, url?: string) {
PanoramaxImageProvider.knownMeta[meta.id] = {
data: Promise.resolve({ data: meta, url }),
time: new Date(),
time: new Date()
}
}
@ -125,7 +125,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
status: meta.properties["geovisio:status"],
rotation: Number(meta.properties["view:azimuth"]),
isSpherical: meta.properties.exif["Xmp.GPano.ProjectionType"] === "equirectangular",
date: new Date(meta.properties.datetime),
date: new Date(meta.properties.datetime)
}
}
@ -156,7 +156,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
const promise: Promise<{ data: ImageData; url: string }> = this.getInfoForUncached(id)
PanoramaxImageProvider.knownMeta[id] = {
time: new Date(),
data: promise,
data: promise
}
return await promise
}
@ -215,7 +215,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
return {
artist: meta.data.providers.at(-1).name, // We take the last provider, as that one probably contain the username of the uploader
date: new Date(meta.data.properties["datetime"]),
licenseShortName: meta.data.properties["geovisio:license"],
licenseShortName: meta.data.properties["geovisio:license"]
}
}
@ -247,8 +247,8 @@ export default class PanoramaxImageProvider extends ImageProvider {
properties: {
url,
northOffset,
pitchOffset,
},
pitchOffset
}
}
}
}
@ -263,6 +263,7 @@ export class PanoramaxUploader implements ImageUploader {
this.panoramax = new AuthorizedPanoramax(url, token)
}
async uploadImage(
blob: File,
currentGps: [number, number],
@ -282,53 +283,63 @@ export class PanoramaxUploader implements ImageUploader {
datetime ??= new Date().toISOString()
try {
const tags = await ExifReader.load(blob)
const [[latD], [latM], [latS, latSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLatitude?.value
const [[lonD], [lonM], [lonS, lonSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLongitude?.value
if (tags.ProjectionType.value === "cylindrical") {
throw "Unsupported image format: cylindrical images (panorama images) are currently not supported"
}
if (tags?.GPSLatitude?.value && tags?.GPSLongitude?.value) {
const exifLat = latD + latM / 60 + latS / (3600 * latSDenom)
const exifLon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
if (
typeof exifLat === "number" &&
!isNaN(exifLat) &&
typeof exifLon === "number" &&
!isNaN(exifLon) &&
!(exifLat === 0 && exifLon === 0)
) {
lat = exifLat
lon = exifLon
if (tags?.GPSLatitudeRef?.value?.[0] === "S") {
lat *= -1
}
if (tags?.GPSLongitudeRef?.value?.[0] === "W") {
lon *= -1
const [[latD], [latM], [latS, latSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLatitude?.value
const [[lonD], [lonM], [lonS, lonSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLongitude?.value
const exifLat = latD + latM / 60 + latS / (3600 * latSDenom)
const exifLon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
if (
typeof exifLat === "number" &&
!isNaN(exifLat) &&
typeof exifLon === "number" &&
!isNaN(exifLon) &&
!(exifLat === 0 && exifLon === 0)
) {
lat = exifLat
lon = exifLon
if (tags?.GPSLatitudeRef?.value?.[0] === "S") {
lat *= -1
}
if (tags?.GPSLongitudeRef?.value?.[0] === "W") {
lon *= -1
}
}
}
const [date, time] = (
const dateTime = (
tags.DateTime.value[0] ??
tags.DateTimeOriginal.value[0] ??
tags.GPSDateStamp ??
tags.CreateDate ??
tags["Date Created"]
).split(" ")
const exifDatetime = new Date(date.replaceAll(":", "-") + "T" + time)
if (exifDatetime.getFullYear() === 1970) {
// The data probably got reset to the epoch
// we don't use the value
console.log(
"Datetime from picture is probably invalid:",
exifDatetime,
"using 'now' instead"
)
} else {
datetime = exifDatetime.toISOString()
)?.split(" ")
if (dateTime) {
const [date, time] = dateTime
const exifDatetime = new Date(date.replaceAll(":", "-") + "T" + time)
if (exifDatetime.getFullYear() === 1970) {
// The data probably got reset to the epoch
// we don't use the value
console.log(
"Datetime from picture is probably invalid:",
exifDatetime,
"using 'now' instead"
)
} else {
datetime = exifDatetime.toISOString()
}
}
console.log("Tags are", tags)
} catch (e) {
console.warn("Could not read EXIF-tags")
console.warn("Could not read EXIF-tags due to", e)
}
const p = this.panoramax
@ -345,7 +356,7 @@ export class PanoramaxUploader implements ImageUploader {
indexInSequence: sequence["stats:items"].count + 1, // stats:items is '1'-indexed, so .count is also the last index
exifOverride: {
Artist: author,
},
}
}
if (progress) {
options.onProgress = (e: ProgressEvent) => {
@ -362,7 +373,7 @@ export class PanoramaxUploader implements ImageUploader {
return {
key: "panoramax",
value: img.id,
absoluteUrl: img.assets.hd.href,
absoluteUrl: img.assets.hd.href
}
}
}

View file

@ -154,7 +154,8 @@ export class OsmConnection {
constructor(options?: {
dryRun?: Store<boolean>
fakeUser?: false | boolean
oauth_token?: UIEventSource<string>
oauth_token?: UIEventSource<string>,
shared_cookie?: string,
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
attemptLogin?: boolean
@ -205,6 +206,10 @@ export class OsmConnection {
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
if (options?.shared_cookie) {
this.setToken(options?.shared_cookie)
}
this.updateAuthObject(false)
AndroidPolyfill.inAndroid.addCallback(() => {
this.updateAuthObject(false)
@ -600,6 +605,9 @@ export class OsmConnection {
})
}
/**
* Gets the login token. Sharing this will allow to mimic the user session on another device
*/
public getToken(): string {
// https://www.openstreetmap.orgoauth2_access_token
let prefix = this.Backend()
@ -608,12 +616,20 @@ export class OsmConnection {
}
return (
QueryParameters.GetQueryParameter(prefix + "oauth_token", undefined).data ??
window.localStorage.getItem(this._oauth_config.url + "oauth2_access_token")
window.localStorage.getItem(this.getLoginCookieName())
)
}
public setToken(token: string) {
window.localStorage.setItem(this.getLoginCookieName(), token)
}
private getLoginCookieName() {
return this._oauth_config.url + "oauth2_access_token"
}
private async loginAndroidPolyfill() {
const key = "https://www.openstreetmap.orgoauth2_access_token"
const key = this.getLoginCookieName()
if (localStorage.getItem(key)) {
// We are probably already logged in
return
@ -629,6 +645,7 @@ export class OsmConnection {
}
await this.loadUserInfo()
}
private updateAuthObject(autoLogin: boolean) {
let redirect_uri = Utils.runningFromConsole
? "https://mapcomplete.org/land.html"

View file

@ -269,9 +269,10 @@ export class OsmPreferences {
if (!this.osmConnection.isLoggedIn.data) {
return
}
// _All_ keys are deleted first, to avoid pending parts
const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k)
await Promise.all(keysToDelete.map((k) => this.deleteKeyDirectly(k)))
if (v === null || v === undefined || v === "" || v === "undefined" || v === "null") {
const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k)
await Promise.all(keysToDelete.map((k) => this.deleteKeyDirectly(k)))
return
}

View file

@ -1,42 +1,14 @@
import { Utils } from "../../Utils"
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
export class ThemeMetaTagging {
public static readonly themeName = "usersettings"
public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
feat.properties._description
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
?.at(1)
)
Utils.AddLazyProperty(
feat.properties,
"_d",
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
}
}
public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/&lt;/g,'<')?.replace(/&gt;/g,'>') ?? '' )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_a', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.href.match(/mastodon|en.osm.town/) !== null)[0]?.href }) (feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_link', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.getAttribute("rel")?.indexOf('me') >= 0)[0]?.href})(feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate', () => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a )
feat.properties['__current_backgroun'] = 'initial_value'
}
}

View file

@ -361,7 +361,7 @@ export default class NameSuggestionIndex {
return nsi.generateMappings(key, tags, country, center, options)
}
private static readonly brandPrefix = ["name", "alt_name", "operator", "brand"] as const
private static readonly brandPrefix = ["name", "alt_name", "operator", "brand", "official_name"] as const
/**
* An NSI-item might have tags such as `name=X`, `alt_name=brand X`, `brand=X`, `brand:wikidata`, `shop=Y`, `service:abc=yes`
@ -370,6 +370,14 @@ export default class NameSuggestionIndex {
* This method is a heuristic which attempts to move all the brand-related tags into an `or` but still requiring the `shop` and other tags
*
* (More of an extension method on NSIItem)
*
* const item = {
* displayName: "test",
* id: "test",
* locationSet: {include: ["BE"],exclude: []},
* tags: {name:"XYZ", brand:"XYZ", alt_name: "ABC",official_name:"Association Brusselse Chou"}
* }
* NameSuggestionIndex.asFilterTags(item) // => {or: ["alt_name=ABC", "brand=XYZ","name=XYZ","official_name=Association Brusselse Chou"]}
*/
static asFilterTags(
item: NSIItem

View file

@ -52,7 +52,7 @@ export abstract class Conversion<TIn, TOut> {
throw new Error(
[
"Detected one or more errors, stopping now:",
context.getAll("error").map((e) => e.context.path.join(".") + ": " + e.message),
context.getAll("error").map((e) => `${e.context.path.join(".")} (in operation: ${e.context.operation.join(".")}): ${e.message}`)
].join("\n\t")
)
}
@ -107,7 +107,7 @@ export class Bypass<T> extends DesugaringStep<T> {
private readonly _step: DesugaringStep<T>
constructor(applyIf: (t: T) => boolean, step: DesugaringStep<T>) {
super("Bypass", "Applies the step on the object, if the object satisfies the predicate")
super("Bypass(" + step.name + ")", "Applies the step on the object, if the object satisfies the predicate")
this._applyIf = applyIf
this._step = step
}

View file

@ -232,7 +232,12 @@ export class ExtractImages extends Conversion<
// Split "circle:white;./assets/layers/.../something.svg" into ["circle", "./assets/layers/.../something.svg"]
const allPaths = Utils.NoNull(
Utils.NoEmpty(foundImage.path?.split(";")?.map((part) => part.split(":")[0]))
Utils.NoEmpty(foundImage.path?.split(";")?.map((part) => {
if (part.startsWith("http")) {
return part
}
return part.split(":")[0]
}))
)
for (const path of allPaths) {
cleanedImages.push({ path, context: foundImage.context })

View file

@ -400,7 +400,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
* RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel"}}, ConversionContext.test()) // => {'*': "{image_carousel()}"}
*
* // should add a class to the special element
* RewriteSpecial.convertIfNeeded({"special": {"type":"qr_code"}, class:"inline"}, ConversionContext.test()) // => {'*': "{qr_code():inline}"}
* RewriteSpecial.convertIfNeeded({"special": {"type":"qr_code"}, class:"inline"}, ConversionContext.test()) // => {'*': "{qr_code(,):inline}"}
*
* // should handle special case with a parameter
* RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel", "image_key": "some_image_key"}}, ConversionContext.test()) // => {'*': "{image_carousel(some_image_key)}"}

View file

@ -6,6 +6,7 @@ import { ConversionContext } from "./ConversionContext"
import ThemeConfig from "../ThemeConfig"
import { Utils } from "../../../Utils"
import { DetectDuplicatePresets, DoesImageExist, ValidateLanguageCompleteness } from "./Validation"
import Constants from "../../Constants"
export class ValidateTheme extends DesugaringStep<ThemeConfigJson> {
/**
@ -64,6 +65,7 @@ export class ValidateTheme extends DesugaringStep<ThemeConfigJson> {
// Check images: are they local, are the licenses there, is the theme icon square, ...
const images = this._extractImages.convert(json, context.inOperation("ValidateTheme"))
const remoteImages = images.filter((img) => img.path.indexOf("http") == 0)
.filter(img => !img.path.startsWith(Constants.nsiLogosEndpoint))
for (const remoteImage of remoteImages) {
context.err(
"Found a remote image: " +
@ -110,7 +112,7 @@ export class ValidateTheme extends DesugaringStep<ThemeConfigJson> {
if (json["mustHaveLanguage"] !== undefined) {
new ValidateLanguageCompleteness(...json["mustHaveLanguage"]).convert(
theme,
context
context.inOperation("ValidateLanguageCompleteness")
)
}
if (!json.hideFromOverview && theme.id !== "personal" && this._isBuiltin) {
@ -123,7 +125,7 @@ export class ValidateTheme extends DesugaringStep<ThemeConfigJson> {
}
// Official, public themes must have a full english translation
new ValidateLanguageCompleteness("en").convert(theme, context)
new ValidateLanguageCompleteness("en").convert(theme, context.inOperation("ValidateLanguageCompleteness"))
}
} catch (e) {
console.error(e)
@ -131,7 +133,7 @@ export class ValidateTheme extends DesugaringStep<ThemeConfigJson> {
}
if (theme.id !== "personal") {
new DetectDuplicatePresets().convert(theme, context)
new DetectDuplicatePresets().convert(theme, context.inOperation("DectectDuplicatePrsets"))
}
if (!theme.title) {

View file

@ -111,6 +111,9 @@ export class DoesImageExist extends DesugaringStep<string> {
if (!this._knownImagePaths.has(image)) {
if (this.doesPathExist === undefined || image.indexOf("nsi/logos/") >= 0) {
// pass
} else if (image.startsWith("https://")) {
// Pass
// This is an online image. Normally forbidden, but not the responsability of this code to check for online images
} else if (!this.doesPathExist(image)) {
context.err(
`Image with path ${image} does not exist.\n Check for typo's and missing directories in the path. `

View file

@ -37,9 +37,11 @@ export class WithUserRelatedState {
}
this.theme = theme
this.featureSwitches = new FeatureSwitchState(theme)
this.osmConnection = new OsmConnection({
dryRun: this.featureSwitches.featureSwitchIsTesting,
fakeUser: this.featureSwitches.featureSwitchFakeUser.data,
shared_cookie: QueryParameters.GetQueryParameter("shared_oauth_cookie", undefined, "Used to share a session with another device - this saves logging in at another device").data,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,

View file

@ -29,6 +29,7 @@
const featureSwitches = new OsmConnectionFeatureSwitches()
const osmConnection = new OsmConnection({
fakeUser: featureSwitches.featureSwitchFakeUser.data,
shared_cookie: QueryParameters.GetQueryParameter("shared_oauth_cookie", undefined, "Used to share a session with another device - this saves logging in at another device").data,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,

View file

@ -2,6 +2,9 @@ import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
/**
* @deprecated
*/
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[]

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from "svelte"
import { twMerge } from "tailwind-merge"
export let accept: string | undefined
export let capture: string | undefined = undefined

View file

@ -1,18 +0,0 @@
import Combine from "./Combine"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import SvelteUIElement from "./SvelteUIElement"
import { default as LoadingSvg } from "../../assets/svg/Loading.svelte"
export default class Loading extends Combine {
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading
t.SetClass("pl-2")
super([
new SvelteUIElement(LoadingSvg)
.SetClass("animate-spin self-center")
.SetStyle("width: 1.5rem; height: 1.5rem; min-width: 1.5rem;"),
t,
])
this.SetClass("flex p-1")
}
}

View file

@ -2,8 +2,8 @@
import { createEventDispatcher } from "svelte"
import { twJoin } from "tailwind-merge"
import { Translation } from "../i18n/Translation"
import { ariaLabel, ariaLabelStore } from "../../Utils/ariaLabel"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import { ariaLabelStore } from "../../Utils/ariaLabel"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
/**
* A round button with an icon and possible a small text, which hovers above the map

View file

@ -1,27 +1,26 @@
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
/**
* @deprecated
*/
export default class Table extends BaseUIElement {
private readonly _header: BaseUIElement[]
private readonly _contents: BaseUIElement[][]
private readonly _contentStyle: string[][]
private readonly _sortable: boolean
constructor(
header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
options?: {
contentStyle?: string[][]
sortable?: false | boolean
}
) {
super()
this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]]
this._header = header?.map(Translations.W)
this._contents = contents.map((row) => row.map(Translations.W))
this._sortable = options?.sortable ?? false
}
AsMarkdown(): string {
@ -46,26 +45,8 @@ export default class Table extends BaseUIElement {
protected InnerConstructElement(): HTMLElement {
const table = document.createElement("table")
/**
* Sortmode: i: sort column i ascending;
* if i is negative : sort column (-i - 1) descending
*/
const sortmode = new UIEventSource<number>(undefined)
const self = this
const headerElems = Utils.NoNull(
(this._header ?? []).map((elem, i) => {
if (self._sortable) {
elem.onClick(() => {
const current = sortmode.data
if (current == i) {
sortmode.setData(-1 - i)
} else {
sortmode.setData(i)
}
})
}
return elem.ConstructElement()
})
(this._header ?? []).map((elem) => elem.ConstructElement())
)
if (headerElems.length > 0) {
const thead = document.createElement("thead")
@ -81,11 +62,11 @@ export default class Table extends BaseUIElement {
}
for (let i = 0; i < this._contents.length; i++) {
let row = this._contents[i]
const row = this._contents[i]
const tr = document.createElement("tr")
for (let j = 0; j < row.length; j++) {
try {
let elem = row[j]
const elem = row[j]
if (elem?.ConstructElement === undefined) {
continue
}
@ -114,29 +95,6 @@ export default class Table extends BaseUIElement {
table.appendChild(tr)
}
sortmode.addCallback((sortCol) => {
if (sortCol === undefined) {
return
}
const descending = sortCol < 0
const col = descending ? -sortCol - 1 : sortCol
let rows: HTMLTableRowElement[] = Array.from(table.rows)
rows.splice(0, 1) // remove header row
rows = rows.sort((a, b) => {
const ac = a.cells[col]?.textContent?.toLowerCase()
const bc = b.cells[col]?.textContent?.toLowerCase()
if (ac === bc) {
return 0
}
return ac < bc !== descending ? -1 : 1
})
for (let j = rows.length; j > 1; j--) {
table.deleteRow(j)
}
for (const row of rows) {
table.appendChild(row)
}
})
return table
}

View file

@ -5,7 +5,6 @@
import { Translation } from "../i18n/Translation"
import WeblateLink from "./WeblateLink.svelte"
import { Store } from "../../Logic/UIEventSource"
import FromHtml from "./FromHtml.svelte"
import { Utils } from "../../Utils"
export let t: Translation

View file

@ -50,16 +50,6 @@ export default abstract class BaseUIElement {
return this
}
public ScrollIntoView() {
if (this._constructedHtmlElement === undefined) {
return
}
this._constructedHtmlElement?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
/**
* Adds all the relevant classes, space separated
*/

View file

@ -42,7 +42,7 @@
const file = files.item(i)
console.log("Got file", file.name)
try {
const canBeUploaded = state?.imageUploadManager?.canBeUploaded(file)
const canBeUploaded = await state?.imageUploadManager?.canBeUploaded(file)
if (canBeUploaded !== true) {
errs.push(canBeUploaded.error)
continue

View file

@ -1,8 +1,9 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
/**
* @deprecated
* The 'Toggle' is a UIElement showing either one of two elements, depending on the state.
* It can be used to implement e.g. checkboxes or collapsible elements
*/

View file

@ -967,6 +967,52 @@ changes // => [[36000,61200], ["10:00", "17:00"]]
}
return oh
}
/**
*
* @param changeHours number of seconds 'till the start of the day, assuming sorted
* @param changeHourText
* @param maxDiff minimum required seconds between two items to be in the same group
*
* OH.partitionOHForDistance([0, 15, 3615], ["start", "15s", "1h15s"]) // => [{changeHours: [0, 3615], changeTexts: ["start", "1h15s"]}, {changeHours: [15], changeTexts: ["15s"]}]
*
*/
public static partitionOHForDistance(changeHours: number[], changeHourText: string[], maxDiff = 5400): {
changeHours: number[],
changeTexts: string[]
}[] {
const partitionedHours: { changeHours: number[], changeTexts: string[] }[] = [
{ changeHours: [changeHours[0]], changeTexts: [changeHourText[0]] }
]
for (let i = 1 /*skip the first one, inited ^*/; i < changeHours.length; i++) {
const moment = changeHours[i]
const text = changeHourText[i]
let depth = 0
while (depth < partitionedHours.length) {
const candidate = partitionedHours[depth]
const lastMoment = candidate.changeHours.at(-1)
const diff = moment - lastMoment
if (diff >= maxDiff) {
candidate.changeHours.push(moment)
candidate.changeTexts.push(text)
break
}
depth++
}
if (depth == partitionedHours.length) {
// No candidate found - make a new list
partitionedHours.push({
changeTexts: [text],
changeHours: [moment]
})
}
}
return partitionedHours
}
}
export class ToTextualDescription {

View file

@ -1,306 +0,0 @@
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { OH, OpeningRange, ToTextualDescription } from "./OpeningHours"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement"
import Table from "../Base/Table"
import { Translation, TypedTranslation } from "../i18n/Translation"
import Loading from "../Base/Loading"
import opening_hours from "opening_hours"
import Locale from "../i18n/Locale"
export default class OpeningHoursVisualization extends Toggle {
private static readonly weekdays: Translation[] = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday,
]
constructor(
tags: UIEventSource<Record<string, string>>,
key: string,
prefix = "",
postfix = ""
) {
const openingHoursStore = OH.CreateOhObjectStore(tags, key, prefix, postfix)
const ohTable = new VariableUiElement(
openingHoursStore.map((opening_hours_obj) => {
if (opening_hours_obj === undefined) {
return new FixedUiElement("No opening hours defined with key " + key).SetClass(
"alert"
)
}
if (opening_hours_obj === "error") {
return Translations.t.general.opening_hours.error_loading
}
const applicableWeek = OH.createRangesForApplicableWeek(opening_hours_obj)
const textual = ToTextualDescription.createTextualDescriptionFor(
opening_hours_obj,
applicableWeek.ranges
)
const vis = OpeningHoursVisualization.CreateFullVisualisation(
opening_hours_obj,
applicableWeek.ranges,
applicableWeek.startingMonday
)
Locale.language.mapD((lng) => {
console.debug("Setting OH description to", lng, textual)
vis.ConstructElement().ariaLabel = textual?.textFor(lng)
})
return vis
})
)
super(
ohTable,
new Loading(Translations.t.general.opening_hours.loadingCountry),
tags.map((tgs) => tgs._country !== undefined)
)
this.SetClass("no-weblate")
}
private static CreateFullVisualisation(
oh: opening_hours,
ranges: OpeningRange[][],
lastMonday: Date
): BaseUIElement {
// First, a small sanity check. The business might be permanently closed, 24/7 opened or be another special case
if (ranges.some((range) => range.length > 0)) {
// The normal case: we have items for the coming days
return OpeningHoursVisualization.ConstructVizTable(oh, ranges, lastMonday)
}
// The special case that range is completely empty
return OpeningHoursVisualization.ShowSpecialCase(oh)
}
private static ConstructVizTable(
oh: any,
ranges: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
}[][],
rangeStart: Date
): BaseUIElement {
const isWeekstable: boolean = oh.isWeekStable()
const [changeHours, changeHourText] = OH.allChangeMoments(ranges)
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayIndex = Math.ceil(
(today.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
)
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
const earliestOpen = Math.min(8 * 60 * 60, ...changeHours)
let latestclose = Math.max(...changeHours)
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60)
const availableArea = latestclose - earliestOpen
/*
* The OH-visualisation is a table, consisting of 8 rows and 2 columns:
* The first row is a header row (which is NOT passed as header but just as a normal row!) containing empty for the first column and one object giving all the end times
* The other rows are one for each weekday: the first element showing 'mo', 'tu', ..., the second element containing the bars.
* Note that the bars are actually an embedded <div> spanning the full width, containing multiple sub-elements
* */
const [header, headerHeight] = OpeningHoursVisualization.ConstructHeaderElement(
availableArea,
changeHours,
changeHourText,
earliestOpen
)
const weekdays = []
const weekdayStyles = []
for (let i = 0; i < 7; i++) {
const day = OpeningHoursVisualization.weekdays[i].Clone()
day.SetClass("w-full h-full flex")
const rangesForDay = ranges[i].map((range) =>
OpeningHoursVisualization.CreateRangeElem(
availableArea,
earliestOpen,
latestclose,
range,
isWeekstable
)
)
const allRanges = new Combine([
...OpeningHoursVisualization.CreateLinesAtChangeHours(
changeHours,
availableArea,
earliestOpen
),
...rangesForDay,
]).SetClass("w-full block")
let extraStyle = ""
if (todayIndex == i) {
extraStyle = "background-color: var(--subtle-detail-color);"
allRanges.SetClass("ohviz-today")
} else if (i >= 5) {
extraStyle = "background-color: rgba(230, 231, 235, 1);"
}
weekdays.push([day, allRanges])
weekdayStyles.push([
"padding-left: 0.5em;" + extraStyle,
`position: relative;` + extraStyle,
])
}
return new Table(undefined, [["&nbsp", header], ...weekdays], {
contentStyle: [
["width: 5%", `position: relative; height: ${headerHeight}`],
...weekdayStyles,
],
})
.SetClass("w-full")
.SetStyle(
"border-collapse: collapse; word-break; word-break: normal; word-wrap: normal"
)
}
private static CreateRangeElem(
availableArea: number,
earliestOpen: number,
latestclose: number,
range: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
},
isWeekstable: boolean
): BaseUIElement {
const textToShow =
range.comment ?? (isWeekstable ? "" : range.startDate.toLocaleDateString())
if (!range.isOpen && !range.isSpecial) {
return new FixedUiElement(textToShow).SetClass("ohviz-day-off")
}
const startOfDay: Date = new Date(range.startDate)
startOfDay.setHours(0, 0, 0, 0)
const startpoint = (range.startDate.getTime() - startOfDay.getTime()) / 1000 - earliestOpen
// prettier-ignore
const width = (100 * (range.endDate.getTime() - range.startDate.getTime()) / 1000) / (latestclose - earliestOpen)
const startPercentage = (100 * startpoint) / availableArea
return new FixedUiElement(textToShow)
.SetStyle(`left:${startPercentage}%; width:${width}%`)
.SetClass("ohviz-range")
}
private static CreateLinesAtChangeHours(
changeHours: number[],
availableArea: number,
earliestOpen: number
): BaseUIElement[] {
const allLines: BaseUIElement[] = []
for (const changeMoment of changeHours) {
const offset = (100 * (changeMoment - earliestOpen)) / availableArea
if (offset < 0 || offset > 100) {
continue
}
const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line")
allLines.push(el)
}
return allLines
}
/**
* The OH-Visualization header element, a single bar with hours
* @param availableArea
* @param changeHours
* @param changeHourText
* @param earliestOpen
* @constructor
* @private
*/
private static ConstructHeaderElement(
availableArea: number,
changeHours: number[],
changeHourText: string[],
earliestOpen: number
): [BaseUIElement, string] {
const header: BaseUIElement[] = []
header.push(
...OpeningHoursVisualization.CreateLinesAtChangeHours(
changeHours,
availableArea,
earliestOpen
)
)
let showHigher = false
let showHigherUsed = false
for (let i = 0; i < changeHours.length; i++) {
const changeMoment = changeHours[i]
const offset = (100 * (changeMoment - earliestOpen)) / availableArea
if (offset < 0 || offset > 100) {
continue
}
if (i > 0 && (changeMoment - changeHours[i - 1]) / (60 * 60) < 2) {
// Quite close to the previous value
// We alternate the heights
showHigherUsed = true
showHigher = !showHigher
} else {
showHigher = false
}
const el = new Combine([
new FixedUiElement(changeHourText[i])
.SetClass(
"relative bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black border-opacity-50"
)
.SetStyle("left: -50%; word-break:initial"),
])
.SetStyle(`left:${offset}%;margin-top: ${showHigher ? "1.4rem;" : "0.1rem"}`)
.SetClass("block absolute top-0 m-0 h-full box-border ohviz-time-indication")
header.push(el)
}
const headerElem = new Combine(header)
.SetClass(`w-full absolute block ${showHigherUsed ? "h-16" : "h-8"}`)
.SetStyle("margin-top: -1rem")
const headerHeight = showHigherUsed ? "4rem" : "2rem"
return [headerElem, headerHeight]
}
/*
* Visualizes any special case: e.g. not open for a long time, 24/7 open, ...
* */
private static ShowSpecialCase(oh: opening_hours) {
const nextChange = oh.getNextChange()
if (nextChange !== undefined) {
const nowOpen = oh.getState(new Date())
const t = Translations.t.general.opening_hours
const tr: TypedTranslation<{ date }> = nowOpen ? t.open_until : t.closed_until
const date = nextChange.toLocaleString()
return tr.Subs({ date })
}
const comment = oh.getComment() ?? oh.getUnknown()
if (typeof comment === "string") {
return new FixedUiElement(comment)
}
if (oh.getState()) {
return Translations.t.general.opening_hours.open_24_7.Clone()
}
return Translations.t.general.opening_hours.closed_permanently.Clone()
}
}

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Full opening hours visualisations table, dispatches to special cases
*/
import { OH, ToTextualDescription } from "../OpeningHours"
import opening_hours from "opening_hours"
import { ariaLabel } from "../../../Utils/ariaLabel"
import RegularOpeningHoursTable from "./RegularOpeningHoursTable.svelte"
import SpecialCase from "./SpecialCase.svelte"
import { Translation } from "../../i18n/Translation"
import type { OpeningRange } from "../OpeningHours"
export let opening_hours_obj: opening_hours
let applicableWeek = OH.createRangesForApplicableWeek(opening_hours_obj)
let oh = opening_hours_obj
let textual: Translation = ToTextualDescription.createTextualDescriptionFor(oh, applicableWeek.ranges)
let applicableWeekRanges: { ranges: OpeningRange[][]; startingMonday: Date } = OH.createRangesForApplicableWeek(oh)
let ranges = applicableWeekRanges.ranges
let lastMonday = applicableWeekRanges.startingMonday
</script>
<div use:ariaLabel={textual} class="no-weblate">
<!-- First, a small sanity check. The business might be permanently closed, 24/7 opened or be another special case -->
{#if ranges.some((range) => range.length > 0)}
<!-- The normal case: we have items for the coming days -->
<RegularOpeningHoursTable {ranges} rangeStart={lastMonday} oh={opening_hours_obj} />
{:else}
<!-- The special case that range is completely empty -->
<SpecialCase oh={opening_hours_obj} />
{/if}
</div>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import BaseUIElement from "../../BaseUIElement"
import Combine from "../../Base/Combine"
import { FixedUiElement } from "../../Base/FixedUiElement"
/**
* The element showing an "hour" in a bubble, above or below the opening hours table
* Dumbly shows one row of what is given.
*
* Does not include lines
*/
export let availableArea: number
export let changeHours: number[]
export let changeHourText: string[]
export let earliestOpen: number
export let todayChangeMoments: Set<number>
function calcOffset(changeMoment: number) {
return (100 * (changeMoment - earliestOpen)) / availableArea
}
</script>
<div class="w-full absolute block h-8" style="margin-top: -1rem">
{#each changeHours as changeMoment, i}
{#if calcOffset(changeMoment) >= 0 && calcOffset(changeMoment) <= 100}
<div style={`left:${calcOffset(changeMoment)}%; margin-top: 0.1rem`}
class="block absolute top-0 m-0 h-full box-border ohviz-time-indication">
<div
style="left: -50%; word-break: initial;"
class:border-opacity-50={!todayChangeMoments?.has(changeMoment)}
class="relative h-fit bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black">
{changeHourText[i]}
</div>
</div>
{/if}
{/each}
</div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
/**
* A single bar in the Opening-Hours visualisations table, eventually with a text
*/
export let availableArea: number
export let earliestOpen: number
export let latestclose: number
export let range: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
}
export let isWeekstable: boolean
let textToShow = range.comment ?? (isWeekstable ? "" : range.startDate.toLocaleDateString())
let startOfDay: Date = new Date(range.startDate)
startOfDay.setHours(0, 0, 0, 0)
let startpoint = (range.startDate.getTime() - startOfDay.getTime()) / 1000 - earliestOpen
// prettier-ignore
let width = (100 * (range.endDate.getTime() - range.startDate.getTime()) / 1000) / availableArea
let startPercentage = (100 * startpoint) / availableArea
</script>
{#if !range.isOpen && !range.isSpecial}
<div class="ohviz-day-off">{textToShow}</div>
{:else}
<div class="ohviz-range" style={`left:${startPercentage}%; width:${width}%`}>{textToShow}</div>
{/if}

View file

@ -0,0 +1,28 @@
<script lang="ts">/**
* Wrapper around 'OpeningHours' so that the latter can deal with the opening_hours object directly
*/
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import type opening_hours from "opening_hours"
import Translations from "../../i18n/Translations"
import Loading from "../../Base/Loading.svelte"
import Tr from "../../Base/Tr.svelte"
import OpeningHours from "./OpeningHours.svelte"
export let tags: UIEventSource<Record<string, string>>
export let opening_hours_obj: Store<opening_hours | "error">
export let key: string
</script>
{#if $tags._country === undefined}
<Loading>
<Tr t={Translations.t.general.opening_hours.loadingCountry} />
</Loading>
{:else if $opening_hours_obj === undefined}
<div class="alert">No opening hours defined with key {key}</div>
{:else if $opening_hours_obj === "error"}
<Tr cls="alert" t={Translations.t.general.opening_hours.error_loading} />
{:else}
<OpeningHours opening_hours_obj={$opening_hours_obj} />
{/if}

View file

@ -0,0 +1,129 @@
<script lang="ts">/**
* The main visualisation which shows ranges, one or more top/bottom headers, ...
* Does not handle the special cases
*/
import opening_hours from "opening_hours"
import OpeningHoursHeader from "./OpeningHoursHeader.svelte"
import { default as Transl } from "../../Base/Tr.svelte" /* The IDE confuses <tr> (table row) and <Tr> (translation) as they are normally case insensitive -> import under a different name */
import OpeningHoursRangeElement from "./OpeningHoursRangeElement.svelte"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { OH } from "../OpeningHours"
import { Utils } from "../../../Utils"
export let oh: opening_hours
export let ranges: {
isOpen: boolean
isSpecial: boolean
comment: string
startDate: Date
endDate: Date
}[][] // Per weekday
export let rangeStart: Date
let isWeekstable: boolean = oh.isWeekStable()
let today = new Date()
today.setHours(0, 0, 0, 0)
let todayIndex = Math.ceil((today.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24))
let weekdayRanges = ranges.map(ranges => ranges.filter(r => r.startDate.getDay() != 0 && r.startDate.getDay() != 6))
let weekendRanges = ranges.map(ranges => ranges.filter(r => r.startDate.getDay() == 0 || r.startDate.getDay() == 6))
let todayRanges = ranges.map(((r, i) => r.filter(() => i === todayIndex)))
const [changeHours, changeHourText] = OH.allChangeMoments(weekdayRanges)
const [changeHoursWeekend, changeHourTextWeekend] = OH.allChangeMoments(weekendRanges)
const weekdayHeaders: {
changeHours: number[];
changeTexts: string[]
}[] = OH.partitionOHForDistance(changeHours, changeHourText)
const weekendDayHeaders: {
changeHours: number[];
changeTexts: string[]
}[] = OH.partitionOHForDistance(changeHoursWeekend, changeHourTextWeekend)
let allChangeMoments: number[] = Utils.DedupT([...changeHours, ...changeHoursWeekend])
let todayChangeMoments: Set<number> = new Set(OH.allChangeMoments(todayRanges)[0])
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
let earliestOpen = Math.min(8 * 60 * 60, ...changeHours)
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
let latestclose = Math.max(19 * 60 * 60, Math.max(...changeHours) + 30 * 60)
let availableArea = latestclose - earliestOpen
function calcLineOffset(moment: number) {
return 100 * (moment - earliestOpen) / availableArea
}
let weekdays: Translation[] = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday
]
</script>
<div class="w-full h-fit relative">
{#each allChangeMoments as moment}
<div class="w-full absolute h-full">
<div class="w-full h-full flex">
<div style="height: 5rem; width: 5%; min-width: 2.75rem" />
<div class="grow">
<div class="border-x h-full"
style={`width: calc( ${calcLineOffset(moment)}% ); border-color: ${todayChangeMoments.has(moment) ? "#000" : "#bbb"}`} />
</div>
</div>
</div>
{/each}
<table class="w-full" style="border-collapse: collapse; word-break: normal; word-wrap: normal">
{#each weekdayHeaders as weekdayHeader}
<tr>
<td style="width: 5%; min-width: 2.75rem;"></td>
<td class="relative h-8">
<OpeningHoursHeader {earliestOpen} {availableArea} changeHours={weekdayHeader.changeHours}
{todayChangeMoments}
changeHourText={weekdayHeader.changeTexts} />
</td>
</tr>
{/each}
{#each weekdays as weekday, i}
<tr class:interactive={i >= 5}>
<td style="width: 5%">
<Transl t={weekday} />
</td>
<td class="relative p-0 m-0" class:ohviz-today={i===todayIndex}>
<div class="w-full" style="margin-left: -0px">
{#each ranges[i] as range}
<OpeningHoursRangeElement
{availableArea}
{earliestOpen}
{latestclose}
{range}
{isWeekstable}
/>
{/each}
</div>
</td>
</tr>
{/each}
{#each weekendDayHeaders as weekdayHeader}
<tr>
<td style="width: 5%"></td>
<td class="relative h-8">
<OpeningHoursHeader {earliestOpen} {availableArea} changeHours={weekdayHeader.changeHours}
{todayChangeMoments}
changeHourText={weekdayHeader.changeTexts} />
</td>
</tr>
{/each}
</table>
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
/*
* Visualizes any special case: e.g. not open for a long time, 24/7 open, ...
* */
import opening_hours from "opening_hours"
import Tr from "../../Base/Tr.svelte"
import Translations from "../../i18n/Translations"
export let oh: opening_hours
let nextChange = oh.getNextChange()
let nowOpen = oh.getState(new Date())
let comment = oh.getComment() ?? oh.getUnknown()
const t = Translations.t.general.opening_hours
</script>
{#if nextChange !== undefined}
<Tr t={(nowOpen ? t.open_until : t.closed_until).Subs({ date :nextChange.toLocaleString() })} />
{:else if typeof comment === "string"}
<div>{comment}</div>
{:else if oh.getState()}
<Tr t={t.open_24_7} />
{:else}
<Tr t={t.closed_permanently} />
{/if}

View file

@ -0,0 +1,179 @@
<script lang="ts">
import Loading from "../Base/Loading.svelte"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import { Utils } from "../../Utils"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import FilteredLayer from "../../Models/FilteredLayer"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import ShowDataLayer from "../Map/ShowDataLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import SpecialVisualizations from "../SpecialVisualizations"
import type { AutoAction } from "./AutoApplyButtonVis"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
/**
* The ids to handle. Might be data from an external dataset, we cannot assume an OSM-id
*/
export let ids: Store<string[] | undefined>
export let state: SpecialVisualizationState
export let options: {
target_layer_id: string,
targetTagRendering: string,
text: string,
icon: string,
}
let buttonState: UIEventSource<
"idle" | "running" | "done" | { error: string }
> = new UIEventSource<
"idle" | "running" | "done" | { error: string }
>("idle")
let tagRenderingConfig: TagRenderingConfig
let appliedNumberOfFeatures = new UIEventSource<number>(0)
let layer: FilteredLayer = state.layerState.filteredLayers.get(options.target_layer_id)
tagRenderingConfig = layer.layerDef.tagRenderings.find(
(tr) => tr.id === options.targetTagRendering
)
const mlmap = new UIEventSource(undefined)
const mla = new MapLibreAdaptor(mlmap, {
rasterLayer: state.mapProperties.rasterLayer
})
mla.allowZooming.setData(false)
mla.allowMoving.setData(false)
const features = ids.mapD(ids => ids.map((id) =>
state.indexedFeatures.featuresById.data.get(id)
))
new ShowDataLayer(mlmap, {
features: StaticFeatureSource.fromGeojson(features),
zoomToFeatures: true,
layer: layer.layerDef
})
features.addCallbackAndRunD(f => console.log("Features are now", f))
async function applyAllChanges() {
buttonState.set("running")
try {
const target_feature_ids = ids.data
console.log("Applying auto-action on " + target_feature_ids.length + " features")
const appliedOn: string[] = []
for (let i = 0; i < target_feature_ids.length; i++) {
const targetFeatureId = target_feature_ids[i]
console.log("Handling", targetFeatureId)
const feature = state.indexedFeatures.featuresById.data.get(targetFeatureId)
const featureTags = state.featureProperties.getStore(targetFeatureId)
const rendering = tagRenderingConfig.GetRenderValue(featureTags.data).txt
const specialRenderings = Utils.NoNull(
SpecialVisualizations.constructSpecification(rendering)
).filter((v) => typeof v !== "string" && v.func["supportsAutoAction"] === true)
if (specialRenderings.length == 0) {
console.warn(
"AutoApply: feature " +
targetFeatureId +
" got a rendering without supported auto actions:",
rendering
)
}
for (const specialRendering of specialRenderings) {
if (typeof specialRendering === "string") {
continue
}
const action = <AutoAction>specialRendering.func
await action.applyActionOn(
feature,
state,
featureTags,
specialRendering.args
)
}
appliedOn.push(targetFeatureId)
if (i % 50 === 0) {
await state.changes.flushChanges("Auto button: intermediate save")
}
appliedNumberOfFeatures.setData(i + 1)
}
console.log("Flushing changes...")
await state.changes.flushChanges("Auto button: done")
buttonState.setData("done")
console.log(
"Applied changes onto",
appliedOn.length,
"items, unique IDs:",
new Set(appliedOn).size
)
} catch (e) {
console.error("Error while running autoApply: ", e)
buttonState.setData({ error: e })
}
}
const t = Translations.t.general.add.import
</script>
{#if !state.theme.official && !state.featureSwitchIsTesting.data}
<div class="alert">The auto-apply button is only available in official themes (or in testing mode)</div>
<Tr t={t.howToTest} />
{:else if ids === undefined}
<Loading>Gathering which elements support auto-apply...</Loading>
{:else if tagRenderingConfig === undefined}
<div class="alert">Target tagrendering {options.targetTagRendering} not found"</div>
{:else if $ids.length === 0}
<div>No elements found to perform action</div>
{:else if $buttonState.error !== undefined}
<div class="flex flex-col">
<div class="alert">Something went wrong</div>
<div>{$buttonState.error}</div>
</div>
{:else if $buttonState === "done"}
<div class="thanks">All done!</div>
{:else if $buttonState === "running"}
<Loading>
Applying changes, currently at {$appliedNumberOfFeatures} / {$ids.length}
</Loading>
{:else if $buttonState === "idle"}
<div class="flex flex-col">
<button on:click={() => {applyAllChanges()}}>
<img class="h-8 w-8" alt="" src={options.icon} />
{options.text}
</button>
<div class="h-48 w-full">
<MaplibreMap mapProperties={mla} map={mlmap} />
</div>
<div class="subtle link-underline">
The following objects will be updated:
<div class="flex flex-wrap gap-x-2">
{#each $ids as featId}
{#if layer.layerDef.source.geojsonSource === undefined}
<a href={"https://openstreetmap.org/" + featId} target="_blank">{featId}</a>
{:else}
<div>
{featId}
</div>
{/if}
{/each}
</div>
</div>
</div>
{:else}
<div>Not supposed to show this... AutoApplyButton has invalid buttonstate: {$buttonState}</div>
{/if}

View file

@ -1,341 +0,0 @@
import BaseUIElement from "../BaseUIElement"
import { Stores, UIEventSource } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Img from "../Base/Img"
import { FixedUiElement } from "../Base/FixedUiElement"
import Combine from "../Base/Combine"
import Link from "../Base/Link"
import { Utils } from "../../Utils"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import Translations from "../i18n/Translations"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Changes } from "../../Logic/Osm/Changes"
import { UIElement } from "../UIElement"
import FilteredLayer from "../../Models/FilteredLayer"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import Lazy from "../Base/Lazy"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import { IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import ShowDataLayer from "../Map/ShowDataLayer"
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import SpecialVisualizations from "../SpecialVisualizations"
import { Feature } from "geojson"
export interface AutoAction extends SpecialVisualization {
supportsAutoAction: boolean
applyActionOn(
feature: Feature,
state: {
theme: ThemeConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
},
tagSource: UIEventSource<any>,
argument: string[]
): Promise<void>
}
/**
* @deprecated
*/
class ApplyButton extends UIElement {
private readonly icon: string
private readonly text: string
private readonly targetTagRendering: string
private readonly target_layer_id: string
private readonly state: SpecialVisualizationState
private readonly target_feature_ids: string[]
private readonly buttonState = new UIEventSource<
"idle" | "running" | "done" | { error: string }
>("idle")
private readonly layer: FilteredLayer
private readonly tagRenderingConfig: TagRenderingConfig
private readonly appliedNumberOfFeatures = new UIEventSource<number>(0)
constructor(
state: SpecialVisualizationState,
target_feature_ids: string[],
options: {
target_layer_id: string
targetTagRendering: string
text: string
icon: string
}
) {
super()
this.state = state
this.target_feature_ids = target_feature_ids
this.target_layer_id = options.target_layer_id
this.targetTagRendering = options.targetTagRendering
this.text = options.text
this.icon = options.icon
this.layer = this.state.layerState.filteredLayers.get(this.target_layer_id)
this.tagRenderingConfig = this.layer.layerDef.tagRenderings.find(
(tr) => tr.id === this.targetTagRendering
)
}
protected InnerRender(): string | BaseUIElement {
if (this.target_feature_ids.length === 0) {
return new FixedUiElement("No elements found to perform action")
}
if (this.tagRenderingConfig === undefined) {
return new FixedUiElement(
"Target tagrendering " + this.targetTagRendering + " not found"
).SetClass("alert")
}
const self = this
const button = new SubtleButton(new Img(this.icon), this.text).onClick(() => {
this.buttonState.setData("running")
window.setTimeout(() => {
self.Run()
}, 50)
})
const explanation = new Combine([
"The following objects will be updated: ",
...this.target_feature_ids.map(
(id) => new Combine([new Link(id, "https:/ /openstreetmap.org/" + id, true), ", "])
),
]).SetClass("subtle")
const mlmap = new UIEventSource(undefined)
const mla = new MapLibreAdaptor(mlmap, {
rasterLayer: this.state.mapProperties.rasterLayer,
})
mla.allowZooming.setData(false)
mla.allowMoving.setData(false)
const previewMap = new SvelteUIElement(MaplibreMap, {
mapProperties: mla,
map: mlmap,
}).SetClass("h-48")
const features = this.target_feature_ids.map((id) =>
this.state.indexedFeatures.featuresById.data.get(id)
)
new ShowDataLayer(mlmap, {
features: StaticFeatureSource.fromGeojson(features),
zoomToFeatures: true,
layer: this.layer.layerDef,
})
return new VariableUiElement(
this.buttonState.map((st) => {
if (st === "idle") {
return new Combine([button, previewMap, explanation])
}
if (st === "done") {
return new FixedUiElement("All done!").SetClass("thanks")
}
if (st === "running") {
return new Loading(
new VariableUiElement(
this.appliedNumberOfFeatures.map((appliedTo) => {
return (
"Applying changes, currently at " +
appliedTo +
"/" +
this.target_feature_ids.length
)
})
)
)
}
const error = st.error
return new Combine([
new FixedUiElement("Something went wrong...").SetClass("alert"),
new FixedUiElement(error).SetClass("subtle"),
]).SetClass("flex flex-col")
})
)
}
/**
* Actually applies all the changes...
*/
private async Run() {
try {
console.log("Applying auto-action on " + this.target_feature_ids.length + " features")
const appliedOn: string[] = []
for (let i = 0; i < this.target_feature_ids.length; i++) {
const targetFeatureId = this.target_feature_ids[i]
const feature = this.state.indexedFeatures.featuresById.data.get(targetFeatureId)
const featureTags = this.state.featureProperties.getStore(targetFeatureId)
const rendering = this.tagRenderingConfig.GetRenderValue(featureTags.data).txt
const specialRenderings = Utils.NoNull(
SpecialVisualizations.constructSpecification(rendering)
).filter((v) => typeof v !== "string" && v.func["supportsAutoAction"] === true)
if (specialRenderings.length == 0) {
console.warn(
"AutoApply: feature " +
targetFeatureId +
" got a rendering without supported auto actions:",
rendering
)
}
for (const specialRendering of specialRenderings) {
if (typeof specialRendering === "string") {
continue
}
const action = <AutoAction>specialRendering.func
await action.applyActionOn(
feature,
this.state,
featureTags,
specialRendering.args
)
}
appliedOn.push(targetFeatureId)
if (i % 50 === 0) {
await this.state.changes.flushChanges("Auto button: intermediate save")
}
this.appliedNumberOfFeatures.setData(i + 1)
}
console.log("Flushing changes...")
await this.state.changes.flushChanges("Auto button: done")
this.buttonState.setData("done")
console.log(
"Applied changes onto",
appliedOn.length,
"items, unique IDs:",
new Set(appliedOn).size
)
} catch (e) {
console.error("Error while running autoApply: ", e)
this.buttonState.setData({ error: e })
}
}
}
export default class AutoApplyButton implements SpecialVisualization {
public readonly docs: string
public readonly funcName: string = "auto_apply"
public readonly needsUrls = []
public readonly args: {
name: string
defaultValue?: string
doc: string
required?: boolean
}[] = [
{
name: "target_layer",
doc: "The layer that the target features will reside in",
required: true,
},
{
name: "target_feature_ids",
doc: "The key, of which the value contains a list of ids",
required: true,
},
{
name: "tag_rendering_id",
doc: "The ID of the tagRendering containing the autoAction. This tagrendering will be calculated. The embedded actions will be executed",
required: true,
},
{
name: "text",
doc: "The text to show on the button",
required: true,
},
{
name: "icon",
doc: "The icon to show on the button",
defaultValue: "./assets/svg/robot.svg",
},
]
constructor(allSpecialVisualisations: SpecialVisualization[]) {
this.docs = AutoApplyButton.generateDocs(
allSpecialVisualisations
.filter((sv) => sv["supportsAutoAction"] === true)
.map((sv) => sv.funcName)
)
}
private static generateDocs(supportedActions: string[]): string {
return `
A button to run many actions for many features at once.
To effectively use this button, you'll need some ingredients:
1. A target layer with features for which an action is defined in a tag rendering. The following special visualisations support an autoAction: ${supportedActions.join(
", "
)}
2. A host feature to place the auto-action on. This can be a big outline (such as a city). Another good option for this is the layer [current_view](./BuiltinLayers.md#current_view)
3. Then, use a calculated tag on the host feature to determine the overlapping object ids
4. At last, add this component`
}
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[]
): BaseUIElement {
try {
if (!state.theme.official && !state.featureSwitchIsTesting.data) {
const t = Translations.t.general.add.import
return new Combine([
new FixedUiElement(
"The auto-apply button is only available in official themes (or in testing mode)"
).SetClass("alert"),
t.howToTest,
])
}
const target_layer_id = argument[0]
const targetTagRendering = argument[2]
const text = argument[3]
const icon = argument[4]
const options = {
target_layer_id,
targetTagRendering,
text,
icon,
}
return new Lazy(() => {
const to_parse = new UIEventSource<string[]>(undefined)
// Very ugly hack: read the value every 500ms
Stores.Chronic(500, () => to_parse.data === undefined).addCallback(() => {
let applicable = <string | string[]>tagSource.data[argument[1]]
if (typeof applicable === "string") {
applicable = JSON.parse(applicable)
}
to_parse.setData(<string[]>applicable)
})
const loading = new Loading("Gathering which elements support auto-apply... ")
return new VariableUiElement(
Stores.ListStabilized(to_parse).map((ids) => {
if (ids === undefined) {
return loading
}
if (typeof ids === "string") {
ids = JSON.parse(ids)
}
return new ApplyButton(state, ids, options)
})
)
})
} catch (e) {
return new FixedUiElement(
"Could not generate a auto_apply-button for key " + argument[0] + " due to " + e
).SetClass("alert")
}
}
getLayerDependencies(args: string[]): string[] {
return [args[0]]
}
}

View file

@ -0,0 +1,124 @@
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Changes } from "../../Logic/Osm/Changes"
import { SpecialVisualization, SpecialVisualizationState, SpecialVisualizationSvelte } from "../SpecialVisualization"
import { IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import SvelteUIElement from "../Base/SvelteUIElement"
import { Feature } from "geojson"
import AutoApplyButton from "./AutoApplyButton.svelte"
export interface AutoAction extends SpecialVisualization {
supportsAutoAction: boolean
applyActionOn(
feature: Feature,
state: {
theme: ThemeConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
},
tagSource: UIEventSource<Record<string, string>>,
argument: string[]
): Promise<void>
}
export default class AutoApplyButtonVis implements SpecialVisualizationSvelte {
public readonly docs: string
public readonly funcName: string = "auto_apply"
public readonly needsUrls = []
public readonly group = "import"
public readonly args: {
name: string
defaultValue?: string
doc: string
required?: boolean
}[] = [
{
name: "target_layer",
doc: "The layer that the target features will reside in",
required: true,
},
{
name: "target_feature_ids",
doc: "The key, of which the value contains a list of ids",
required: true,
},
{
name: "tag_rendering_id",
doc: "The ID of the tagRendering containing the autoAction. This tagrendering will be calculated. The embedded actions will be executed",
required: true,
},
{
name: "text",
doc: "The text to show on the button",
required: true,
},
{
name: "icon",
doc: "The icon to show on the button",
defaultValue: "./assets/svg/robot.svg",
},
]
constructor(allSpecialVisualisations: SpecialVisualization[]) {
this.docs = AutoApplyButtonVis.generateDocs(
allSpecialVisualisations
.filter((sv) => sv["supportsAutoAction"] === true)
.map((sv) => sv.funcName)
)
}
private static generateDocs(supportedActions: string[]): string {
return `
A button to run many actions for many features at once.
To effectively use this button, you'll need some ingredients:
1. A target layer with features for which an action is defined in a tag rendering. The following special visualisations support an autoAction: ${supportedActions.join(
", "
)}
2. A host feature to place the auto-action on. This can be a big outline (such as a city). Another good option for this is the layer [current_view](./BuiltinLayers.md#current_view)
3. Then, use a calculated tag on the host feature to determine the overlapping object ids
4. At last, add this component`
}
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[]
): SvelteUIElement {
const target_layer_id = argument[0]
const targetTagRendering = argument[2]
const text = argument[3]
const icon = argument[4]
const options = {
target_layer_id,
targetTagRendering,
text,
icon
}
const to_parse: UIEventSource<string[]> = new UIEventSource<string[]>(undefined)
Stores.Chronic(500, () => to_parse.data === undefined).map(() => {
const applicable = <string | string[]>tagSource.data[argument[1]]
if (typeof applicable === "string") {
return <string[]>JSON.parse(applicable)
} else {
return applicable
}
}).addCallbackAndRunD(data => {
to_parse.set(data)
})
const stableIds: Store<string[]> = Stores.ListStabilized(to_parse).map((ids) => {
if (typeof ids === "string") {
ids = JSON.parse(ids)
}
return ids.map(id => id)
})
return new SvelteUIElement(AutoApplyButton, { state, ids: stableIds, options })
}
getLayerDependencies(args: string[]): string[] {
return [args[0]]
}
}

View file

@ -8,7 +8,7 @@ import { Utils } from "../../../Utils"
import SvelteUIElement from "../../Base/SvelteUIElement"
import WayImportFlow from "./WayImportFlow.svelte"
import ConflateImportFlowState from "./ConflateImportFlowState"
import { AutoAction } from "../AutoApplyButton"
import { AutoAction } from "../AutoApplyButtonVis"
import { IndexedFeatureSource } from "../../../Logic/FeatureSource/FeatureSource"
import { Changes } from "../../../Logic/Osm/Changes"
import ThemeConfig from "../../../Models/ThemeConfig/ThemeConfig"

View file

@ -1,5 +1,5 @@
import { SpecialVisualization, SpecialVisualizationState } from "../../SpecialVisualization"
import { AutoAction } from "../AutoApplyButton"
import { AutoAction } from "../AutoApplyButtonVis"
import { Feature, LineString, Polygon } from "geojson"
import { UIEventSource } from "../../../Logic/UIEventSource"
import BaseUIElement from "../../BaseUIElement"

View file

@ -1,28 +1,53 @@
<script lang="ts">
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { Feature } from "geojson"
import Loading from "../Base/Loading.svelte"
import Qr from "../../Utils/Qr"
import { GeoOperations } from "../../Logic/GeoOperations"
import { Utils } from "../../Utils"
const smallSize = 100
const bigSize = 200
let size = new UIEventSource(smallSize)
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
let [lon, lat] = GeoOperations.centerpointCoordinates(feature)
export let extraUrlParams: Record<string, string> = {}
export let sideText: string = undefined
export let sideTextClass: string = undefined
const includeLayout = window.location.pathname.split("/").at(-1).startsWith("theme")
const layout = includeLayout ? "layout=" + state.theme.id + "&" : ""
let id: Store<string> = tags.mapD((tags) => tags.id)
let url = id.mapD(
extraUrlParams["z"] ??= 15
if (includeLayout) {
extraUrlParams["layout"] ??= state.theme.id
}
if (feature) {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
extraUrlParams["lon"] ??= "" + lon
extraUrlParams["lat"] ??= "" + lat
} else if (state?.mapProperties?.location?.data) {
const l: { lon: number; lat: number } = state?.mapProperties?.location?.data
if (l.lon !== 0 && l.lat !== 0) {
extraUrlParams["lon"] ??= "" + l.lon
extraUrlParams["lat"] ??= "" + l.lat
}
}
const params = []
for (const key in extraUrlParams) {
console.log(key, "-->", extraUrlParams[key])
params.push(key + "=" + encodeURIComponent(extraUrlParams[key]))
}
let url = id.map((id) => {
if (id) {
return "#" + id
} else {
return ""
}
}).map(
(id) =>
`${window.location.protocol}//${window.location.host}${window.location.pathname}?${layout}lat=${lat}&lon=${lon}&z=15` +
`#${id}`
`${window.location.protocol}//${window.location.host}${window.location.pathname}?${params.join("&")}${id}`
)
function toggleSize() {
@ -32,15 +57,27 @@
size.setData(smallSize)
}
}
let sideTextSub = (tags ?? new ImmutableStore({})).map(tgs => Utils.SubstituteKeys(sideText, tgs))
</script>
{#if $id.startsWith("node/-")}
{#if $id?.startsWith("node/-")}
<!-- Not yet uploaded, doesn't have a fixed ID -->
<Loading />
{:else}
<img
<div class="flex flex-col my-4">
<div class="flex w-full items-center ">
<img
class="shrink-0"
on:click={() => toggleSize()}
src={new Qr($url).toImageElement($size)}
style={`width: ${$size}px; height: ${$size}px`}
/>
{#if sideText}
<div class={sideTextClass}>{ $sideTextSub}</div>
{/if}
</div>
<a href={$url} target="_blank" class="subtle text-sm ">{$url}</a>
</div>
{/if}

View file

@ -1,4 +1,4 @@
import { AutoAction } from "./AutoApplyButton"
import { AutoAction } from "./AutoApplyButtonVis"
import Translations from "../i18n/Translations"
import { VariableUiElement } from "../Base/VariableUIElement"
import BaseUIElement from "../BaseUIElement"

View file

@ -14,6 +14,9 @@ import LanguageUtils from "../../Utils/LanguageUtils"
import LanguagePicker from "../InputElement/LanguagePicker.svelte"
import PendingChangesIndicator from "../BigComponents/PendingChangesIndicator.svelte"
import { Utils } from "../../Utils"
import { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import QrCode from "../Popup/QrCode.svelte"
export class SettingsVisualisations {
public static initList(): SpecialVisualizationSvelte[] {
@ -146,6 +149,29 @@ export class SettingsVisualisations {
})
},
},
{
funcName: "qr_login",
args: [{
name: "text",
doc: "Extra text on the side of the QR-code"
}, {
name: "textClass",
doc: "CSS class of the the side text"
}],
docs: "A QR-code which shares the current URL and adds the login token. Anyone with this login token will have the same permissions as you currently have. Logging out from this session will also log them out",
group: "settings",
constr(state: SpecialVisualizationState, tags: UIEventSource<Record<string, string>>, argument: string[], feature: Feature, layer: LayerConfig): SvelteUIElement {
const shared_oauth_cookie = state.osmConnection.getToken()
const sideText = argument[0]
const sideTextClass = argument[1] ?? ""
return new SvelteUIElement(QrCode, {
state,
tags,
sideText, sideTextClass,
extraUrlParams: { shared_oauth_cookie }
})
}
},
{
funcName: "logout",

View file

@ -174,7 +174,13 @@ export class UISpecialVisualisations {
},
{
funcName: "qr_code",
args: [],
args: [{
name: "text",
doc: "Extra text on the side of the QR-code"
}, {
name: "textClass",
doc: "CSS class of the the side text"
}],
group: "default",
docs: "Generates a QR-code to share the selected object",
constr(
@ -183,7 +189,9 @@ export class UISpecialVisualisations {
argument: string[],
feature: Feature
): SvelteUIElement {
return new SvelteUIElement(QrCode, { state, tags, feature })
const sideText = argument[0]
const sideTextClass = argument[1] ?? ""
return new SvelteUIElement(QrCode, { state, tags, feature, sideText, sideTextClass })
},
},
{

View file

@ -1,21 +1,16 @@
import { FixedUiElement } from "./Base/FixedUiElement"
import BaseUIElement from "./BaseUIElement"
import { default as FeatureTitle } from "./Popup/Title.svelte"
import {
RenderingSpecification,
SpecialVisualization,
SpecialVisualizationState,
} from "./SpecialVisualization"
import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"
import { HistogramViz } from "./Popup/HistogramViz"
import { UploadToOsmViz } from "./Popup/UploadToOsmViz"
import { MultiApplyViz } from "./Popup/MultiApplyViz"
import { UIEventSource } from "../Logic/UIEventSource"
import { Store, UIEventSource } from "../Logic/UIEventSource"
import AllTagsPanel from "./Popup/AllTagsPanel/AllTagsPanel.svelte"
import { VariableUiElement } from "./Base/VariableUIElement"
import { Translation } from "./i18n/Translation"
import Translations from "./i18n/Translations"
import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import AutoApplyButton from "./Popup/AutoApplyButton"
import AutoApplyButtonVis from "./Popup/AutoApplyButtonVis"
import { LanguageElement } from "./Popup/LanguageElement/LanguageElement"
import SvelteUIElement from "./Base/SvelteUIElement"
import { Feature, LineString } from "geojson"
@ -40,10 +35,16 @@ import { UISpecialVisualisations } from "./SpecialVisualisations/UISpecialVisual
import { SettingsVisualisations } from "./SpecialVisualisations/SettingsVisualisations"
import { ReviewSpecialVisualisations } from "./SpecialVisualisations/ReviewSpecialVisualisations"
import { DataImportSpecialVisualisations } from "./SpecialVisualisations/DataImportSpecialVisualisations"
import TagrenderingManipulationSpecialVisualisations from "./SpecialVisualisations/TagrenderingManipulationSpecialVisualisations"
import { WebAndCommunicationSpecialVisualisations } from "./SpecialVisualisations/WebAndCommunicationSpecialVisualisations"
import TagrenderingManipulationSpecialVisualisations
from "./SpecialVisualisations/TagrenderingManipulationSpecialVisualisations"
import {
WebAndCommunicationSpecialVisualisations
} from "./SpecialVisualisations/WebAndCommunicationSpecialVisualisations"
import ClearGPSHistory from "./BigComponents/ClearGPSHistory.svelte"
import AllFeaturesStatistics from "./Statistics/AllFeaturesStatistics.svelte"
import OpeningHoursWithError from "./OpeningHours/Visualisation/OpeningHoursWithError.svelte"
import { OH } from "./OpeningHours/OpeningHours"
import opening_hours from "opening_hours"
export default class SpecialVisualizations {
public static specialVisualizations: SpecialVisualization[] = SpecialVisualizations.initList()
@ -277,7 +278,12 @@ export default class SpecialVisualizations {
"A normal opening hours table can be invoked with `{opening_hours_table()}`. A table for e.g. conditional access with opening hours can be `{opening_hours_table(access:conditional, no @ &LPARENS, &RPARENS)}`",
constr: (state, tagSource: UIEventSource<any>, args) => {
const [key, prefix, postfix] = args
return new OpeningHoursVisualization(tagSource, key, prefix, postfix)
const openingHoursStore: Store<opening_hours | "error" | undefined> = OH.CreateOhObjectStore(tagSource, key, prefix, postfix)
return new SvelteUIElement(OpeningHoursWithError, {
tags: tagSource,
key,
opening_hours_obj: openingHoursStore
})
},
},
{
@ -609,7 +615,7 @@ export default class SpecialVisualizations {
},
]
specialVisualizations.push(new AutoApplyButton(specialVisualizations))
specialVisualizations.push(new AutoApplyButtonVis(specialVisualizations))
const regex = /[a-zA-Z_]+/
const invalid = specialVisualizations

View file

@ -1,31 +1,19 @@
<script lang="ts">
import { onMount } from "svelte"
export let imageInfo
import { PhotoSphereViewerWrapper } from "./Image/photoSphereViewerWrapper"
import FileSelector from "./Base/FileSelector.svelte"
import ExifReader from "exifreader"
import { UIEventSource } from "../Logic/UIEventSource"
let container: HTMLElement
let txt = new UIEventSource("")
onMount(() => {
console.log("Creating viewer...")
const features = [
{
type: "Feature",
properties: { name: "trap" },
geometry: {
coordinates: [3.742395038713312, 51.05237592785801],
type: "Point",
},
},
]
const viewer = new PhotoSphereViewerWrapper(container, imageInfo, features)
// console.log(panorama, container)
})
async function accept(fileList: FileList) {
const tags = await ExifReader.load(fileList.item(0))
console.log("All tags:", tags)
txt.set(tags.ProjectionType.value)
}
</script>
<head>
<link rel="stylesheet" href="./node_modules/pannellum/build/pannellum.css" />
</head>
<div bind:this={container} class="h-screen w-screen border" style="height: 500px" />
<FileSelector on:submit={fileList => accept(fileList.detail)} accept="image/jpg">Select file</FileSelector>
<b>{$txt}</b>

View file

@ -1,17 +1,5 @@
import { Mapillary } from "./Logic/ImageProviders/Mapillary"
import Test from "./UI/Test.svelte"
const target = document.getElementById("maindiv")
target.innerHTML = ""
/*
let imgId = "8af265ba-3521-4c46-b2a9-c072215c1de3"
let panoramax = new PanoramaxXYZ()
panoramax.imageInfo(imgId).then((imageInfo: ImageData) => {
console.log("IMG INFO: ", imageInfo)
new Test({ target, props: { imageInfo } })
})*/
let pkey = 1199645818028177
new Mapillary().DownloadImageInfo(pkey).then((imageInfo) => {
new Test({ target, props: { imageInfo } })
})
new Test({ target })