forked from MapComplete/MapComplete
Merge branch 'develop'
This commit is contained in:
commit
a1d05400b3
86 changed files with 53743 additions and 51297 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -1919,7 +1919,9 @@
|
|||
},
|
||||
"qr_code": {
|
||||
"render": {
|
||||
"after": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої"
|
||||
"special": {
|
||||
"text": "Відскануйте цей код, щоб відкрити це місце на іншому пристрої"
|
||||
}
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -659,7 +659,7 @@
|
|||
},
|
||||
"stations": {
|
||||
"layers": {
|
||||
"16": {
|
||||
"17": {
|
||||
"name": "Afgangstavler",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -1100,7 +1100,7 @@
|
|||
"stations": {
|
||||
"description": "기차역 보기, 세부사항 편집 또는 추가하기",
|
||||
"layers": {
|
||||
"16": {
|
||||
"17": {
|
||||
"description": "이 역에서 출발하는 기차를 보여주는 안내 전광판",
|
||||
"name": "출발 안내 전광판",
|
||||
"presets": {
|
||||
|
|
|
@ -393,7 +393,7 @@
|
|||
},
|
||||
"stations": {
|
||||
"layers": {
|
||||
"16": {
|
||||
"17": {
|
||||
"tagRenderings": {
|
||||
"type": {
|
||||
"mappings": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -750,7 +750,7 @@
|
|||
},
|
||||
"stations": {
|
||||
"layers": {
|
||||
"16": {
|
||||
"17": {
|
||||
"name": "出發板",
|
||||
"presets": {
|
||||
"0": {
|
||||
|
|
2082
package-lock.json
generated
2082
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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(/</g, "<")?.replace(/>/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(/</g,'<')?.replace(/>/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'
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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)}"}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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. `
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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, [[" ", 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()
|
||||
}
|
||||
}
|
34
src/UI/OpeningHours/Visualisation/OpeningHours.svelte
Normal file
34
src/UI/OpeningHours/Visualisation/OpeningHours.svelte
Normal 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>
|
38
src/UI/OpeningHours/Visualisation/OpeningHoursHeader.svelte
Normal file
38
src/UI/OpeningHours/Visualisation/OpeningHoursHeader.svelte
Normal 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>
|
|
@ -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}
|
|
@ -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}
|
|
@ -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>
|
24
src/UI/OpeningHours/Visualisation/SpecialCase.svelte
Normal file
24
src/UI/OpeningHours/Visualisation/SpecialCase.svelte
Normal 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}
|
179
src/UI/Popup/AutoApplyButton.svelte
Normal file
179
src/UI/Popup/AutoApplyButton.svelte
Normal 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}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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]]
|
||||
}
|
||||
}
|
124
src/UI/Popup/AutoApplyButtonVis.ts
Normal file
124
src/UI/Popup/AutoApplyButtonVis.ts
Normal 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]]
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 })
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
14
src/test.ts
14
src/test.ts
|
@ -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 })
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue