Merge pull request #1731 from pietervdvn/feature/favourites

Feature/favourites
This commit is contained in:
Pieter Vander Vennet 2023-12-05 00:12:29 +01:00 committed by GitHub
commit 4197ec0055
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2715 additions and 1059 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ scratch
assets/editor-layer-index.json assets/editor-layer-index.json
assets/generated/* assets/generated/*
src/assets/generated/ src/assets/generated/
assets/layers/favourite/favourite.json
public/*.webmanifest public/*.webmanifest
/*.html /*.html
!/index.html !/index.html

View file

@ -0,0 +1,29 @@
## Task
Add a (specified) feature as favourite
Find and use the list of favourites
Determine information from this list
Open the popup from this list
## Background info
User has used mapcomplete before
## Results
The user is asked to mark a specified bicycle shop as favourite. They find the big button to mark as favourite at the bottom.
When asked to select another feature, they choose a bicycle pump. When hinted that 'they can add this in a different way', they immediately select the heart title icon.
When asked to open the list of favourites, they open the 'hamburger'-menu. After a bit of looking, they spot the 'Your favourites'-button.
They are a bit confused. The specified bicycle shop is advertised as `building or wall`.
The bicycle pump is shown correctly, the icons are clear. When asked to open the popup for one of them, they click directly on the link.
## Surfaced issues
Due to the way the title is generated, wrong titles appeared: all titles from all layers are mixed and used as title, if the tags match. As such, the title `building or wall` appeared, as it happened to be on top and the bicycle shop had a `building~*` tag.
This was resolved by sorting those titles by popularity. The least occuring tags/titles are placed first, so that the most specific title is shown. This might, in some cases, still result in differing titles (e.g. if something is e.g. both a shop and a café), but this should be exceptional.

View file

@ -29,7 +29,8 @@
"natural=stone" "natural=stone"
] ]
}, },
"climbing=" "climbing=",
"sport!=climbing"
] ]
} }
}, },

View file

@ -0,0 +1,47 @@
{
"#":"no-translations",
"#dont-translate": "*",
"pointRendering": [
{
"location": [
"point",
"centroid"
],
"marker": [
{
"icon": {
"render": "heart",
"mappings": [
{
"if": "_favourite=no",
"then": "heart_outline"
}
]
},
"color": "red"
}
]
}
],
"description": {
"en": "A generic map layer which shows locations that a contributor marked as favourite",
"nl": "Een laag met persoonlijke favourieten"
},
"name": {
"en": "Favourites",
"nl": "Favorieten"
},
"id": "favourite",
"source": "special",
"isShown": "_favourite=yes",
"minzoom": 0,
"title": {
"render": {
"en": "Favourite location",
"nl": "Favoriete locatie"
}
},
"tagRenderings": [
]
}

View file

@ -14,7 +14,8 @@
{ {
"id": "wikipedialink", "id": "wikipedialink",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank' rel='noopener'><img src='./assets/svg/wikipedia.svg' textmode='📖' alt='Wikipedia'/></a>", "render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank' rel='noopener'><img src='./assets/svg/wikipedia.svg' textmode='📖' alt='Wikipedia'/></a>",
"condition": { "condition": {
@ -66,10 +67,23 @@
], ],
"metacondition": "__showTimeSensitiveIcons!=no" "metacondition": "__showTimeSensitiveIcons!=no"
}, },
{
"id": "open_until",
"labels": [
"defaults",
"in_favourite"
],
"#": "Titleicon showing 'open until 17:00'",
"icon": {
"class": "w-20 mx-1 flex items-center"
},
"render": "{opening_hours_state()}"
},
{ {
"id": "phonelink", "id": "phonelink",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"render": "<a href='tel:{phone}'><img textmode='📞' alt='phone' src='./assets/layers/questions/phone.svg'/></a>", "render": "<a href='tel:{phone}'><img textmode='📞' alt='phone' src='./assets/layers/questions/phone.svg'/></a>",
"mappings": [ "mappings": [
@ -89,7 +103,8 @@
{ {
"id": "emaillink", "id": "emaillink",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"render": "<a href='mailto:{email}'><img textmode='✉️' alt='email' src='./assets/layers/questions/send_email.svg'/></a>", "render": "<a href='mailto:{email}'><img textmode='✉️' alt='email' src='./assets/layers/questions/send_email.svg'/></a>",
"mappings": [ "mappings": [
@ -109,7 +124,8 @@
{ {
"id": "websitelink", "id": "websitelink",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"render": "<a href='{website}' target='_blank' rel='noopener'><img textmode='🌐' alt='website' src='./assets/layers/icons/website.svg'/></a>", "render": "<a href='{website}' target='_blank' rel='noopener'><img textmode='🌐' alt='website' src='./assets/layers/icons/website.svg'/></a>",
"condition": "website~*" "condition": "website~*"
@ -117,7 +133,8 @@
{ {
"id": "smokingicon", "id": "smokingicon",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"mappings": [ "mappings": [
{ {
@ -140,6 +157,15 @@
"render": "{share_link()}", "render": "{share_link()}",
"metacondition": "_supports_sharing=yes" "metacondition": "_supports_sharing=yes"
}, },
{
"id": "favourite_title_icon",
"labels": [
"defaults"
],
"render": {
"*": "{favourite_icon()}"
}
},
{ {
"id": "osmlink", "id": "osmlink",
"labels": [ "labels": [
@ -162,7 +188,8 @@
{ {
"id": "dogicon", "id": "dogicon",
"labels": [ "labels": [
"defaults" "defaults",
"in_favourite"
], ],
"mappings": [ "mappings": [
{ {
@ -193,6 +220,13 @@
"class": "w-20 mx-1 flex items-center" "class": "w-20 mx-1 flex items-center"
}, },
"render": "{rating()}" "render": "{rating()}"
},
{
"id": "favourite_icon",
"description": "Only for rendering",
"condition": "_favourite=yes",
"icon": "circle:white;heart:red",
"metacondition": "__showTimeSensitiveIcons!=no"
} }
] ]
} }

62
assets/svg/center.svg Normal file
View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="544.02838"
height="544.02838"
viewBox="0 0 544.02838 544.02838"
version="1.1"
id="svg1"
sodipodi:docname="center.svg"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showguides="true"
inkscape:zoom="0.90326851"
inkscape:cx="393.57068"
inkscape:cy="250.756"
inkscape:window-width="1920"
inkscape:window-height="995"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1">
<sodipodi:guide
position="171.95879,103.32864"
orientation="0,-1"
id="guide4"
inkscape:locked="false" />
<sodipodi:guide
position="271.68286,132.35281"
orientation="1,0"
id="guide5"
inkscape:locked="false" />
</sodipodi:namedview>
<path
d="m 365.63918,111.75001 h -62.375 V 15.9375 c 0,-8.75 -7,-15.9375 -15.625,-15.9375 h -31.1875 c -8.5625,0 -15.625,7.1875 -15.625,15.9375 v 95.81251 h -62.375 l 93.5625,127.75 z"
id="path1"
sodipodi:nodetypes="ccsssscccc" />
<path
d="m 432.27837,365.63919 v -62.375 h 95.8125 c 8.75,0 15.9375,-7 15.9375,-15.625 v -31.1875 c 0,-8.5625 -7.1875,-15.625 -15.9375,-15.625 h -95.8125 v -62.375 l -127.75,93.5625 z"
id="path1-5"
sodipodi:nodetypes="ccsssscccc" />
<path
d="m 178.38918,432.27838 h 62.375 v 95.8125 c 0,8.75 7,15.9375 15.625,15.9375 h 31.1875 c 8.5625,0 15.625,-7.1875 15.625,-15.9375 v -95.8125 h 62.375 l -93.5625,-127.75 z"
id="path2"
sodipodi:nodetypes="ccsssscccc" />
<path
d="m 111.75,178.38919 v 62.375 H 15.9375 c -8.75,0 -15.9375,7 -15.9375,15.625 v 31.1875 c 0,8.5625 7.1875,15.625 15.9375,15.625 H 111.75 v 62.375 l 127.74999,-93.5625 z"
id="path3"
sodipodi:nodetypes="ccsssscccc" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Pieter Vander Vennet
SPDX-License-Identifier: CC0-1.0

View file

@ -153,6 +153,14 @@
"https://commons.wikimedia.org/wiki/File:Camera_font_awesome.svg" "https://commons.wikimedia.org/wiki/File:Camera_font_awesome.svg"
] ]
}, },
{
"path": "center.svg",
"license": "CC0-1.0",
"authors": [
"Pieter Vander Vennet"
],
"sources": []
},
{ {
"path": "checkmark.svg", "path": "checkmark.svg",
"license": "CC0-1.0", "license": "CC0-1.0",

View file

@ -69,10 +69,12 @@
}, },
"+titleIcons": [ "+titleIcons": [
{ {
"id": "climbing_length",
"render": "<div class='flex' style='word-wrap: normal; padding-right: 0.25rem;'><img src='./assets/themes/climbing/height.svg' style='height: 1.75rem;'/>{climbing:length}m</div>", "render": "<div class='flex' style='word-wrap: normal; padding-right: 0.25rem;'><img src='./assets/themes/climbing/height.svg' style='height: 1.75rem;'/>{climbing:length}m</div>",
"condition": "climbing:length~*" "condition": "climbing:length~*"
}, },
{ {
"id": "climbing_bolts",
"mappings": [ "mappings": [
{ {
"if": "__bolts_max~*", "if": "__bolts_max~*",
@ -95,6 +97,7 @@
"render": "<div class='w-8 flex justify-center rounded-right-full climbing-{__difficulty_max:char}'> {__difficulty_max}</div>" "render": "<div class='w-8 flex justify-center rounded-right-full climbing-{__difficulty_max:char}'> {__difficulty_max}</div>"
}, },
{ {
"id": "difficulty",
"render": "<div class='flex justify-center rounded-full pl-1 pr-1 climbing-{__difficulty:char}'> {climbing:grade:french}</div>", "render": "<div class='flex justify-center rounded-full pl-1 pr-1 climbing-{__difficulty:char}'> {climbing:grade:french}</div>",
"condition": "__difficulty:char~*" "condition": "__difficulty:char~*"
} }

View file

@ -166,31 +166,31 @@
{ {
"if": "sidewalk:left|right=yes", "if": "sidewalk:left|right=yes",
"then": { "then": {
"en": "Yes, there is a sidewalk on this side of the road", "en": "There is a sidewalk on this side of the road",
"de": "Ja, es gibt einen Bürgersteig auf dieser Straßenseite", "de": "Es gibt einen Bürgersteig auf dieser Straßenseite",
"da": "Ja, der er et fortov på denne side af vejen", "da": "Der er et fortov på denne side af vejen",
"nl": "Ja, er is een stoep aan deze kant van de weg", "nl": "Er is een stoep aan deze kant van de weg",
"fr": "Oui, il y a un trottoir de ce côté de la route", "fr": "Il y a un trottoir de ce côté de la route",
"ca": "Sí, hi ha una vorera a aquest costat del carrer", "ca": "Hi ha una vorera a aquest costat del carrer",
"es": "Sí, hay una acera en este lado de la calle", "es": "Hay una acera en este lado de la calle",
"cs": "Ano, na této straně silnice je chodník", "cs": "Na této straně silnice je chodník",
"it": "Sì, c'è un marciapiede su questo lato della strada", "it": "C'è un marciapiede su questo lato della strada",
"pl": "Tak, jest chodnik z boku drogi" "pl": "Jest chodnik z boku drogi"
} }
}, },
{ {
"if": "sidewalk:left|right=no", "if": "sidewalk:left|right=no",
"then": { "then": {
"en": "No, there is no sidewalk to walk on", "en": "There is no sidewalk to walk on",
"de": "Nein, es gibt keinen Bürgersteig für Fußgänger", "de": "Es gibt keinen Bürgersteig für Fußgänger",
"da": "Nej, der er ikke noget fortov at gå på", "da": "Der er ikke noget fortov at gå på",
"nl": "Nee, er is geen stoep om op te lopen", "nl": "Er is geen stoep om op te lopen",
"fr": "Non, il n'y a pas de trottoir où marcher", "fr": "Il n'y a pas de trottoir où marcher",
"ca": "No, no hi ha vorera per la que caminar", "ca": "No hi ha vorera per la que caminar",
"es": "No, no hay acera por la que caminar", "es": "No hay acera por la que caminar",
"cs": "Ne, není tu žádný chodník", "cs": "Není tu žádný chodník",
"it": "No, non c'è un marciapiede su cui camminare", "it": "Non c'è un marciapiede su cui camminare",
"pl": "Nie, nie ma chodnika, którym można chodzić" "pl": "Nie ma chodnika, którym można chodzić"
} }
}, },
{ {

View file

@ -50,6 +50,22 @@
"panelIntro": "<h3>Your personal theme</h3>Activate your favourite layers from all the official themes", "panelIntro": "<h3>Your personal theme</h3>Activate your favourite layers from all the official themes",
"reload": "Reload the data" "reload": "Reload the data"
}, },
"favouritePoi": {
"button": {
"isFavourite": "This location is currently marked as favourite and will show up on all thematic maps of MapComplete you visit.",
"markAsFavouriteTitle": "Mark this location as favourite location",
"markDescription": "Add this location to a personal list of your favourites",
"unmark": "Remove from your personal list of favourites",
"unmarkNotDeleted": "This point will not be deleted and still be visible on the appropriate map for you and others"
},
"downloadGeojson": "Download your favourites as geojson",
"downloadGpx": "Download your favourites as GPX",
"intro": "You marked {length} locations as a favourite location.",
"introPrivacy": "This list is only visible to you",
"loginToSeeList": "Login to see the list of locations you marked as favourite",
"tab": "Your favourites",
"title": "Your favourite locations"
},
"flyer": { "flyer": {
"aerial": "This map uses a different background, namely aerial imagery by Agentschap Informatie Vlaanderen", "aerial": "This map uses a different background, namely aerial imagery by Agentschap Informatie Vlaanderen",
"callToAction": "Test it on mapcomplete.org", "callToAction": "Test it on mapcomplete.org",
@ -404,6 +420,7 @@
"key": "Key combination", "key": "Key combination",
"openLayersPanel": "Opens the layers and filters panel", "openLayersPanel": "Opens the layers and filters panel",
"selectAerial": "Set the background to aerial or satellite imagery. Toggles between the two best, available layers", "selectAerial": "Set the background to aerial or satellite imagery. Toggles between the two best, available layers",
"selectFavourites": "Open the favourites page",
"selectItem": "Select the POI which is closest to the map center (crosshair). Only when in keyboard navigation is used", "selectItem": "Select the POI which is closest to the map center (crosshair). Only when in keyboard navigation is used",
"selectMap": "Set the background to a map from external sources. Toggles between the two best, available layers", "selectMap": "Set the background to a map from external sources. Toggles between the two best, available layers",
"selectMapnik": "Set the background layer to OpenStreetMap-carto", "selectMapnik": "Set the background layer to OpenStreetMap-carto",

View file

@ -1,6 +1,6 @@
{ {
"name": "mapcomplete", "name": "mapcomplete",
"version": "0.35.2", "version": "0.36.0",
"repository": "https://github.com/pietervdvn/MapComplete", "repository": "https://github.com/pietervdvn/MapComplete",
"description": "A small website to edit OSM easily", "description": "A small website to edit OSM easily",
"bugs": "https://github.com/pietervdvn/MapComplete/issues", "bugs": "https://github.com/pietervdvn/MapComplete/issues",
@ -65,7 +65,7 @@
"generate:service-worker": "tsc src/service-worker.ts --outFile public/service-worker.js && git_hash=$(git rev-parse HEAD) && sed -i.bak \"s/GITHUB-COMMIT/$git_hash/\" public/service-worker.js && rm public/service-worker.js.bak", "generate:service-worker": "tsc src/service-worker.ts --outFile public/service-worker.js && git_hash=$(git rev-parse HEAD) && sed -i.bak \"s/GITHUB-COMMIT/$git_hash/\" public/service-worker.js && rm public/service-worker.js.bak",
"optimize-images": "cd assets/generated/ && find -name '*.png' -exec optipng '{}' \\; && echo 'PNGs are optimized'", "optimize-images": "cd assets/generated/ && find -name '*.png' -exec optipng '{}' \\; && echo 'PNGs are optimized'",
"generate:stats": "vite-node scripts/GenerateSeries.ts", "generate:stats": "vite-node scripts/GenerateSeries.ts",
"reset:layeroverview": "mkdir -p ./src/assets/generated/layers; echo {\\\"themes\\\":[]} > ./src/assets/generated/known_themes.json && echo {\\\"layers\\\": []} > ./src/assets/generated/known_layers.json && rm -f ./src/assets/generated/layers/*.json && rm -f ./src/assets/generated/themes/*.json && cp ./assets/layers/usersettings/usersettings.json ./src/assets/generated/layers/usersettings.json && npm run generate:layeroverview && vite-node scripts/generateLayerOverview.ts -- --force", "reset:layeroverview": "mkdir -p ./src/assets/generated/layers; echo {\\\"themes\\\":[]} > ./src/assets/generated/known_themes.json && echo {\\\"layers\\\": []} > ./src/assets/generated/known_layers.json && rm -f ./src/assets/generated/layers/*.json && rm -f ./src/assets/generated/themes/*.json && cp ./assets/layers/usersettings/usersettings.json ./src/assets/generated/layers/usersettings.json && echo '{}' > ./src/assets/generated/layers/favourite.json && npm run generate:layeroverview && vite-node scripts/generateLayerOverview.ts -- --force",
"generate": "mkdir -p ./assets/generated; npm run generate:licenses; npm run generate:images; npm run generate:charging-stations; npm run generate:translations; npm run reset:layeroverview; npm run generate:service-worker", "generate": "mkdir -p ./assets/generated; npm run generate:licenses; npm run generate:images; npm run generate:charging-stations; npm run generate:translations; npm run reset:layeroverview; npm run generate:service-worker",
"generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -", "generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -",
"prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh", "prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh",

View file

@ -745,6 +745,10 @@ video {
top: 2.5rem; top: 2.5rem;
} }
.left-1\/4 {
left: 25%;
}
.isolate { .isolate {
isolation: isolate; isolation: isolate;
} }
@ -765,10 +769,6 @@ video {
float: left; float: left;
} }
.m-8 {
margin: 2rem;
}
.m-4 { .m-4 {
margin: 1rem; margin: 1rem;
} }
@ -781,6 +781,10 @@ video {
margin: 0px; margin: 0px;
} }
.m-8 {
margin: 2rem;
}
.m-2 { .m-2 {
margin: 0.5rem; margin: 0.5rem;
} }
@ -841,10 +845,6 @@ video {
margin-right: 3rem; margin-right: 3rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 { .mt-4 {
margin-top: 1rem; margin-top: 1rem;
} }
@ -881,6 +881,10 @@ video {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
@ -1088,6 +1092,10 @@ video {
height: 2.75rem; height: 2.75rem;
} }
.h-5 {
height: 1.25rem;
}
.h-48 { .h-48 {
height: 12rem; height: 12rem;
} }
@ -1198,6 +1206,14 @@ video {
width: 50%; width: 50%;
} }
.w-14 {
width: 3.5rem;
}
.w-5 {
width: 1.25rem;
}
.w-10 { .w-10 {
width: 2.5rem; width: 2.5rem;
} }
@ -1289,6 +1305,10 @@ video {
appearance: none; appearance: none;
} }
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
@ -1441,6 +1461,14 @@ video {
align-self: center; align-self: center;
} }
.justify-self-start {
justify-self: start;
}
.justify-self-end {
justify-self: end;
}
.overflow-auto { .overflow-auto {
overflow: auto; overflow: auto;
} }
@ -2335,6 +2363,16 @@ button.disabled:hover, .button.disabled:hover {
color: unset; color: unset;
} }
button.link {
border: none;
text-decoration: underline;
background-color: unset;
}
button.link:hover {
color:unset;
}
.interactive button.disabled svg path, .interactive .button.disabled svg path { .interactive button.disabled svg path, .interactive .button.disabled svg path {
fill: var(--interactive-foreground) !important; fill: var(--interactive-foreground) !important;
} }

View file

@ -10,7 +10,7 @@ mkdir dist 2> /dev/null
mkdir dist/assets 2> /dev/null mkdir dist/assets 2> /dev/null
export NODE_OPTIONS="--max-old-space-size=8192" export NODE_OPTIONS="--max-old-space-size=16384"
# This script ends every line with '&&' to chain everything. A failure will thus stop the build # This script ends every line with '&&' to chain everything. A failure will thus stop the build
npm run generate:editor-layer-index && npm run generate:editor-layer-index &&
@ -48,7 +48,7 @@ else
exit 1 exit 1
fi fi
export NODE_OPTIONS=--max-old-space-size=7000 export NODE_OPTIONS=--max-old-space-size=16000
which vite which vite
vite build --sourcemap vite build --sourcemap
# Copy the layer files, as these might contain assets (e.g. svgs) # Copy the layer files, as these might contain assets (e.g. svgs)

View file

@ -0,0 +1,304 @@
import Script from "./Script"
import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson"
import { existsSync, readFileSync, writeFileSync } from "fs"
import { AllSharedLayers } from "../src/Customizations/AllSharedLayers"
import { AllKnownLayoutsLazy } from "../src/Customizations/AllKnownLayouts"
import { Utils } from "../src/Utils"
import { AddEditingElements } from "../src/Models/ThemeConfig/Conversion/PrepareLayer"
import {
MappingConfigJson,
QuestionableTagRenderingConfigJson,
} from "../src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import { TagConfigJson } from "../src/Models/ThemeConfig/Json/TagConfigJson"
import { TagUtils } from "../src/Logic/Tags/TagUtils"
import { TagRenderingConfigJson } from "../src/Models/ThemeConfig/Json/TagRenderingConfigJson"
import { Translatable } from "../src/Models/ThemeConfig/Json/Translatable"
export class GenerateFavouritesLayer extends Script {
private readonly layers: LayerConfigJson[] = []
constructor() {
super("Prepares the 'favourites'-layer")
const allThemes = new AllKnownLayoutsLazy(false).values()
for (const theme of allThemes) {
if (theme.hideFromOverview) {
continue
}
for (const layer of theme.layers) {
if (!layer.source) {
continue
}
if (layer.source.geojsonSource) {
continue
}
const layerConfig = AllSharedLayers.getSharedLayersConfigs().get(layer.id)
if (!layerConfig) {
continue
}
this.layers.push(layerConfig)
}
}
}
private sortMappings(mappings: MappingConfigJson[]): MappingConfigJson[] {
const sortedMappings: MappingConfigJson[] = [...mappings]
sortedMappings.sort((a, b) => {
const aTag = TagUtils.Tag(a.if)
const bTag = TagUtils.Tag(b.if)
const aPop = TagUtils.GetPopularity(aTag)
const bPop = TagUtils.GetPopularity(bTag)
return aPop - bPop
})
return sortedMappings
}
private addTagRenderings(proto: LayerConfigJson) {
const blacklistedIds = new Set([
"images",
"questions",
"mapillary",
"leftover-questions",
"last_edit",
"minimap",
"move-button",
"delete-button",
"all-tags",
"all_tags",
...AddEditingElements.addedElements,
])
const generatedTagRenderings: (string | QuestionableTagRenderingConfigJson)[] = []
const trPerId = new Map<
string,
{ conditions: TagConfigJson[]; tr: QuestionableTagRenderingConfigJson }
>()
for (const layerConfig of this.layers) {
if (!layerConfig.tagRenderings) {
continue
}
for (const tagRendering of layerConfig.tagRenderings) {
if (typeof tagRendering === "string") {
if (blacklistedIds.has(tagRendering)) {
continue
}
generatedTagRenderings.push(tagRendering)
blacklistedIds.add(tagRendering)
continue
}
if (tagRendering["builtin"]) {
continue
}
const id = tagRendering.id
if (blacklistedIds.has(id)) {
continue
}
if (trPerId.has(id)) {
const old = trPerId.get(id).tr
// We need to figure out if this was a 'recycled' tag rendering or just happens to have the same id
function isSame(fieldName: string) {
return old[fieldName]?.["en"] === tagRendering[fieldName]?.["en"]
}
const sameQuestion = isSame("question") && isSame("render")
if (!sameQuestion) {
const newTr = <QuestionableTagRenderingConfigJson>Utils.Clone(tagRendering)
newTr.id = layerConfig.id + "_" + newTr.id
if (blacklistedIds.has(newTr.id)) {
continue
}
newTr.condition = {
and: Utils.NoNull([newTr.condition, layerConfig.source["osmTags"]]),
}
generatedTagRenderings.push(newTr)
blacklistedIds.add(newTr.id)
continue
}
}
if (!trPerId.has(id)) {
const newTr = <QuestionableTagRenderingConfigJson>Utils.Clone(tagRendering)
generatedTagRenderings.push(newTr)
trPerId.set(newTr.id, { tr: newTr, conditions: [] })
}
const conditions = trPerId.get(id).conditions
if (tagRendering["condition"]) {
conditions.push({
and: [tagRendering["condition"], layerConfig.source["osmTags"]],
})
} else {
conditions.push(layerConfig.source["osmTags"])
}
}
}
for (const { tr, conditions } of Array.from(trPerId.values())) {
const optimized = TagUtils.optimzeJson({ or: conditions })
if (optimized === true) {
continue
}
if (optimized === false) {
throw "Optimized into 'false', this is weird..."
}
tr.condition = optimized
}
const allTags: QuestionableTagRenderingConfigJson = {
id: "all-tags",
render: { "*": "{all_tags()}" },
metacondition: {
or: [
"__featureSwitchIsDebugging=true",
"mapcomplete-show_tags=full",
"mapcomplete-show_debug=yes",
],
},
}
proto.tagRenderings = [
"images",
...generatedTagRenderings,
...proto.tagRenderings,
"questions",
allTags,
]
}
private addTitleIcons(proto: LayerConfigJson) {
proto.titleIcons = []
const seenTitleIcons = new Set<string>()
for (const layer of this.layers) {
for (const titleIcon of layer.titleIcons) {
if (typeof titleIcon === "string") {
continue
}
if (titleIcon["labels"]?.indexOf("defaults") >= 0) {
continue
}
if (titleIcon.id === "rating") {
if (!seenTitleIcons.has("rating")) {
proto.titleIcons.unshift("icons.rating")
seenTitleIcons.add("rating")
}
continue
}
if (seenTitleIcons.has(titleIcon.id)) {
continue
}
seenTitleIcons.add(titleIcon.id)
console.log("Adding ", titleIcon.id)
proto.titleIcons.push(titleIcon)
}
}
proto.titleIcons.push("icons.defaults")
}
private addTitle(proto: LayerConfigJson) {
let mappings: MappingConfigJson[] = []
for (const layer of this.layers) {
const t = layer.title
const tags: TagConfigJson = layer.source["osmTags"]
if (!t) {
continue
}
if (typeof t === "string") {
mappings.push({ if: tags, then: t })
} else if (t["render"] !== undefined || t["mappings"] !== undefined) {
const tr = <TagRenderingConfigJson>t
for (let i = 0; i < (tr.mappings ?? []).length; i++) {
const mapping = tr.mappings[i]
const optimized = TagUtils.optimzeJson({
and: [mapping.if, tags],
})
if (optimized === false) {
console.warn(
"The following tags yielded 'false':",
JSON.stringify(mapping.if),
JSON.stringify(tags)
)
continue
}
if (optimized === true) {
console.error(
"The following tags yielded 'false':",
JSON.stringify(mapping.if),
JSON.stringify(tags)
)
throw "Tags for title optimized to true"
}
if (!mapping.then) {
throw (
"The title has a missing 'then' for mapping " +
i +
" in layer " +
layer.id
)
}
mappings.push({
if: optimized,
then: mapping.then,
})
}
if (tr.render) {
mappings.push({
if: tags,
then: <Translatable>tr.render,
})
}
} else {
mappings.push({ if: tags, then: <Record<string, string>>t })
}
}
mappings = this.sortMappings(mappings)
if (proto.title["mappings"]) {
mappings.unshift(...proto.title["mappings"])
}
if (proto.title["render"]) {
mappings.push({
if: "id~*",
then: proto.title["render"],
})
}
for (const mapping of mappings) {
const opt = TagUtils.optimzeJson(mapping.if)
if (typeof opt === "boolean") {
continue
}
mapping.if = opt
}
proto.title = {
mappings,
}
}
async main(args: string[]): Promise<void> {
console.log("Generating the favourite layer: stealing _all_ tagRenderings")
const proto = this.readLayer("favourite/favourite.proto.json")
this.addTagRenderings(proto)
this.addTitle(proto)
this.addTitleIcons(proto)
const targetContent = JSON.stringify(proto, null, " ")
const path = "./assets/layers/favourite/favourite.json"
if (existsSync(path)) {
if (readFileSync(path, "utf8") === targetContent) {
return // No need to actually write the file, it is identical
}
}
writeFileSync(path, targetContent)
}
private readLayer(path: string): LayerConfigJson {
try {
return JSON.parse(readFileSync("./assets/layers/" + path, "utf8"))
} catch (e) {
console.error("Could not read ./assets/layers/" + path)
throw e
}
}
}
new GenerateFavouritesLayer().run()

View file

@ -27,7 +27,8 @@ function genImages(dryrun = false) {
"star_outline", "star_outline",
"star", "star",
"osm_logo_us", "osm_logo_us",
"triangle",
"teardrop_with_hole_green",
"SocialImageForeground", "SocialImageForeground",
"wikipedia", "wikipedia",
"Upload", "Upload",

View file

@ -28,6 +28,7 @@ import { QuestionableTagRenderingConfigJson } from "../src/Models/ThemeConfig/Js
import LayerConfig from "../src/Models/ThemeConfig/LayerConfig" import LayerConfig from "../src/Models/ThemeConfig/LayerConfig"
import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig" import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig"
import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext" import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext"
import { GenerateFavouritesLayer } from "./generateFavouritesLayer"
// This scripts scans 'src/assets/layers/*.json' for layer definition files and 'src/assets/themes/*.json' for theme definition files. // This scripts scans 'src/assets/layers/*.json' for layer definition files and 'src/assets/themes/*.json' for theme definition files.
// It spits out an overview of those to be used to load them // It spits out an overview of those to be used to load them
@ -381,16 +382,11 @@ class LayerOverviewUtils extends Script {
forceReload forceReload
) )
writeFileSync(
"./src/assets/generated/known_themes.json",
JSON.stringify({
themes: Array.from(sharedThemes.values()),
})
)
writeFileSync( writeFileSync(
"./src/assets/generated/known_layers.json", "./src/assets/generated/known_layers.json",
JSON.stringify({ layers: Array.from(sharedLayers.values()) }) JSON.stringify({
layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"),
})
) )
const mcChangesPath = "./assets/themes/mapcomplete-changes/mapcomplete-changes.json" const mcChangesPath = "./assets/themes/mapcomplete-changes/mapcomplete-changes.json"
@ -428,6 +424,19 @@ class LayerOverviewUtils extends Script {
ConversionContext.construct([], []) ConversionContext.construct([], [])
) )
for (const [_, theme] of sharedThemes) {
theme.layers = theme.layers.filter(
(l) => Constants.added_by_default.indexOf(l["id"]) < 0
)
}
writeFileSync(
"./src/assets/generated/known_themes.json",
JSON.stringify({
themes: Array.from(sharedThemes.values()),
})
)
const end = new Date() const end = new Date()
const millisNeeded = end.getTime() - start.getTime() const millisNeeded = end.getTime() - start.getTime()
if (AllSharedLayers.getSharedLayersConfigs().size == 0) { if (AllSharedLayers.getSharedLayersConfigs().size == 0) {
@ -791,4 +800,5 @@ class LayerOverviewUtils extends Script {
} }
} }
new GenerateFavouritesLayer().run()
new LayerOverviewUtils().run() new LayerOverviewUtils().run()

View file

@ -4,6 +4,8 @@ import { TagUtils } from "../src/Logic/Tags/TagUtils"
import { Utils } from "../src/Utils" import { Utils } from "../src/Utils"
import { writeFileSync } from "fs" import { writeFileSync } from "fs"
import ScriptUtils from "./ScriptUtils" import ScriptUtils from "./ScriptUtils"
import TagRenderingConfig from "../src/Models/ThemeConfig/TagRenderingConfig"
import { And } from "../src/Logic/Tags/And"
/* Downloads stats on osmSource-tags and keys from tagInfo */ /* Downloads stats on osmSource-tags and keys from tagInfo */
@ -21,7 +23,12 @@ async function main(includeTags = true) {
continue continue
} }
const sources = TagUtils.Tag(layer.source["osmTags"]) const sourcesList = [TagUtils.Tag(layer.source["osmTags"])]
if (layer?.title) {
sourcesList.push(...new TagRenderingConfig(layer.title).usedTags())
}
const sources = new And(sourcesList)
const allKeys = sources.usedKeys() const allKeys = sources.usedKeys()
for (const key of allKeys) { for (const key of allKeys) {
if (!keysAndTags.has(key)) { if (!keysAndTags.has(key)) {
@ -68,6 +75,8 @@ async function main(includeTags = true) {
"./src/assets/key_totals.json", "./src/assets/key_totals.json",
JSON.stringify( JSON.stringify(
{ {
"#": "Generated with generateStats.ts",
date: new Date().toISOString(),
keys: Utils.MapToObj(keyTotal, (t) => t), keys: Utils.MapToObj(keyTotal, (t) => t),
tags: Utils.MapToObj(tagTotal, (v) => Utils.MapToObj(v, (t) => t)), tags: Utils.MapToObj(tagTotal, (v) => Utils.MapToObj(v, (t) => t)),
}, },

View file

@ -1,45 +1,54 @@
import known_themes from "../assets/generated/known_themes.json" import known_themes from "../assets/generated/known_themes.json"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import favourite from "../assets/generated/layers/favourite.json"
import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson" import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson"
import { AllSharedLayers } from "./AllSharedLayers"
import Constants from "../Models/Constants"
/** /**
* Somewhat of a dictionary, which lazily parses needed themes * Somewhat of a dictionary, which lazily parses needed themes
*/ */
export class AllKnownLayoutsLazy { export class AllKnownLayoutsLazy {
private readonly dict: Map<string, { data: LayoutConfig } | { func: () => LayoutConfig }> = private readonly raw: Map<string, LayoutConfigJson> = new Map()
new Map() private readonly dict: Map<string, LayoutConfig> = new Map()
constructor() {
constructor(includeFavouriteLayer = true) {
for (const layoutConfigJson of known_themes["themes"]) { for (const layoutConfigJson of known_themes["themes"]) {
this.dict.set(layoutConfigJson.id, { for (const layerId of Constants.added_by_default) {
func: () => { if (layerId === "favourite" && favourite.id) {
const layout = new LayoutConfig(<LayoutConfigJson>layoutConfigJson, true) if (includeFavouriteLayer) {
for (let i = 0; i < layout.layers.length; i++) { layoutConfigJson.layers.push(favourite)
let layer = layout.layers[i]
if (typeof layer === "string") {
throw "Layer " + layer + " was not expanded in " + layout.id
}
} }
return layout continue
}, }
}) const defaultLayer = AllSharedLayers.getSharedLayersConfigs().get(layerId)
if (defaultLayer === undefined) {
console.error("Could not find builtin layer", layerId)
continue
}
layoutConfigJson.layers.push(defaultLayer)
}
this.raw.set(layoutConfigJson.id, layoutConfigJson)
} }
} }
public getConfig(key: string): LayoutConfigJson {
return this.raw.get(key)
}
public get(key: string): LayoutConfig { public get(key: string): LayoutConfig {
const thunk = this.dict.get(key) const cached = this.dict.get(key)
if (thunk === undefined) { if (cached !== undefined) {
return undefined return cached
} }
if (thunk["data"]) {
return thunk["data"] const layout = new LayoutConfig(this.getConfig(key))
} this.dict.set(key, layout)
const layout = thunk["func"]()
this.dict.set(key, { data: layout })
return layout return layout
} }
public keys() { public keys() {
return this.dict.keys() return this.raw.keys()
} }
public values() { public values() {

View file

@ -6,13 +6,21 @@ import { Changes } from "../Osm/Changes"
import { OsmConnection } from "../Osm/OsmConnection" import { OsmConnection } from "../Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleMetaTagger from "../SimpleMetaTagger" import SimpleMetaTagger from "../SimpleMetaTagger"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { Feature } from "geojson" import { Feature } from "geojson"
import { OsmTags } from "../../Models/OsmFeature" import { OsmTags } from "../../Models/OsmFeature"
import OsmObjectDownloader from "../Osm/OsmObjectDownloader" import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
interface TagsUpdaterState {
selectedElement: UIEventSource<Feature>
featureProperties: { getStore: (id: string) => UIEventSource<Record<string, string>> }
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
indexedFeatures: IndexedFeatureSource
}
export default class SelectedElementTagsUpdater { export default class SelectedElementTagsUpdater {
private static readonly metatags = new Set([ private static readonly metatags = new Set([
"timestamp", "timestamp",
@ -23,38 +31,18 @@ export default class SelectedElementTagsUpdater {
"id", "id",
]) ])
private readonly state: { constructor(state: TagsUpdaterState) {
selectedElement: UIEventSource<Feature>
featureProperties: FeaturePropertiesStore
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
indexedFeatures: IndexedFeatureSource
}
constructor(state: {
selectedElement: UIEventSource<Feature>
featureProperties: FeaturePropertiesStore
indexedFeatures: IndexedFeatureSource
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
}) {
this.state = state
state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => { state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => {
if (!isLoggedIn && !Utils.runningFromConsole) { if (!isLoggedIn && !Utils.runningFromConsole) {
return return
} }
this.installCallback() this.installCallback(state)
// We only have to do this once... // We only have to do this once...
return true return true
}) })
} }
private installCallback() { private installCallback(state: TagsUpdaterState) {
const state = this.state
state.selectedElement.addCallbackAndRunD(async (s) => { state.selectedElement.addCallbackAndRunD(async (s) => {
let id = s.properties?.id let id = s.properties?.id
if (!id) { if (!id) {
@ -94,7 +82,7 @@ export default class SelectedElementTagsUpdater {
oldFeature.geometry = newGeometry oldFeature.geometry = newGeometry
state.featureProperties.getStore(id)?.ping() state.featureProperties.getStore(id)?.ping()
} }
this.applyUpdate(latestTags, id) SelectedElementTagsUpdater.applyUpdate(latestTags, id, state)
console.log("Updated", id) console.log("Updated", id)
} catch (e) { } catch (e) {
@ -102,8 +90,7 @@ export default class SelectedElementTagsUpdater {
} }
}) })
} }
private applyUpdate(latestTags: OsmTags, id: string) { public static applyUpdate(latestTags: OsmTags, id: string, state: TagsUpdaterState) {
const state = this.state
try { try {
const leftRightSensitive = state.layout.isLeftRightSensitive() const leftRightSensitive = state.layout.isLeftRightSensitive()
@ -162,11 +149,16 @@ export default class SelectedElementTagsUpdater {
} }
if (somethingChanged) { if (somethingChanged) {
console.log("Detected upstream changes to the object when opening it, updating...") console.log(
"Detected upstream changes to the object " +
id +
" when opening it, updating..."
)
currentTagsSource.ping() currentTagsSource.ping()
} else { } else {
console.debug("Fetched latest tags for ", id, "but detected no changes") console.debug("Fetched latest tags for ", id, "but detected no changes")
} }
return currentTags
} catch (e) { } catch (e) {
console.error("Updating the tags of selected element ", id, "failed due to", e) console.error("Updating the tags of selected element ", id, "failed due to", e)
} }

View file

@ -0,0 +1,220 @@
import StaticFeatureSource from "./StaticFeatureSource"
import { Feature } from "geojson"
import { Store, Stores, UIEventSource } from "../../UIEventSource"
import { OsmConnection } from "../../Osm/OsmConnection"
import { OsmId } from "../../../Models/OsmFeature"
import { GeoOperations } from "../../GeoOperations"
import { IndexedFeatureSource } from "../FeatureSource"
import OsmObjectDownloader from "../../Osm/OsmObjectDownloader"
import { SpecialVisualizationState } from "../../../UI/SpecialVisualization"
import SelectedElementTagsUpdater from "../../Actors/SelectedElementTagsUpdater"
/**
* Generates the favourites from the preferences and marks them as favourite
*/
export default class FavouritesFeatureSource extends StaticFeatureSource {
public static readonly prefix = "mapcomplete-favourite-"
private readonly _osmConnection: OsmConnection
private readonly _detectedIds: Store<string[]>
/**
* All favourites, including the ones which are filtered away because they are already displayed
*/
public readonly allFavourites: Store<Feature[]>
constructor(state: SpecialVisualizationState) {
const features: Store<Feature[]> = Stores.ListStabilized(
state.osmConnection.preferencesHandler.preferences.map((prefs) => {
const feats: Feature[] = []
const allIds = new Set<string>()
for (const key in prefs) {
if (!key.startsWith(FavouritesFeatureSource.prefix)) {
continue
}
try {
const feat = FavouritesFeatureSource.ExtractFavourite(key, prefs)
if (!feat) {
continue
}
feats.push(feat)
allIds.add(feat.properties.id)
} catch (e) {
console.error("Could not create favourite from", key, "due to", e)
}
}
return feats
})
)
const featuresWithoutAlreadyPresent = features.map((features) =>
features.filter(
(feat) => !state.layout.layers.some((l) => l.id === feat.properties._orig_layer)
)
)
super(featuresWithoutAlreadyPresent)
this.allFavourites = features
this._osmConnection = state.osmConnection
this._detectedIds = Stores.ListStabilized(
features.map((feats) => feats.map((f) => f.properties.id))
)
let allFeatures = state.indexedFeatures
this._detectedIds.addCallbackAndRunD((detected) =>
this.markFeatures(detected, state.featureProperties, allFeatures)
)
// We use the indexedFeatureSource as signal to update
allFeatures.features.map((_) =>
this.markFeatures(this._detectedIds.data, state.featureProperties, allFeatures)
)
this.allFavourites.addCallbackD((features) => {
for (const feature of features) {
this.updateFeature(feature, state.osmObjectDownloader, state)
}
return true
})
}
private async updateFeature(
feature: Feature,
osmObjectDownloader: OsmObjectDownloader,
state: SpecialVisualizationState
) {
const id = feature.properties.id
const upstream = await osmObjectDownloader.DownloadObjectAsync(id)
if (upstream === "deleted") {
this.removeFavourite(feature)
return
}
console.log("Updating metadata due to favourite of", id)
const latestTags = SelectedElementTagsUpdater.applyUpdate(upstream.tags, id, state)
this.updatePropertiesOfFavourite(latestTags)
}
private static ExtractFavourite(key: string, prefs: Record<string, string>): Feature {
const id = key.substring(FavouritesFeatureSource.prefix.length)
const osmId = id.replace("-", "/")
if (id.indexOf("-property-") > 0 || id.endsWith("-layer") || id.endsWith("-theme")) {
return undefined
}
const geometry = <[number, number]>JSON.parse(prefs[key])
const properties = FavouritesFeatureSource.getPropertiesFor(prefs, id)
properties._orig_layer = prefs[FavouritesFeatureSource.prefix + id + "-layer"]
properties._orig_theme = prefs[FavouritesFeatureSource.prefix + id + "-theme"]
properties.id = osmId
properties._favourite = "yes"
return {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates: geometry,
},
}
}
private static getPropertiesFor(
prefs: Record<string, string>,
id: string
): Record<string, string> {
const properties: Record<string, string> = {}
const minLength = FavouritesFeatureSource.prefix.length + id.length + "-property-".length
for (const key in prefs) {
if (key.length < minLength) {
continue
}
if (!key.startsWith(FavouritesFeatureSource.prefix + id)) {
continue
}
const propertyName = key.substring(minLength).replaceAll("__", ":")
properties[propertyName] = prefs[key]
}
return properties
}
/**
* Sets all the (normal) properties as the feature is updated
*/
private updatePropertiesOfFavourite(properties: Record<string, string>) {
const id = properties?.id?.replace("/", "-")
if (!id) {
return
}
console.log("Updating store for", id)
for (const key in properties) {
const pref = this._osmConnection.GetPreference(
"favourite-" + id + "-property-" + key.replaceAll(":", "__")
)
const v = properties[key]
if (v === "" || !v) {
continue
}
pref.setData("" + v)
}
}
public removeFavourite(feature: Feature, tags?: UIEventSource<Record<string, string>>) {
const id = feature.properties.id.replace("/", "-")
const pref = this._osmConnection.GetPreference("favourite-" + id)
this._osmConnection.preferencesHandler.removeAllWithPrefix("mapcomplete-favourite-" + id)
if (tags) {
delete tags.data._favourite
tags.ping()
}
}
public markAsFavourite(
feature: Feature,
layer: string,
theme: string,
tags: UIEventSource<Record<string, string> & { id: OsmId }>,
isFavourite: boolean = true
) {
{
if (!isFavourite) {
this.removeFavourite(feature, tags)
return
}
const id = tags.data.id.replace("/", "-")
const pref = this._osmConnection.GetPreference("favourite-" + id)
const center = GeoOperations.centerpointCoordinates(feature)
pref.setData(JSON.stringify(center))
this._osmConnection.GetPreference("favourite-" + id + "-layer").setData(layer)
this._osmConnection.GetPreference("favourite-" + id + "-theme").setData(theme)
this.updatePropertiesOfFavourite(tags.data)
}
tags.data._favourite = "yes"
tags.ping()
}
private markFeatures(
detected: string[],
featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> },
allFeatures: IndexedFeatureSource
) {
const feature = allFeatures.features.data
for (const f of feature) {
const id = f.properties.id
if (!id) {
continue
}
const store = featureProperties.getStore(id)
const origValue = store.data._favourite
if (detected.indexOf(id) >= 0) {
if (origValue !== "yes") {
store.data._favourite = "yes"
store.ping()
}
} else {
if (origValue) {
store.data._favourite = ""
store.ping()
}
}
}
}
}

View file

@ -6,10 +6,14 @@ import FilteringFeatureSource from "./FilteringFeatureSource"
import LayerState from "../../State/LayerState" import LayerState from "../../State/LayerState"
export default class NearbyFeatureSource implements FeatureSource { export default class NearbyFeatureSource implements FeatureSource {
private readonly _result = new UIEventSource<Feature[]>(undefined)
public readonly features: Store<Feature[]> public readonly features: Store<Feature[]>
private readonly _targetPoint: Store<{ lon: number; lat: number }> private readonly _targetPoint: Store<{ lon: number; lat: number }>
private readonly _numberOfNeededFeatures: number private readonly _numberOfNeededFeatures: number
private readonly _layerState?: LayerState
private readonly _currentZoom: Store<number> private readonly _currentZoom: Store<number>
private readonly _allSources: Store<{ feat: Feature; d: number }[]>[] = []
constructor( constructor(
targetPoint: Store<{ lon: number; lat: number }>, targetPoint: Store<{ lon: number; lat: number }>,
@ -18,43 +22,46 @@ export default class NearbyFeatureSource implements FeatureSource {
layerState?: LayerState, layerState?: LayerState,
currentZoom?: Store<number> currentZoom?: Store<number>
) { ) {
this._layerState = layerState
this._targetPoint = targetPoint.stabilized(100) this._targetPoint = targetPoint.stabilized(100)
this._numberOfNeededFeatures = numberOfNeededFeatures this._numberOfNeededFeatures = numberOfNeededFeatures
this._currentZoom = currentZoom.stabilized(500) this._currentZoom = currentZoom.stabilized(500)
const allSources: Store<{ feat: Feature; d: number }[]>[] = [] this.features = Stores.ListStabilized(this._result)
let minzoom = 999
const result = new UIEventSource<Feature[]>(undefined)
this.features = Stores.ListStabilized(result)
function update() {
let features: { feat: Feature; d: number }[] = []
for (const src of allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (numberOfNeededFeatures !== undefined) {
features = features.slice(0, numberOfNeededFeatures)
}
result.setData(features.map((f) => f.feat))
}
sources.forEach((source, layer) => { sources.forEach((source, layer) => {
const flayer = layerState?.filteredLayers.get(layer) this.registerSource(source, layer)
minzoom = Math.min(minzoom, flayer.layerDef.minzoom)
const calcSource = this.createSource(
source.features,
flayer.layerDef.minzoom,
flayer.isDisplayed
)
calcSource.addCallbackAndRunD((features) => {
update()
})
allSources.push(calcSource)
}) })
} }
public registerSource(source: FeatureSource, layerId: string) {
const flayer = this._layerState?.filteredLayers.get(layerId)
if (!flayer) {
return
}
const calcSource = this.createSource(
source.features,
flayer.layerDef.minzoom,
flayer.isDisplayed
)
calcSource.addCallbackAndRunD((features) => {
this.update()
})
this._allSources.push(calcSource)
}
private update() {
let features: { feat: Feature; d: number }[] = []
for (const src of this._allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (this._numberOfNeededFeatures !== undefined) {
features = features.slice(0, this._numberOfNeededFeatures)
}
this._result.setData(features.map((f) => f.feat))
}
/** /**
* Sorts the given source by distance, slices down to the required number * Sorts the given source by distance, slices down to the required number
*/ */

View file

@ -501,147 +501,43 @@ export class GeoOperations {
) )
} }
public static IdentifieCommonSegments(coordinatess: [number, number][][]): { /**
originalIndex: number * Given a list of points, convert into a GPX-list, e.g. for favourites
segmentShardWith: number[] * @param locations
coordinates: [] * @param title
}[] { */
// An edge. Note that the edge might be reversed to fix the sorting condition: start[0] < end[0] && (start[0] != end[0] || start[0] < end[1]) public static toGpxPoints(
type edge = { locations: Feature<Point, { date?: string; altitude?: number | string }>[],
start: [number, number] title?: string
end: [number, number] ) {
intermediate: [number, number][] title = title?.trim()
members: { index: number; isReversed: boolean }[] if (title === undefined || title === "") {
title = "Created with MapComplete"
} }
title = Utils.EncodeXmlValue(title)
// The strategy: const trackPoints: string[] = []
// 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them for (const l of locations) {
// 2. Join these edges back together - as long as their membership groups are the same let trkpt = ` <wpt lat="${l.geometry.coordinates[1]}" lon="${l.geometry.coordinates[0]}">`
// 3. Convert to results for (const key in l.properties) {
const keyCleaned = key.replaceAll(":", "__")
const allEdgesByKey = new Map<string, edge>() trkpt += ` <${keyCleaned}>${l.properties[key]}</${keyCleaned}>\n`
if (key === "website") {
for (let index = 0; index < coordinatess.length; index++) { trkpt += ` <link>${l.properties[key]}</link>\n`
const coordinates = coordinatess[index]
for (let i = 0; i < coordinates.length - 1; i++) {
const c0 = coordinates[i]
const c1 = coordinates[i + 1]
const isReversed = c0[0] > c1[0] || (c0[0] == c1[0] && c0[1] > c1[1])
let key: string
if (isReversed) {
key = "" + c1 + ";" + c0
} else {
key = "" + c0 + ";" + c1
} }
const member = { index, isReversed }
if (allEdgesByKey.has(key)) {
allEdgesByKey.get(key).members.push(member)
continue
}
let edge: edge
if (!isReversed) {
edge = {
start: c0,
end: c1,
members: [member],
intermediate: [],
}
} else {
edge = {
start: c1,
end: c0,
members: [member],
intermediate: [],
}
}
allEdgesByKey.set(key, edge)
} }
trkpt += " </wpt>\n"
trackPoints.push(trkpt)
} }
const header =
// Lets merge them back together! '<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">'
return (
let didMergeSomething = false header +
let allMergedEdges = Array.from(allEdgesByKey.values()) "\n<name>" +
const allEdgesByStartPoint = new Map<string, edge[]>() title +
for (const edge of allMergedEdges) { "</name>\n<trk><trkseg>\n" +
edge.members.sort((m0, m1) => m0.index - m1.index) trackPoints.join("\n") +
"\n</trkseg></trk></gpx>"
const kstart = edge.start + "" )
if (!allEdgesByStartPoint.has(kstart)) {
allEdgesByStartPoint.set(kstart, [])
}
allEdgesByStartPoint.get(kstart).push(edge)
}
function membersAreCompatible(first: edge, second: edge): boolean {
// There must be an exact match between the members
if (first.members === second.members) {
return true
}
if (first.members.length !== second.members.length) {
return false
}
// Members are sorted and have the same length, so we can check quickly
for (let i = 0; i < first.members.length; i++) {
const m0 = first.members[i]
const m1 = second.members[i]
if (m0.index !== m1.index || m0.isReversed !== m1.isReversed) {
return false
}
}
// Allrigth, they are the same, lets mark this permanently
second.members = first.members
return true
}
do {
didMergeSomething = false
// We use 'allMergedEdges' as our running list
const consumed = new Set<edge>()
for (const edge of allMergedEdges) {
// Can we make this edge longer at the end?
if (consumed.has(edge)) {
continue
}
console.log("Considering edge", edge)
const matchingEndEdges = allEdgesByStartPoint.get(edge.end + "")
console.log("Matchign endpoints:", matchingEndEdges)
if (matchingEndEdges === undefined) {
continue
}
for (let i = 0; i < matchingEndEdges.length; i++) {
const endEdge = matchingEndEdges[i]
if (consumed.has(endEdge)) {
continue
}
if (!membersAreCompatible(edge, endEdge)) {
continue
}
// We can make the segment longer!
didMergeSomething = true
console.log("Merging ", edge, "with ", endEdge)
edge.intermediate.push(edge.end)
edge.end = endEdge.end
consumed.add(endEdge)
matchingEndEdges.splice(i, 1)
break
}
}
allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge))
} while (didMergeSomething)
return []
} }
/** /**

View file

@ -107,7 +107,8 @@ export class ImageUploadManager {
title, title,
description, description,
file, file,
targetKey targetKey,
tags.data["_orig_theme"]
) )
if (!isNaN(Number(featureId))) { if (!isNaN(Number(featureId))) {
// This is a map note // This is a map note
@ -126,7 +127,8 @@ export class ImageUploadManager {
title: string, title: string,
description: string, description: string,
blob: File, blob: File,
targetKey: string | undefined targetKey: string | undefined,
theme?: string
): Promise<LinkImageAction> { ): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId) this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId) const properties = this._featureProperties.getStore(featureId)
@ -148,7 +150,7 @@ export class ImageUploadManager {
console.log("Uploading done, creating action for", featureId) console.log("Uploading done, creating action for", featureId)
key = targetKey ?? key key = targetKey ?? key
const action = new LinkImageAction(featureId, key, value, properties, { const action = new LinkImageAction(featureId, key, value, properties, {
theme: this._layout.id, theme: theme ?? this._layout.id,
changeType: "add-image", changeType: "add-image",
}) })
this.increaseCountFor(this._uploadFinished, featureId) this.increaseCountFor(this._uploadFinished, featureId)

View file

@ -12,6 +12,10 @@ export class OsmPreferences {
"all-osm-preferences", "all-osm-preferences",
{} {}
) )
/**
* A map containing the individual preference sources
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>() private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any private auth: any
private userDetails: UIEventSource<UserDetails> private userDetails: UIEventSource<UserDetails>
@ -21,7 +25,10 @@ export class OsmPreferences {
this.auth = auth this.auth = auth
this.userDetails = osmConnection.userDetails this.userDetails = osmConnection.userDetails
const self = this const self = this
osmConnection.OnLoggedIn(() => self.UpdatePreferences()) osmConnection.OnLoggedIn(() => {
self.UpdatePreferences(true)
return true
})
} }
/** /**
@ -72,11 +79,19 @@ export class OsmPreferences {
let i = 0 let i = 0
while (str !== "") { while (str !== "") {
if (str === undefined || str === "undefined") { if (str === undefined || str === "undefined") {
source.setData(undefined)
throw ( throw (
"Got 'undefined' or a literal string containing 'undefined' for a long preference with name " + "Got 'undefined' or a literal string containing 'undefined' for a long preference with name " +
key key
) )
} }
if (str === "undefined") {
source.setData(undefined)
throw (
"Got a literal string containing 'undefined' for a long preference with name " +
key
)
}
if (i > 100) { if (i > 100) {
throw "This long preference is getting very long... " throw "This long preference is getting very long... "
} }
@ -197,7 +212,7 @@ export class OsmPreferences {
}) })
} }
private UpdatePreferences() { private UpdatePreferences(forceUpdate?: boolean) {
const self = this const self = this
this.auth.xhr( this.auth.xhr(
{ {
@ -210,11 +225,22 @@ export class OsmPreferences {
return return
} }
const prefs = value.getElementsByTagName("preference") const prefs = value.getElementsByTagName("preference")
const seenKeys = new Set<string>()
for (let i = 0; i < prefs.length; i++) { for (let i = 0; i < prefs.length; i++) {
const pref = prefs[i] const pref = prefs[i]
const k = pref.getAttribute("k") const k = pref.getAttribute("k")
const v = pref.getAttribute("v") const v = pref.getAttribute("v")
self.preferences.data[k] = v self.preferences.data[k] = v
seenKeys.add(k)
}
if (forceUpdate) {
for (let key in self.preferences.data) {
if (seenKeys.has(key)) {
continue
}
console.log("Deleting key", key, "as we didn't find it upstream")
delete self.preferences.data[key]
}
} }
// We merge all the preferences: new keys are uploaded // We merge all the preferences: new keys are uploaded
@ -285,4 +311,14 @@ export class OsmPreferences {
} }
) )
} }
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
} }

View file

@ -294,6 +294,9 @@ export default class UserRelatedState {
osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => { osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => {
for (const k in newPrefs) { for (const k in newPrefs) {
const v = newPrefs[k] const v = newPrefs[k]
if (v === "undefined" || !v) {
continue
}
if (k.endsWith("-combined-length")) { if (k.endsWith("-combined-length")) {
const l = Number(v) const l = Number(v)
const key = k.substring(0, k.length - "length".length) const key = k.substring(0, k.length - "length".length)
@ -308,7 +311,6 @@ export default class UserRelatedState {
} }
amendedPrefs.ping() amendedPrefs.ping()
console.log("Amended prefs are:", amendedPrefs.data)
}) })
const translationMode = osmConnection.GetPreference("translation-mode") const translationMode = osmConnection.GetPreference("translation-mode")

View file

@ -3,6 +3,7 @@ import { Or } from "./Or"
import { TagUtils } from "./TagUtils" import { TagUtils } from "./TagUtils"
import { Tag } from "./Tag" import { Tag } from "./Tag"
import { RegexTag } from "./RegexTag" import { RegexTag } from "./RegexTag"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class And extends TagsFilter { export class And extends TagsFilter {
public and: TagsFilter[] public and: TagsFilter[]
@ -72,6 +73,10 @@ export class And extends TagsFilter {
return allChoices return allChoices
} }
asJson(): TagConfigJson {
return { and: this.and.map((a) => a.asJson()) }
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) { asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
return this.and return this.and
.map((t) => { .map((t) => {
@ -228,6 +233,15 @@ export class And extends TagsFilter {
return And.construct(newAnds) return And.construct(newAnds)
} }
/**
* const raw = {"and": [{"or":["leisure=playground","playground!=forest"]},{"or":["leisure=playground","playground!=forest"]}]}
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => {"or":["leisure=playground","playground!=forest"]}
*
* const raw = {"and": [{"and":["advertising=screen"]}, {"and":["advertising~*"]}]}]
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => "advertising=screen"
*/
optimize(): TagsFilter | boolean { optimize(): TagsFilter | boolean {
if (this.and.length === 0) { if (this.and.length === 0) {
return true return true
@ -289,9 +303,17 @@ export class And extends TagsFilter {
optimized.splice(i, 1) optimized.splice(i, 1)
i-- i--
} }
} else if (v !== opt.value) { } else {
// detected an internal conflict if (!v.match(opt.value)) {
return false // We _know_ that for the key of the RegexTag `opt`, the value will be `v`.
// As such, if `opt.value` cannot match `v`, we detected an internal conflict and can fail
return false
} else {
// Another tag already provided a _stricter_ value then this regex, so we can remove this one!
optimized.splice(i, 1)
i--
}
} }
} }
} }
@ -369,10 +391,13 @@ export class And extends TagsFilter {
const elements = containedOr.or.filter( const elements = containedOr.or.filter(
(candidate) => !commonValues.some((cv) => cv.shadows(candidate)) (candidate) => !commonValues.some((cv) => cv.shadows(candidate))
) )
newOrs.push(Or.construct(elements)) if (elements.length > 0) {
newOrs.push(Or.construct(elements))
}
}
if (newOrs.length > 0) {
commonValues.push(And.construct(newOrs))
} }
commonValues.push(And.construct(newOrs))
const result = new Or(commonValues).optimize() const result = new Or(commonValues).optimize()
if (result === false) { if (result === false) {
return false return false

View file

@ -1,18 +1,23 @@
import { TagsFilter } from "./TagsFilter" import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { Tag } from "./Tag"
export default class ComparingTag implements TagsFilter { export default class ComparingTag implements TagsFilter {
private readonly _key: string private readonly _key: string
private readonly _predicate: (value: string) => boolean private readonly _predicate: (value: string) => boolean
private readonly _representation: string private readonly _representation: "<" | ">" | "<=" | ">="
private readonly _boundary: string
constructor( constructor(
key: string, key: string,
predicate: (value: string | undefined) => boolean, predicate: (value: string | undefined) => boolean,
representation: string = "" representation: "<" | ">" | "<=" | ">=",
boundary: string
) { ) {
this._key = key this._key = key
this._predicate = predicate this._predicate = predicate
this._representation = representation this._representation = representation
this._boundary = boundary
} }
asChange(properties: Record<string, string>): { k: string; v: string }[] { asChange(properties: Record<string, string>): { k: string; v: string }[] {
@ -20,15 +25,64 @@ export default class ComparingTag implements TagsFilter {
} }
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) { asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
return this._key + this._representation return this._key + this._representation + this._boundary
} }
asOverpass(): string[] { asOverpass(): string[] {
throw "A comparable tag can not be used as overpass filter" throw "A comparable tag can not be used as overpass filter"
} }
/**
* const tg = new ComparingTag("key", value => (Number(value) < 42), "<", "42")
* const tg0 = new ComparingTag("key", value => (Number(value) < 42), "<", "42")
* const tg1 = new ComparingTag("key", value => (Number(value) <= 42), "<=", "42")
* const against = new ComparingTag("key", value => (Number(value) > 0), ">", "0")
* tg.shadows(new Tag("key", "41")) // => true
* tg.shadows(new Tag("key", "0")) // => true
* tg.shadows(new Tag("key", "43")) // => false
* tg.shadows(new Tag("key", "0")) // => true
* tg.shadows(tg) // => true
* tg.shadows(tg0) // => true
* tg.shadows(against) // => false
* tg1.shadows(tg0) // => true
* tg0.shadows(tg1) // => false
*
*/
shadows(other: TagsFilter): boolean { shadows(other: TagsFilter): boolean {
return other === this if (other === this) {
return true
}
if (other instanceof ComparingTag) {
if (other._key !== this._key) {
return false
}
const selfDesc = this._representation === "<" || this._representation === "<="
const otherDesc = other._representation === "<" || other._representation === "<="
if (selfDesc !== otherDesc) {
return false
}
if (
this._boundary === other._boundary &&
this._representation === other._representation
) {
return true
}
if (this._predicate(other._boundary)) {
return true
}
return false
}
if (other instanceof Tag) {
if (other.key !== this._key) {
return false
}
if (this.matchesProperties({ [other.key]: other.value })) {
return true
}
}
return false
} }
isUsableAsAnswer(): boolean { isUsableAsAnswer(): boolean {
@ -38,7 +92,7 @@ export default class ComparingTag implements TagsFilter {
/** /**
* Checks if the properties match * Checks if the properties match
* *
* const t = new ComparingTag("key", (x => Number(x) < 42)) * const t = new ComparingTag("key", (x => Number(x) < 42), "<", "42")
* t.matchesProperties({key: 42}) // => false * t.matchesProperties({key: 42}) // => false
* t.matchesProperties({key: 41}) // => true * t.matchesProperties({key: 41}) // => true
* t.matchesProperties({key: 0}) // => true * t.matchesProperties({key: 0}) // => true
@ -56,6 +110,10 @@ export default class ComparingTag implements TagsFilter {
return [] return []
} }
asJson(): TagConfigJson {
return this._key + this._representation
}
optimize(): TagsFilter | boolean { optimize(): TagsFilter | boolean {
return this return this
} }

View file

@ -1,6 +1,7 @@
import { TagsFilter } from "./TagsFilter" import { TagsFilter } from "./TagsFilter"
import { TagUtils } from "./TagUtils" import { TagUtils } from "./TagUtils"
import { And } from "./And" import { And } from "./And"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class Or extends TagsFilter { export class Or extends TagsFilter {
public or: TagsFilter[] public or: TagsFilter[]
@ -27,6 +28,10 @@ export class Or extends TagsFilter {
return false return false
} }
asJson(): TagConfigJson {
return { or: this.or.map((o) => o.asJson()) }
}
/** /**
* *
* import {Tag} from "./Tag"; * import {Tag} from "./Tag";
@ -157,6 +162,12 @@ export class Or extends TagsFilter {
return Or.construct(newOrs) return Or.construct(newOrs)
} }
/**
* const raw = {"or": [{"and":["leisure=playground","playground!=forest"]},{"and":["leisure=playground","playground!=forest"]}]}
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => {"and":["leisure=playground","playground!=forest"]}
*
*/
optimize(): TagsFilter | boolean { optimize(): TagsFilter | boolean {
if (this.or.length === 0) { if (this.or.length === 0) {
return false return false
@ -174,9 +185,9 @@ export class Or extends TagsFilter {
const newOrs: TagsFilter[] = [] const newOrs: TagsFilter[] = []
let containedAnds: And[] = [] let containedAnds: And[] = []
for (const tf of optimized) { for (const tf of optimized) {
if (tf instanceof Or) { if (tf["or"]) {
// expand all the nested ors... // expand all the nested ors...
newOrs.push(...tf.or) newOrs.push(...tf["or"])
} else if (tf instanceof And) { } else if (tf instanceof And) {
// partition of all the ands // partition of all the ands
containedAnds.push(tf) containedAnds.push(tf)
@ -191,7 +202,7 @@ export class Or extends TagsFilter {
const cleanedContainedANds: And[] = [] const cleanedContainedANds: And[] = []
outer: for (let containedAnd of containedAnds) { outer: for (let containedAnd of containedAnds) {
for (const known of newOrs) { for (const known of newOrs) {
// input for optimazation: (K=V | (X=Y & K=V)) // input for optimization: (K=V | (X=Y & K=V))
// containedAnd: (X=Y & K=V) // containedAnd: (X=Y & K=V)
// newOrs (and thus known): (K=V) --> false // newOrs (and thus known): (K=V) --> false
const cleaned = containedAnd.removePhraseConsideredKnown(known, false) const cleaned = containedAnd.removePhraseConsideredKnown(known, false)
@ -236,16 +247,21 @@ export class Or extends TagsFilter {
const elements = containedAnd.and.filter( const elements = containedAnd.and.filter(
(candidate) => !commonValues.some((cv) => cv.shadows(candidate)) (candidate) => !commonValues.some((cv) => cv.shadows(candidate))
) )
if (elements.length == 0) {
continue
}
newAnds.push(And.construct(elements)) newAnds.push(And.construct(elements))
} }
if (newAnds.length > 0) {
commonValues.push(Or.construct(newAnds))
}
commonValues.push(Or.construct(newAnds))
const result = new And(commonValues).optimize() const result = new And(commonValues).optimize()
if (result === true) { if (result === true) {
return true return true
} else if (result === false) { } else if (result === false) {
// neutral element: skip // neutral element: skip
} else { } else if (commonValues.length > 0) {
newOrs.push(And.construct(commonValues)) newOrs.push(And.construct(commonValues))
} }
} }

View file

@ -1,5 +1,6 @@
import { Tag } from "./Tag" import { Tag } from "./Tag"
import { TagsFilter } from "./TagsFilter" import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class RegexTag extends TagsFilter { export class RegexTag extends TagsFilter {
public readonly key: RegExp | string public readonly key: RegExp | string
@ -11,6 +12,9 @@ export class RegexTag extends TagsFilter {
super() super()
this.key = key this.key = key
this.value = value this.value = value
if (this.value instanceof RegExp && ("" + this.value).startsWith("^(^(")) {
throw "Detected a duplicate start marker ^(^( in a regextag:" + this.value
}
this.invert = invert this.invert = invert
this.matchesEmpty = RegexTag.doesMatch("", this.value) this.matchesEmpty = RegexTag.doesMatch("", this.value)
} }
@ -41,11 +45,21 @@ export class RegexTag extends TagsFilter {
return possibleRegex.test(fromTag) return possibleRegex.test(fromTag)
} }
private static source(r: string | RegExp) { private static source(r: string | RegExp, includeStartMarker: boolean = true) {
if (typeof r === "string") { if (typeof r === "string") {
return r return r
} }
return r.source if (r === undefined) {
return undefined
}
const src = r.source
if (includeStartMarker) {
return src
}
if (src.startsWith("^(") && src.endsWith(")$")) {
return src.substring(2, src.length - 2)
}
return src
} }
/** /**
@ -82,6 +96,24 @@ export class RegexTag extends TagsFilter {
} }
} }
/**
* import { TagUtils } from "./TagUtils";
*
* const t = TagUtils.Tag("a~b")
* t.asJson() // => "a~b"
*
* const t = TagUtils.Tag("a=")
* t.asJson() // => "a="
*/
asJson(): TagConfigJson {
const v = RegexTag.source(this.value, false)
if (typeof this.key === "string") {
const oper = typeof this.value === "string" ? "=" : "~"
return `${this.key}${this.invert ? "!" : ""}${oper}${v}`
}
return `${this.key.source}${this.invert ? "!" : ""}~~${v}`
}
isUsableAsAnswer(): boolean { isUsableAsAnswer(): boolean {
return false return false
} }
@ -293,7 +325,7 @@ export class RegexTag extends TagsFilter {
if (typeof this.key === "string") { if (typeof this.key === "string") {
return [this.key] return [this.key]
} }
throw "Key cannot be determined as it is a regex" return []
} }
usedTags(): { key: string; value: string }[] { usedTags(): { key: string; value: string }[] {

View file

@ -1,6 +1,7 @@
import { TagsFilter } from "./TagsFilter" import { TagsFilter } from "./TagsFilter"
import { Tag } from "./Tag" import { Tag } from "./Tag"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
/** /**
* The substituting-tag uses the tags of a feature a variables and replaces them. * The substituting-tag uses the tags of a feature a variables and replaces them.
@ -45,6 +46,10 @@ export default class SubstitutingTag implements TagsFilter {
) )
} }
asJson(): TagConfigJson {
return this._key + (this._invert ? "!" : "") + ":=" + this._value
}
asOverpass(): string[] { asOverpass(): string[] {
throw "A variable with substitution can not be used to query overpass" throw "A variable with substitution can not be used to query overpass"
} }

View file

@ -1,5 +1,6 @@
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { TagsFilter } from "./TagsFilter" import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class Tag extends TagsFilter { export class Tag extends TagsFilter {
public key: string public key: string
@ -67,6 +68,10 @@ export class Tag extends TagsFilter {
return [`["${this.key}"="${this.value}"]`] return [`["${this.key}"="${this.value}"]`]
} }
asJson(): TagConfigJson {
return this.key + "=" + this.value
}
/** /**
const t = new Tag("key", "value") const t = new Tag("key", "value")

View file

@ -15,13 +15,14 @@ type Tags = Record<string, string>
export type UploadableTag = Tag | SubstitutingTag | And export type UploadableTag = Tag | SubstitutingTag | And
export class TagUtils { export class TagUtils {
public static readonly comparators: ReadonlyArray<[string, (a: number, b: number) => boolean]> = public static readonly comparators: ReadonlyArray<
[ ["<" | ">" | "<=" | ">=", (a: number, b: number) => boolean]
["<=", (a, b) => a <= b], > = [
[">=", (a, b) => a >= b], ["<=", (a, b) => a <= b],
["<", (a, b) => a < b], [">=", (a, b) => a >= b],
[">", (a, b) => a > b], ["<", (a, b) => a < b],
] [">", (a, b) => a > b],
]
public static modeDocumentation: Record< public static modeDocumentation: Record<
string, string,
{ name: string; docs: string; uploadable?: boolean; overpassSupport: boolean } { name: string; docs: string; uploadable?: boolean; overpassSupport: boolean }
@ -324,6 +325,14 @@ export class TagUtils {
return tags return tags
} }
static optimzeJson(json: TagConfigJson): TagConfigJson | boolean {
const optimized = TagUtils.Tag(json).optimize()
if (optimized === true || optimized === false) {
return optimized
}
return optimized.asJson()
}
/** /**
* Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set. * Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set.
* *
@ -735,11 +744,10 @@ export class TagUtils {
const tag = json as string const tag = json as string
for (const [operator, comparator] of TagUtils.comparators) { for (const [operator, comparator] of TagUtils.comparators) {
if (tag.indexOf(operator) >= 0) { if (tag.indexOf(operator) >= 0) {
const split = Utils.SplitFirst(tag, operator) const split = Utils.SplitFirst(tag, operator).map((v) => v.trim())
let val = Number(split[1])
let val = Number(split[1].trim())
if (isNaN(val)) { if (isNaN(val)) {
val = new Date(split[1].trim()).getTime() val = new Date(split[1]).getTime()
} }
const f = (value: string | number | undefined) => { const f = (value: string | number | undefined) => {
@ -762,7 +770,7 @@ export class TagUtils {
} }
return comparator(b, val) return comparator(b, val)
} }
return new ComparingTag(split[0], f, operator + val) return new ComparingTag(split[0], f, operator, "" + val)
} }
} }
@ -861,6 +869,27 @@ export class TagUtils {
return TagUtils.keyCounts.keys[key] return TagUtils.keyCounts.keys[key]
} }
public static GetPopularity(tag: TagsFilter): number | undefined {
if (tag instanceof And) {
return Math.min(...Utils.NoNull(tag.and.map((t) => TagUtils.GetPopularity(t)))) - 1
}
if (tag instanceof Or) {
return Math.max(...Utils.NoNull(tag.or.map((t) => TagUtils.GetPopularity(t)))) + 1
}
if (tag instanceof Tag) {
return TagUtils.GetCount(tag.key, tag.value)
}
if (tag instanceof RegexTag) {
const key = tag.key
if (key instanceof RegExp || tag.invert || tag.isNegative()) {
return undefined
}
return TagUtils.GetCount(key)
}
return undefined
}
private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number { private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number {
const rta = a instanceof RegexTag const rta = a instanceof RegexTag
const rtb = b instanceof RegexTag const rtb = b instanceof RegexTag

View file

@ -1,3 +1,5 @@
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export abstract class TagsFilter { export abstract class TagsFilter {
abstract asOverpass(): string[] abstract asOverpass(): string[]
@ -17,6 +19,8 @@ export abstract class TagsFilter {
properties: Record<string, string> properties: Record<string, string>
): string ): string
abstract asJson(): TagConfigJson
abstract usedKeys(): string[] abstract usedKeys(): string[]
/** /**

View file

@ -14,7 +14,7 @@ export class MangroveIdentity {
const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined) const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined)
this.keypair = keypairEventSource this.keypair = keypairEventSource
mangroveIdentity.addCallbackAndRunD(async (data) => { mangroveIdentity.addCallbackAndRunD(async (data) => {
if (data === "") { if (!data) {
return return
} }
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data)) const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))

View file

@ -23,6 +23,7 @@ export default class Constants {
"gps_track", "gps_track",
"range", "range",
"last_click", "last_click",
"favourite",
] as const ] as const
/** /**
* Special layers which are not included in a theme by default * Special layers which are not included in a theme by default
@ -131,6 +132,8 @@ export default class Constants {
"clock", "clock",
"invalid", "invalid",
"close", "close",
"heart",
"heart_outline",
] as const ] as const
public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons

View file

@ -24,6 +24,7 @@ export class MenuState {
public static readonly _menuviewTabs = [ public static readonly _menuviewTabs = [
"about", "about",
"settings", "settings",
"favourites",
"community", "community",
"privacy", "privacy",
"advanced", "advanced",

View file

@ -2,6 +2,7 @@ import { LayerConfigJson } from "../Json/LayerConfigJson"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
import { ConversionContext } from "./ConversionContext" import { ConversionContext } from "./ConversionContext"
import { T } from "vitest/dist/types-aac763a5"
export interface DesugaringContext { export interface DesugaringContext {
tagRenderings: Map<string, QuestionableTagRenderingConfigJson> tagRenderings: Map<string, QuestionableTagRenderingConfigJson>
@ -81,18 +82,36 @@ export class Pure<TIn, TOut> extends Conversion<TIn, TOut> {
} }
} }
export class Bypass<T> extends DesugaringStep<T> {
private readonly _applyIf: (t: T) => boolean
private readonly _step: DesugaringStep<T>
constructor(applyIf: (t: T) => boolean, step: DesugaringStep<T>) {
super("Applies the step on the object, if the object satisfies the predicate", [], "Bypass")
this._applyIf = applyIf
this._step = step
}
convert(json: T, context: ConversionContext): T {
if (!this._applyIf(json)) {
return json
}
return this._step.convert(json, context)
}
}
export class Each<X, Y> extends Conversion<X[], Y[]> { export class Each<X, Y> extends Conversion<X[], Y[]> {
private readonly _step: Conversion<X, Y> private readonly _step: Conversion<X, Y>
private readonly _msg: string private readonly _msg: string
private readonly _filter: (x: X) => boolean
constructor(step: Conversion<X, Y>, msg?: string) { constructor(step: Conversion<X, Y>, options?: { msg?: string }) {
super( super(
"Applies the given step on every element of the list", "Applies the given step on every element of the list",
[], [],
"OnEach(" + step.name + ")" "OnEach(" + step.name + ")"
) )
this._step = step this._step = step
this._msg = msg this._msg = options?.msg
} }
convert(values: X[], context: ConversionContext): Y[] { convert(values: X[], context: ConversionContext): Y[] {

View file

@ -85,7 +85,7 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfigJson, L
description: trs(t.description, { title: layer.title.render }), description: trs(t.description, { title: layer.title.render }),
source: { source: {
osmTags: { osmTags: {
and: ["id~*"], and: ["id~[0-9]+", "comment_url~.*notes/[0-9]*g.json"],
}, },
geoJson: geoJson:
"https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed=" + "https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed=" +

View file

@ -10,7 +10,10 @@ import {
SetDefault, SetDefault,
} from "./Conversion" } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson" import {
MinimalTagRenderingConfigJson,
TagRenderingConfigJson,
} from "../Json/TagRenderingConfigJson"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import RewritableConfigJson from "../Json/RewritableConfigJson" import RewritableConfigJson from "../Json/RewritableConfigJson"
import SpecialVisualizations from "../../../UI/SpecialVisualizations" import SpecialVisualizations from "../../../UI/SpecialVisualizations"
@ -563,6 +566,16 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
} }
export class AddEditingElements extends DesugaringStep<LayerConfigJson> { export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
static addedElements: string[] = [
"minimap",
"just_created",
"split_button",
"move_button",
"delete_button",
"last_edit",
"favourite_state",
"all_tags",
]
private readonly _desugaring: DesugaringContext private readonly _desugaring: DesugaringContext
constructor(desugaring: DesugaringContext) { constructor(desugaring: DesugaringContext) {
@ -636,6 +649,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
json.tagRenderings.push(this._desugaring.tagRenderings.get("last_edit")) json.tagRenderings.push(this._desugaring.tagRenderings.get("last_edit"))
} }
if (!usedSpecialFunctions.has("favourite_status")) {
json.tagRenderings.push({
id: "favourite_status",
render: { "*": "{favourite_status()}" },
})
}
if (!usedSpecialFunctions.has("all_tags")) { if (!usedSpecialFunctions.has("all_tags")) {
const trc: QuestionableTagRenderingConfigJson = { const trc: QuestionableTagRenderingConfigJson = {
id: "all-tags", id: "all-tags",
@ -1190,6 +1210,31 @@ class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> {
} }
} }
class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> {
constructor() {
super(
"Adds the favourite heart to the title and the rendering badges",
[],
"AddFavouriteBadges"
)
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
if (json.source === "special" || json.source === "special:library") {
return json
}
const pr = json.pointRendering?.[0]
if (pr) {
pr.iconBadges ??= []
if (!pr.iconBadges.some((ti) => ti.if === "_favourite=yes")) {
pr.iconBadges.push({ if: "_favourite=yes", then: "circle:white;heart:red" })
}
}
return json
}
}
export class AddRatingBadge extends DesugaringStep<LayerConfigJson> { export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
constructor() { constructor() {
super( super(
@ -1203,6 +1248,10 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
if (!json.tagRenderings) { if (!json.tagRenderings) {
return json return json
} }
if (json.titleIcons.some((ti) => ti === "icons.rating" || ti["id"] === "rating")) {
// already added
return json
}
const specialVis: Exclude<RenderingSpecification, string>[] = < const specialVis: Exclude<RenderingSpecification, string>[] = <
Exclude<RenderingSpecification, string>[] Exclude<RenderingSpecification, string>[]
@ -1238,23 +1287,28 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
continue continue
} }
const trId = titleIcon.substring("auto:".length) const trId = titleIcon.substring("auto:".length)
const tr = <QuestionableTagRenderingConfigJson>json.tagRenderings.find((tr) => tr["id"] === trId) const tr = <QuestionableTagRenderingConfigJson>(
json.tagRenderings.find((tr) => tr["id"] === trId)
)
if (tr === undefined) { if (tr === undefined) {
context context.enters("titleIcons", i).err("TagRendering with id " + trId + " not found")
.enters("titleIcons", i)
.err("TagRendering with id " + trId + " not found")
continue continue
} }
const mappings: { if: TagConfigJson, then: string }[] = tr.mappings?.filter(m => m.icon !== undefined) const mappings: { if: TagConfigJson; then: string }[] = tr.mappings
.map(m => { ?.filter((m) => m.icon !== undefined)
.map((m) => {
const path: string = typeof m.icon === "string" ? m.icon : m.icon.path const path: string = typeof m.icon === "string" ? m.icon : m.icon.path
const img = `<img class="m-1 h-6 w-6 low-interaction rounded" src='${path}'/>` const img = `<img class="m-1 h-6 w-6 low-interaction rounded" src='${path}'/>`
return ({ if: m.if, then: img }) return { if: m.if, then: img }
}) })
if (mappings.length === 0) { if (mappings.length === 0) {
context context
.enters("titleIcons", i) .enters("titleIcons", i)
.warn("TagRendering with id " + trId + " does not have any icons, not generating an icon for this") .warn(
"TagRendering with id " +
trId +
" does not have any icons, not generating an icon for this"
)
continue continue
} }
json.titleIcons[i] = <TagRenderingConfigJson>{ json.titleIcons[i] = <TagRenderingConfigJson>{
@ -1292,6 +1346,7 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
), ),
new SetDefault("titleIcons", ["icons.defaults"]), new SetDefault("titleIcons", ["icons.defaults"]),
new AddRatingBadge(), new AddRatingBadge(),
new AddFavouriteBadges(),
new AutoTitleIcon(), new AutoTitleIcon(),
new On( new On(
"titleIcons", "titleIcons",

View file

@ -1,4 +1,4 @@
import { Conversion, DesugaringStep, Each, Fuse, On, Pipe, Pure } from "./Conversion" import { Bypass, Conversion, DesugaringStep, Each, Fuse, On } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import LayerConfig from "../LayerConfig" import LayerConfig from "../LayerConfig"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
@ -11,7 +11,6 @@ import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { ExtractImages } from "./FixImages" import { ExtractImages } from "./FixImages"
import { And } from "../../../Logic/Tags/And" import { And } from "../../../Logic/Tags/And"
import Translations from "../../../UI/i18n/Translations" import Translations from "../../../UI/i18n/Translations"
import Svg from "../../../Svg"
import FilterConfigJson from "../Json/FilterConfigJson" import FilterConfigJson from "../Json/FilterConfigJson"
import DeleteConfig from "../DeleteConfig" import DeleteConfig from "../DeleteConfig"
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
@ -23,7 +22,7 @@ import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import { Translatable } from "../Json/Translatable" import { Translatable } from "../Json/Translatable"
import { ConversionContext } from "./ConversionContext" import { ConversionContext } from "./ConversionContext"
class ValidateLanguageCompleteness extends DesugaringStep<any> { class ValidateLanguageCompleteness extends DesugaringStep<LayoutConfig> {
private readonly _languages: string[] private readonly _languages: string[]
constructor(...languages: string[]) { constructor(...languages: string[]) {
@ -35,7 +34,9 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> {
this._languages = languages ?? ["en"] this._languages = languages ?? ["en"]
} }
convert(obj: any, context: ConversionContext): LayerConfig { convert(obj: LayoutConfig, context: ConversionContext): LayoutConfig {
const origLayers = obj.layers
obj.layers = [...obj.layers].filter((l) => l["id"] !== "favourite")
const translations = Translation.ExtractAllTranslationsFrom(obj) const translations = Translation.ExtractAllTranslationsFrom(obj)
for (const neededLanguage of this._languages) { for (const neededLanguage of this._languages) {
translations translations
@ -57,7 +58,7 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> {
) )
}) })
} }
obj.layers = origLayers
return obj return obj
} }
} }
@ -276,9 +277,9 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
new On( new On(
"layers", "layers",
new Each( new Each(
new Pipe( new Bypass(
new ValidateLayer(undefined, isBuiltin, doesImageExist, false, true), (layer) => Constants.added_by_default.indexOf(<any>layer.id) < 0,
new Pure((x) => x?.raw) new ValidateLayerConfig(undefined, isBuiltin, doesImageExist, false, true)
) )
) )
) )
@ -974,7 +975,7 @@ export class ValidateTagRenderings extends Fuse<TagRenderingConfigJson> {
"Various validation on tagRenderingConfigs", "Various validation on tagRenderingConfigs",
new DetectShadowedMappings(layerConfig), new DetectShadowedMappings(layerConfig),
new DetectConflictingAddExtraTags(), new DetectConflictingAddExtraTags(),
new DetectNonErasedKeysInMappings(), // TODO enable new DetectNonErasedKeysInMappings(),
new DetectMappingsWithImages(doesImageExist), new DetectMappingsWithImages(doesImageExist),
new On("render", new ValidatePossibleLinks()), new On("render", new ValidatePossibleLinks()),
new On("question", new ValidatePossibleLinks()), new On("question", new ValidatePossibleLinks()),
@ -1356,6 +1357,34 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> {
} }
} }
export class ValidateLayerConfig extends DesugaringStep<LayerConfigJson> {
private readonly validator: ValidateLayer
constructor(
path: string,
isBuiltin: boolean,
doesImageExist: DoesImageExist,
studioValidations: boolean = false,
skipDefaultLayers: boolean = false
) {
super("Thin wrapper around 'ValidateLayer", [], "ValidateLayerConfig")
this.validator = new ValidateLayer(
path,
isBuiltin,
doesImageExist,
studioValidations,
skipDefaultLayers
)
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
const prepared = this.validator.convert(json, context)
if (!prepared) {
context.err("Preparing layer failed")
return undefined
}
return prepared?.raw
}
}
export class ValidateLayer extends Conversion< export class ValidateLayer extends Conversion<
LayerConfigJson, LayerConfigJson,
{ parsed: LayerConfig; raw: LayerConfigJson } { parsed: LayerConfig; raw: LayerConfigJson }

View file

@ -245,7 +245,7 @@ export interface LayerConfigJson {
* Type: icon[] * Type: icon[]
* group: infobox * group: infobox
*/ */
titleIcons?: (string | TagRenderingConfigJson)[] | ["defaults"] titleIcons?: (string | (TagRenderingConfigJson & { id?: string }))[] | ["defaults"]
/** /**
* Creates points to render on the map. * Creates points to render on the map.

View file

@ -305,6 +305,9 @@ export default class LayoutConfig implements LayoutInformation {
} }
for (const layer of this.layers) { for (const layer of this.layers) {
if (!layer.source) { if (!layer.source) {
if (layer.isShown?.matchesProperties(tags)) {
return layer
}
continue continue
} }
if (layer.source.osmTags.matchesProperties(tags)) { if (layer.source.osmTags.matchesProperties(tags)) {

View file

@ -16,10 +16,10 @@ import {
} from "./Json/QuestionableTagRenderingConfigJson" } from "./Json/QuestionableTagRenderingConfigJson"
import { FixedUiElement } from "../../UI/Base/FixedUiElement" import { FixedUiElement } from "../../UI/Base/FixedUiElement"
import { Paragraph } from "../../UI/Base/Paragraph" import { Paragraph } from "../../UI/Base/Paragraph"
import Svg from "../../Svg"
import Validators, { ValidatorType } from "../../UI/InputElement/Validators" import Validators, { ValidatorType } from "../../UI/InputElement/Validators"
import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
import Constants from "../Constants" import Constants from "../Constants"
import { RegexTag } from "../../Logic/Tags/RegexTag"
export interface Icon {} export interface Icon {}
@ -800,4 +800,25 @@ export default class TagRenderingConfig {
labels, labels,
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
} }
public usedTags(): TagsFilter[] {
const tags: TagsFilter[] = []
tags.push(
this.metacondition,
this.condition,
this.freeform?.key ? new RegexTag(this.freeform?.key, /.*/) : undefined,
this.invalidValues
)
for (const m of this.mappings ?? []) {
tags.push(m.if)
tags.push(m.priorityIf)
tags.push(...(m.addExtraTags ?? []))
if (typeof m.hideInAnswer !== "boolean") {
tags.push(m.hideInAnswer)
}
tags.push(m.ifnot)
}
return Utils.NoNull(tags)
}
} }

View file

@ -58,6 +58,7 @@ import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLay
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { Imgur } from "../Logic/ImageProviders/Imgur" import { Imgur } from "../Logic/ImageProviders/Imgur"
import NearbyFeatureSource from "../Logic/FeatureSource/Sources/NearbyFeatureSource" import NearbyFeatureSource from "../Logic/FeatureSource/Sources/NearbyFeatureSource"
import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource"
/** /**
* *
@ -96,10 +97,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
readonly indexedFeatures: IndexedFeatureSource & LayoutSource readonly indexedFeatures: IndexedFeatureSource & LayoutSource
readonly currentView: FeatureSource<Feature<Polygon>> readonly currentView: FeatureSource<Feature<Polygon>>
readonly featuresInView: FeatureSource readonly featuresInView: FeatureSource
readonly favourites: FavouritesFeatureSource
/** /**
* Contains a few (<10) >features that are near the center of the map. * Contains a few (<10) >features that are near the center of the map.
*/ */
readonly closestFeatures: FeatureSource readonly closestFeatures: NearbyFeatureSource
readonly newFeatures: WritableFeatureSource readonly newFeatures: WritableFeatureSource
readonly layerState: LayerState readonly layerState: LayerState
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
@ -220,8 +222,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.fullNodeDatabase this.fullNodeDatabase
) )
this.indexedFeatures = layoutSource
let currentViewIndex = 0 let currentViewIndex = 0
const empty = [] const empty = []
this.currentView = new StaticFeatureSource( this.currentView = new StaticFeatureSource(
@ -242,13 +242,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds) this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds)
this.dataIsLoading = layoutSource.isLoading this.dataIsLoading = layoutSource.isLoading
this.indexedFeatures = layoutSource
this.featureProperties = new FeaturePropertiesStore(layoutSource)
const indexedElements = this.indexedFeatures
this.featureProperties = new FeaturePropertiesStore(indexedElements)
this.changes = new Changes( this.changes = new Changes(
{ {
dryRun: this.featureSwitches.featureSwitchIsTesting, dryRun: this.featureSwitches.featureSwitchIsTesting,
allElements: indexedElements, allElements: layoutSource,
featurePropertiesStore: this.featureProperties, featurePropertiesStore: this.featureProperties,
osmConnection: this.osmConnection, osmConnection: this.osmConnection,
historicalUserLocations: this.geolocation.historicalUserLocations, historicalUserLocations: this.geolocation.historicalUserLocations,
@ -258,7 +258,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.historicalUserLocations = this.geolocation.historicalUserLocations this.historicalUserLocations = this.geolocation.historicalUserLocations
this.newFeatures = new NewGeometryFromChangesFeatureSource( this.newFeatures = new NewGeometryFromChangesFeatureSource(
this.changes, this.changes,
indexedElements, layoutSource,
this.featureProperties this.featureProperties
) )
layoutSource.addSource(this.newFeatures) layoutSource.addSource(this.newFeatures)
@ -327,10 +327,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
return sorted return sorted
}) })
const lastClick = (this.lastClickObject = new LastClickFeatureSource( this.lastClickObject = new LastClickFeatureSource(
this.mapProperties.lastClickLocation, this.mapProperties.lastClickLocation,
this.layout this.layout
)) )
this.osmObjectDownloader = new OsmObjectDownloader( this.osmObjectDownloader = new OsmObjectDownloader(
this.osmConnection.Backend(), this.osmConnection.Backend(),
@ -353,6 +353,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.osmConnection, this.osmConnection,
this.changes this.changes
) )
this.favourites = new FavouritesFeatureSource(this)
this.initActors() this.initActors()
this.drawSpecialLayers() this.drawSpecialLayers()
@ -456,6 +457,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
* @private * @private
*/ */
private selectClosestAtCenter(i: number = 0) { private selectClosestAtCenter(i: number = 0) {
this.mapProperties.lastKeyNavigation.setData(Date.now() / 1000)
const toSelect = this.closestFeatures.features.data[i] const toSelect = this.closestFeatures.features.data[i]
if (!toSelect) { if (!toSelect) {
return return
@ -465,6 +467,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.selectedLayer.setData(layer) this.selectedLayer.setData(layer)
this.selectedElement.setData(toSelect) this.selectedElement.setData(toSelect)
} }
private initHotkeys() { private initHotkeys() {
Hotkeys.RegisterHotkey( Hotkeys.RegisterHotkey(
{ nomod: "Escape", onUp: true }, { nomod: "Escape", onUp: true },
@ -476,6 +479,15 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
) )
Hotkeys.RegisterHotkey(
{ nomod: "f" },
Translations.t.hotkeyDocumentation.selectFavourites,
() => {
this.guistate.menuViewTab.setData("favourites")
this.guistate.menuIsOpened.setData(true)
}
)
this.mapProperties.lastKeyNavigation.addCallbackAndRunD((_) => { this.mapProperties.lastKeyNavigation.addCallbackAndRunD((_) => {
Hotkeys.RegisterHotkey( Hotkeys.RegisterHotkey(
{ {
@ -561,46 +573,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
}) })
} }
private addLastClick(last_click: LastClickFeatureSource) {
// The last_click gets a _very_ special treatment as it interacts with various parts
this.featureProperties.trackFeatureSource(last_click)
this.indexedFeatures.addSource(last_click)
last_click.features.addCallbackAndRunD((features) => {
if (this.selectedLayer.data?.id === "last_click") {
// The last-click location moved, but we have selected the last click of the previous location
// So, we update _after_ clearing the selection to make sure no stray data is sticking around
this.selectedElement.setData(undefined)
this.selectedElement.setData(features[0])
}
})
new ShowDataLayer(this.map, {
features: new FilteringFeatureSource(this.newPointDialog, last_click),
doShowLayer: this.featureSwitches.featureSwitchEnableLogin,
layer: this.newPointDialog.layerDef,
selectedElement: this.selectedElement,
selectedLayer: this.selectedLayer,
metaTags: this.userRelatedState.preferencesAsTags,
onClick: (feature: Feature) => {
if (this.mapProperties.zoom.data < Constants.minZoomLevelToAddNewPoint) {
this.map.data.flyTo({
zoom: Constants.minZoomLevelToAddNewPoint,
center: this.mapProperties.lastClickLocation.data,
})
return
}
// We first clear the selection to make sure no weird state is around
this.selectedLayer.setData(undefined)
this.selectedElement.setData(undefined)
this.selectedElement.setData(feature)
this.selectedLayer.setData(this.newPointDialog.layerDef)
},
})
}
/** /**
* Add the special layers to the map * Add the special layers to the map
*/ */
@ -627,7 +599,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
) )
), ),
current_view: this.currentView, current_view: this.currentView,
favourite: this.favourites,
} }
this.closestFeatures.registerSource(specialLayers.favourite, "favourite")
if (this.layout?.lockLocation) { if (this.layout?.lockLocation) {
const bbox = new BBox(this.layout.lockLocation) const bbox = new BBox(this.layout.lockLocation)
this.mapProperties.maxbounds.setData(bbox) this.mapProperties.maxbounds.setData(bbox)
@ -654,21 +629,23 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range") const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range")
const rangeIsDisplayed = rangeFLayer?.isDisplayed const rangeIsDisplayed = rangeFLayer?.isDisplayed
if ( if (
!QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef)) !QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef))
) { ) {
rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true) rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true)
} }
// enumarate all 'normal' layers and match them with the appropriate 'special' layer - if applicable
this.layerState.filteredLayers.forEach((flayer) => { this.layerState.filteredLayers.forEach((flayer) => {
const id = flayer.layerDef.id const id = flayer.layerDef.id
const features: FeatureSource = specialLayers[id] const features: FeatureSource = specialLayers[id]
if (features === undefined) { if (features === undefined) {
return return
} }
if (id === "favourite") {
console.log("Matching special layer", id, flayer)
}
this.featureProperties.trackFeatureSource(features) this.featureProperties.trackFeatureSource(features)
new ShowDataLayer(this.map, { new ShowDataLayer(this.map, {

View file

@ -11,7 +11,7 @@
<button class={clss} on:click={() => osmConnection.AttemptLogin()}> <button class={clss} on:click={() => osmConnection.AttemptLogin()}>
<ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} /> <ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} />
<slot name="message"> <slot>
<Tr t={Translations.t.general.loginWithOpenStreetMap} /> <Tr t={Translations.t.general.loginWithOpenStreetMap} />
</slot> </slot>
</button> </button>

View file

@ -4,12 +4,12 @@
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Tr from "./Tr.svelte" import Tr from "./Tr.svelte"
export let osmConnection: OsmConnection export let osmConnection: OsmConnection;
</script> </script>
<button <button
on:click={() => { on:click={() => {
state.osmConnection.LogOut() osmConnection.LogOut()
}} }}
> >
<Logout class="h-6 w-6" /> <Logout class="h-6 w-6" />

View file

@ -9,7 +9,7 @@
const uiElem = typeof construct === "function" ? construct() : construct const uiElem = typeof construct === "function" ? construct() : construct
html = uiElem?.ConstructElement() html = uiElem?.ConstructElement()
if (html !== undefined) { if (html !== undefined) {
elem.replaceWith(html) elem?.replaceWith(html)
} }
}) })

View file

@ -121,9 +121,9 @@ export default class UploadTraceToOsmUI extends LoginToggle {
]).SetClass("flex p-2 rounded-xl border-2 subtle-border items-center"), ]).SetClass("flex p-2 rounded-xl border-2 subtle-border items-center"),
new Toggle( new Toggle(
confirmPanel, confirmPanel,
new SubtleButton(new SvelteUIElement(Upload), t.title).onClick(() => new SubtleButton(new SvelteUIElement(Upload), t.title)
clicked.setData(true) .onClick(() => clicked.setData(true))
), .SetClass("w-full"),
clicked clicked
), ),
uploadFinished uploadFinished

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization";
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte";
import type { Feature } from "geojson";
import { ImmutableStore } from "../../Logic/UIEventSource";
import { GeoOperations } from "../../Logic/GeoOperations";
import Center from "../../assets/svg/Center.svelte";
export let feature: Feature;
let properties: Record<string, string> = feature.properties;
export let state: SpecialVisualizationState;
let tags = state.featureProperties.getStore(properties.id) ?? new ImmutableStore(properties);
const favLayer = state.layerState.filteredLayers.get("favourite");
const favConfig = favLayer.layerDef;
const titleConfig = favConfig.title;
function center() {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature);
state.mapProperties.location.setData(
{ lon, lat }
);
const z = state.mapProperties.zoom.data
state.mapProperties.zoom.setData( Math.min(17, Math.max(12, z )) )
state.guistate.menuIsOpened.setData(false);
}
function select() {
state.selectedLayer.setData(favConfig);
state.selectedElement.setData(feature);
center();
}
const coord = GeoOperations.centerpointCoordinates(feature);
const distance = state.mapProperties.location.stabilized(500).mapD(({ lon, lat }) => {
let meters = Math.round(GeoOperations.distanceBetween(coord, [lon, lat]));
if (meters < 1000) {
return meters + "m";
}
meters = Math.round(meters / 100);
const kmStr = "" + meters;
return kmStr.substring(0, kmStr.length - 1) + "." + kmStr.substring(kmStr.length - 1) + "km";
});
const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"];
</script>
<div class="px-1 my-1 border-2 border-dashed border-gray-300 rounded grid grid-cols-2 items-center no-weblate">
<button class="cursor-pointer ml-1 m-0 link justify-self-start" on:click={() => select()}>
<TagRenderingAnswer config={titleConfig} extraClasses="underline" layer={favConfig} selectedElement={feature}
{tags} />
</button>
<div class="flex items-center justify-self-end title-icons links-as-button gap-x-0.5 p-1 pt-0.5 sm:pt-1">
{#each favConfig.titleIcons as titleIconConfig}
{#if (titleIconBlacklist.indexOf(titleIconConfig.id) < 0) && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties({ ...properties, ...state.userRelatedState.preferencesAsTags.data }) ?? true) && titleIconConfig.IsKnown(properties)}
<div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}>
<TagRenderingAnswer
config={titleIconConfig}
{tags}
selectedElement={feature}
{state}
layer={favLayer}
extraClasses="h-full justify-center"
/>
</div>
{/if}
{/each}
<button class="p-1" on:click={() => center()}>
<Center class="w-6 h-6" />
</button>
<div class="w-14">
{$distance}
</div>
</div>
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization";
import FavouriteSummary from "./FavouriteSummary.svelte";
import Translations from "../i18n/Translations";
import Tr from "../Base/Tr.svelte";
import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid";
import { Utils } from "../../Utils";
import { GeoOperations } from "../../Logic/GeoOperations";
import type { Feature, LineString, Point } from "geojson";
import LoginToggle from "../Base/LoginToggle.svelte";
import LoginButton from "../Base/LoginButton.svelte";
/**
* A panel showing all your favourites
*/
export let state: SpecialVisualizationState;
let favourites = state.favourites.allFavourites;
function downloadGeojson() {
const contents = { features: favourites.data, type: "FeatureCollection" };
Utils.offerContentsAsDownloadableFile(
JSON.stringify(contents),
"mapcomplete-favourites-" + (new Date().toISOString()) + ".geojson",
{
mimetype: "application/vnd.geo+json"
}
);
}
function downloadGPX() {
const gpx = GeoOperations.toGpxPoints(<Feature<Point>>favourites.data, "MapComplete favourites");
Utils.offerContentsAsDownloadableFile(gpx,
"mapcomplete-favourites-" + (new Date().toISOString()) + ".gpx",
{
mimetype: "{gpx=application/gpx+xml}"
});
}
</script>
<LoginToggle {state}>
<div slot="not-logged-in">
<LoginButton osmConnection={state.osmConnection}>
<Tr t={Translations.t.favouritePoi.loginToSeeList}/>
</LoginButton>
</div>
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
<Tr t={Translations.t.favouritePoi.intro.Subs({length: $favourites?.length ?? 0})} />
<Tr t={Translations.t.favouritePoi.privacy} />
{#each $favourites as feature (feature.properties.id)}
<FavouriteSummary {feature} {state} />
{/each}
<div class="mt-8">
<button class="flex p-2" on:click={() => downloadGeojson()}>
<DownloadIcon class="h-6 w-6" />
<Tr t={Translations.t.favouritePoi.downloadGeojson} />
</button>
<button class="flex p-2" on:click={() => downloadGPX()}>
<DownloadIcon class="h-6 w-6" />
<Tr t={Translations.t.favouritePoi.downloadGpx} />
</button>
</div>
</div>
</LoginToggle>

View file

@ -1,27 +1,7 @@
<script lang="ts"> <script lang="ts">
import { IconConfig } from "../../Models/ThemeConfig/PointRenderingConfig" import { IconConfig } from "../../Models/ThemeConfig/PointRenderingConfig";
import { Store } from "../../Logic/UIEventSource" import { Store } from "../../Logic/UIEventSource";
import Pin from "../../assets/svg/Pin.svelte" import Icon from "./Icon.svelte";
import Square from "../../assets/svg/Square.svelte"
import Circle from "../../assets/svg/Circle.svelte"
import Checkmark from "../../assets/svg/Checkmark.svelte"
import Clock from "../../assets/svg/Clock.svelte"
import Close from "../../assets/svg/Close.svelte"
import Crosshair from "../../assets/svg/Crosshair.svelte"
import Help from "../../assets/svg/Help.svelte"
import Home from "../../assets/svg/Home.svelte"
import Invalid from "../../assets/svg/Invalid.svelte"
import Location from "../../assets/svg/Location.svelte"
import Location_empty from "../../assets/svg/Location_empty.svelte"
import Location_locked from "../../assets/svg/Location_locked.svelte"
import Note from "../../assets/svg/Note.svelte"
import Resolved from "../../assets/svg/Resolved.svelte"
import Ring from "../../assets/svg/Ring.svelte"
import Scissors from "../../assets/svg/Scissors.svelte"
import Teardrop from "../../assets/svg/Teardrop.svelte"
import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte"
import Triangle from "../../assets/svg/Triangle.svelte"
import Icon from "./Icon.svelte"
/** /**
* Renders a single icon. * Renders a single icon.

View file

@ -7,9 +7,9 @@
/** /**
* Renders a 'marker', which consists of multiple 'icons' * Renders a 'marker', which consists of multiple 'icons'
*/ */
export let marker: IconConfig[] = config?.marker export let marker: IconConfig[] = config?.marker;
export let tags: Store<Record<string, string>> export let tags: Store<Record<string, string>>
export let rotation: TagRenderingConfig export let rotation: TagRenderingConfig = undefined;
let _rotation = rotation let _rotation = rotation
? tags.map((tags) => rotation.GetRenderValue(tags).Subs(tags).txt) ? tags.map((tags) => rotation.GetRenderValue(tags).Subs(tags).txt)
: new ImmutableStore(0) : new ImmutableStore(0)
@ -18,7 +18,9 @@
{#if marker && marker} {#if marker && marker}
<div class="relative h-full w-full" style={`transform: rotate(${$_rotation})`}> <div class="relative h-full w-full" style={`transform: rotate(${$_rotation})`}>
{#each marker as icon} {#each marker as icon}
<DynamicIcon {icon} {tags} /> <div class="absolute top-0 left-0 h-full w-full">
<DynamicIcon {icon} {tags} />
</div>
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -1,27 +1,29 @@
<script lang="ts"> <script lang="ts">
import Pin from "../../assets/svg/Pin.svelte" import Pin from "../../assets/svg/Pin.svelte";
import Square from "../../assets/svg/Square.svelte" import Square from "../../assets/svg/Square.svelte";
import Circle from "../../assets/svg/Circle.svelte" import Circle from "../../assets/svg/Circle.svelte";
import Checkmark from "../../assets/svg/Checkmark.svelte" import Checkmark from "../../assets/svg/Checkmark.svelte";
import Clock from "../../assets/svg/Clock.svelte" import Clock from "../../assets/svg/Clock.svelte";
import Close from "../../assets/svg/Close.svelte" import Close from "../../assets/svg/Close.svelte";
import Crosshair from "../../assets/svg/Crosshair.svelte" import Crosshair from "../../assets/svg/Crosshair.svelte";
import Help from "../../assets/svg/Help.svelte" import Help from "../../assets/svg/Help.svelte";
import Home from "../../assets/svg/Home.svelte" import Home from "../../assets/svg/Home.svelte";
import Invalid from "../../assets/svg/Invalid.svelte" import Invalid from "../../assets/svg/Invalid.svelte";
import Location from "../../assets/svg/Location.svelte" import Location from "../../assets/svg/Location.svelte";
import Location_empty from "../../assets/svg/Location_empty.svelte" import Location_empty from "../../assets/svg/Location_empty.svelte";
import Location_locked from "../../assets/svg/Location_locked.svelte" import Location_locked from "../../assets/svg/Location_locked.svelte";
import Note from "../../assets/svg/Note.svelte" import Note from "../../assets/svg/Note.svelte";
import Resolved from "../../assets/svg/Resolved.svelte" import Resolved from "../../assets/svg/Resolved.svelte";
import Ring from "../../assets/svg/Ring.svelte" import Ring from "../../assets/svg/Ring.svelte";
import Scissors from "../../assets/svg/Scissors.svelte" import Scissors from "../../assets/svg/Scissors.svelte";
import Teardrop from "../../assets/svg/Teardrop.svelte" import Teardrop from "../../assets/svg/Teardrop.svelte";
import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte" import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte";
import Triangle from "../../assets/svg/Triangle.svelte" import Triangle from "../../assets/svg/Triangle.svelte";
import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte" import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte";
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte" import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte";
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte" import Gps_arrow from "../../assets/svg/Gps_arrow.svelte";
import { HeartIcon } from "@babeard/svelte-heroicons/solid";
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline";
/** /**
* Renders a single icon. * Renders a single icon.
@ -29,68 +31,72 @@
* Icons -placed on top of each other- form a 'Marker' together * Icons -placed on top of each other- form a 'Marker' together
*/ */
export let icon: string | undefined export let icon: string | undefined;
export let color: string | undefined export let color: string | undefined = undefined
export let clss: string | undefined = undefined
</script> </script>
{#if icon} {#if icon}
<div class="absolute top-0 left-0 h-full w-full">
{#if icon === "pin"} {#if icon === "pin"}
<Pin {color} /> <Pin {color} class={clss}/>
{:else if icon === "square"} {:else if icon === "square"}
<Square {color} /> <Square {color} class={clss}/>
{:else if icon === "circle"} {:else if icon === "circle"}
<Circle {color} /> <Circle {color} class={clss}/>
{:else if icon === "checkmark"} {:else if icon === "checkmark"}
<Checkmark {color} /> <Checkmark {color} class={clss}/>
{:else if icon === "clock"} {:else if icon === "clock"}
<Clock {color} /> <Clock {color} class={clss}/>
{:else if icon === "close"} {:else if icon === "close"}
<Close {color} /> <Close {color} class={clss}/>
{:else if icon === "crosshair"} {:else if icon === "crosshair"}
<Crosshair {color} /> <Crosshair {color} class={clss}/>
{:else if icon === "help"} {:else if icon === "help"}
<Help {color} /> <Help {color} class={clss}/>
{:else if icon === "home"} {:else if icon === "home"}
<Home {color} /> <Home {color} class={clss}/>
{:else if icon === "invalid"} {:else if icon === "invalid"}
<Invalid {color} /> <Invalid {color} class={clss}/>
{:else if icon === "location"} {:else if icon === "location"}
<Location {color} /> <Location {color} class={clss}/>
{:else if icon === "location_empty"} {:else if icon === "location_empty"}
<Location_empty {color} /> <Location_empty {color} class={clss}/>
{:else if icon === "location_locked"} {:else if icon === "location_locked"}
<Location_locked {color} /> <Location_locked {color} class={clss}/>
{:else if icon === "note"} {:else if icon === "note"}
<Note {color} /> <Note {color} class={clss}/>
{:else if icon === "resolved"} {:else if icon === "resolved"}
<Resolved {color} /> <Resolved {color} class={clss}/>
{:else if icon === "ring"} {:else if icon === "ring"}
<Ring {color} /> <Ring {color} class={clss}/>
{:else if icon === "scissors"} {:else if icon === "scissors"}
<Scissors {color} /> <Scissors {color} class={clss}/>
{:else if icon === "teardrop"} {:else if icon === "teardrop"}
<Teardrop {color} /> <Teardrop {color} class={clss}/>
{:else if icon === "teardrop_with_hole_green"} {:else if icon === "teardrop_with_hole_green"}
<Teardrop_with_hole_green {color} /> <Teardrop_with_hole_green {color} class={clss}/>
{:else if icon === "triangle"} {:else if icon === "triangle"}
<Triangle {color} /> <Triangle {color} class={clss}/>
{:else if icon === "brick_wall_square"} {:else if icon === "brick_wall_square"}
<Brick_wall_square {color} /> <Brick_wall_square {color} class={clss}/>
{:else if icon === "brick_wall_round"} {:else if icon === "brick_wall_round"}
<Brick_wall_round {color} /> <Brick_wall_round {color} class={clss}/>
{:else if icon === "gps_arrow"} {:else if icon === "gps_arrow"}
<Gps_arrow {color} /> <Gps_arrow {color} class={clss}/>
{:else if icon === "checkmark"} {:else if icon === "checkmark"}
<Checkmark {color} /> <Checkmark {color} class={clss}/>
{:else if icon === "help"} {:else if icon === "help"}
<Help {color} /> <Help {color} class={clss}/>
{:else if icon === "close"} {:else if icon === "close"}
<Close {color} /> <Close {color} class={clss}/>
{:else if icon === "invalid"} {:else if icon === "invalid"}
<Invalid {color} /> <Invalid {color} class={clss}/>
{:else if icon === "heart"}
<HeartIcon class={clss}/>
{:else if icon === "heart_outline"}
<HeartOutlineIcon class={clss}/>
{:else} {:else}
<img class="h-full w-full" src={icon} /> <img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true"
alt="" />
{/if} {/if}
</div>
{/if} {/if}

View file

@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import Icon from "./Icon.svelte" import Icon from "./Icon.svelte";
/** /**
* Renders a 'marker', which consists of multiple 'icons' * Renders a 'marker', which consists of multiple 'icons'
*/ */
export let icons: { icon: string; color: string }[] export let icons: { icon: string; color: string }[];
</script> </script>
{#if icons !== undefined && icons.length > 0} {#if icons !== undefined && icons.length > 0}
<div class="relative h-full w-full"> <div class="relative h-full w-full">
{#each icons as icon} {#each icons as icon}
<Icon icon={icon.icon} color={icon.color} /> <div class="absolute top-0 left-0 h-full w-full">
<Icon icon={icon.icon} color={icon.color} />
</div>
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -12,11 +12,9 @@ import { Feature, Point } from "geojson"
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import * as range_layer from "../../../assets/layers/range/range.json" import * as range_layer from "../../../assets/layers/range/range.json"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter"
import FilteredLayer from "../../Models/FilteredLayer" import FilteredLayer from "../../Models/FilteredLayer"
import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource"
import { CLIENT_RENEG_LIMIT } from "tls"
class PointRenderingLayer { class PointRenderingLayer {
private readonly _config: PointRenderingConfig private readonly _config: PointRenderingConfig

View file

@ -0,0 +1,50 @@
<script lang="ts">/**
* Simple visualisation which shows when the POI opens/closes next.
*/
import type { SpecialVisualizationState } from "../SpecialVisualization";
import { Store, Stores } from "../../Logic/UIEventSource";
import { OH } from "./OpeningHours";
import opening_hours from "opening_hours";
import Clock from "../../assets/svg/Clock.svelte";
import { Utils } from "../../Utils";
import Circle from "../../assets/svg/Circle.svelte";
import Ring from "../../assets/svg/Ring.svelte";
import { twMerge } from "tailwind-merge";
export let state: SpecialVisualizationState;
export let tags: Store<Record<string, string>>;
export let keyToUse: string = "opening_hours";
export let prefix: string = undefined;
export let postfix: string = undefined;
let oh: Store<opening_hours | "error" | undefined> = OH.CreateOhObjectStore(tags, keyToUse, prefix, postfix);
let currentState = oh.mapD(oh => typeof oh === "string" ? undefined : oh.getState());
let tomorrow = new Date();
tomorrow.setTime(tomorrow.getTime() + 24 * 60 * 60 * 1000);
let nextChange = oh
.mapD(oh => typeof oh === "string" ? undefined : oh.getNextChange(new Date(), tomorrow), [Stores.Chronic(5 * 60 * 1000)])
.mapD(date => Utils.TwoDigits(date.getHours()) + ":" + Utils.TwoDigits(date.getMinutes()));
let size = nextChange.map(change => change === undefined ? "absolute h-7 w-7" : "absolute h-5 w-5 top-0 left-1/4");
</script>
{#if $currentState !== undefined}
<div class="relative h-8 w-8">
{#if $currentState === true}
<Ring class={$size} color="#0f0" style="z-index: 0" />
<Clock class={$size} color="#0f0" style="z-index: 0" />
{:else if $currentState === false}
<Circle class={$size} color="#f00" style="z-index: 0" />
<Clock class={$size} color="#fff" style="z-index: 0" />
{/if}
{#if $nextChange !== undefined}
<span class="absolute bottom-0 font-bold text-sm" style="z-index: 1; background-color: #ffffff88; margin-top: 3px">
{$nextChange}
</span>
{/if}
</div>
{/if}

View file

@ -1,5 +1,6 @@
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import opening_hours from "opening_hours" import opening_hours from "opening_hours"
import { Store } from "../../Logic/UIEventSource"
export interface OpeningHour { export interface OpeningHour {
weekday: number // 0 is monday, 1 is tuesday, ... weekday: number // 0 is monday, 1 is tuesday, ...
@ -494,10 +495,48 @@ This list will be sorted
return [changeHours, changeHourText] return [changeHours, changeHourText]
} }
public static CreateOhObjectStore(
tags: Store<Record<string, string>>,
key: string = "opening_hours",
prefixToIgnore?: string,
postfixToIgnore?: string
): Store<opening_hours | undefined | "error"> {
prefixToIgnore ??= ""
postfixToIgnore ??= ""
const country = tags.map((tags) => tags._country)
return tags
.mapD((tags) => {
const value: string = tags[key]
if (value === undefined) {
return undefined
}
if (
(prefixToIgnore || postfixToIgnore) &&
value.startsWith(prefixToIgnore) &&
value.endsWith(postfixToIgnore)
) {
return value
.substring(prefixToIgnore.length, value.length - postfixToIgnore.length)
.trim()
}
return value
})
.mapD(
(ohtext) => {
try {
return OH.CreateOhObject(<any>tags.data, ohtext, country.data)
} catch (e) {
return "error"
}
},
[country]
)
}
public static CreateOhObject( public static CreateOhObject(
tags: Record<string, string> & { _lat: number; _lon: number; _country?: string }, tags: Record<string, string> & { _lat: number; _lon: number; _country?: string },
textToParse: string textToParse: string,
country?: string
) { ) {
// noinspection JSPotentiallyInvalidConstructorUsage // noinspection JSPotentiallyInvalidConstructorUsage
return new opening_hours( return new opening_hours(
@ -506,7 +545,7 @@ This list will be sorted
lat: tags._lat, lat: tags._lat,
lon: tags._lon, lon: tags._lon,
address: { address: {
country_code: tags._country?.toLowerCase(), country_code: country.toLowerCase(),
state: undefined, state: undefined,
}, },
}, },

View file

@ -3,7 +3,6 @@ import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement" import { FixedUiElement } from "../Base/FixedUiElement"
import { OH } from "./OpeningHours" import { OH } from "./OpeningHours"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Constants from "../../Models/Constants"
import BaseUIElement from "../BaseUIElement" import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle" import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement" import { VariableUiElement } from "../Base/VariableUIElement"
@ -30,48 +29,20 @@ export default class OpeningHoursVisualization extends Toggle {
prefix = "", prefix = "",
postfix = "" postfix = ""
) { ) {
const country = tags.map((tags) => tags._country) const openingHoursStore = OH.CreateOhObjectStore(tags, key, prefix, postfix)
const ohTable = new VariableUiElement( const ohTable = new VariableUiElement(
tags openingHoursStore.map((opening_hours_obj) => {
.map((tags) => { if (opening_hours_obj === undefined) {
const value: string = tags[key] return new FixedUiElement("No opening hours defined with key " + key).SetClass(
if (value === undefined) { "alert"
return undefined )
} }
if (value.startsWith(prefix) && value.endsWith(postfix)) {
return value.substring(prefix.length, value.length - postfix.length).trim() if (opening_hours_obj === "error") {
} return Translations.t.general.opening_hours.error_loading
return value }
}) // This mapping will absorb all other changes to tags in order to prevent regeneration return OpeningHoursVisualization.CreateFullVisualisation(opening_hours_obj)
.map( })
(ohtext) => {
if (ohtext === undefined) {
return new FixedUiElement(
"No opening hours defined with key " + key
).SetClass("alert")
}
try {
return OpeningHoursVisualization.CreateFullVisualisation(
OH.CreateOhObject(<any>tags.data, ohtext)
)
} catch (e) {
console.warn(e, e.stack)
return new Combine([
Translations.t.general.opening_hours.error_loading,
new Toggle(
new FixedUiElement(e).SetClass("subtle"),
undefined,
state?.osmConnection?.userDetails.map(
(userdetails) =>
userdetails.csCount >=
Constants.userJourney.tagsVisibleAndWikiLinked
)
),
])
}
},
[country]
)
) )
super( super(

View file

@ -161,7 +161,7 @@
2. What do we want to add? 2. What do we want to add?
3. Are all elements of this category visible? (i.e. there are no filters possibly hiding this, is the data still loading, ...) --> 3. Are all elements of this category visible? (i.e. there are no filters possibly hiding this, is the data still loading, ...) -->
<LoginButton osmConnection={state.osmConnection} slot="not-logged-in"> <LoginButton osmConnection={state.osmConnection} slot="not-logged-in">
<Tr slot="message" t={Translations.t.general.add.pleaseLogin} /> <Tr t={Translations.t.general.add.pleaseLogin} />
</LoginButton> </LoginButton>
<div class="h-full w-full"> <div class="h-full w-full">
{#if $zoom < Constants.minZoomLevelToAddNewPoint} {#if $zoom < Constants.minZoomLevelToAddNewPoint}

View file

@ -19,7 +19,7 @@
...(state?.layoutToUse?.layers?.map((l) => l.calculatedTags?.map((c) => c[0]) ?? []) ?? []) ...(state?.layoutToUse?.layers?.map((l) => l.calculatedTags?.map((c) => c[0]) ?? []) ?? [])
) )
const allTags = tags.map((tags) => { const allTags = tags.mapD((tags) => {
const parts: (string | BaseUIElement)[][] = [] const parts: (string | BaseUIElement)[][] = []
for (const key in tags) { for (const key in tags) {
let v = tags[key] let v = tags[key]

View file

@ -31,14 +31,16 @@ export class ExportAsGpxViz implements SpecialVisualization {
t.downloadFeatureAsGpx.SetClass("font-bold text-lg"), t.downloadFeatureAsGpx.SetClass("font-bold text-lg"),
t.downloadGpxHelper.SetClass("subtle"), t.downloadGpxHelper.SetClass("subtle"),
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
).onClick(() => { )
console.log("Exporting as GPX!") .SetClass("w-full")
const tags = tagSource.data .onClick(() => {
const title = layer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track" console.log("Exporting as GPX!")
const gpx = GeoOperations.toGpx(<Feature<LineString>>feature, title) const tags = tagSource.data
Utils.offerContentsAsDownloadableFile(gpx, title + "_mapcomplete_export.gpx", { const title = layer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track"
mimetype: "{gpx=application/gpx+xml}", const gpx = GeoOperations.toGpx(<Feature<LineString>>feature, title)
Utils.offerContentsAsDownloadableFile(gpx, title + "_mapcomplete_export.gpx", {
mimetype: "{gpx=application/gpx+xml}",
})
}) })
})
} }
} }

View file

@ -28,7 +28,6 @@
const t = Translations.t.image.nearby const t = Translations.t.image.nearby
const c = [lon, lat] const c = [lon, lat]
console.log(">>>", image)
let attributedImage = new AttributedImage({ let attributedImage = new AttributedImage({
url: image.thumbUrl ?? image.pictureUrl, url: image.thumbUrl ?? image.pictureUrl,
provider: AllImageProviders.byName(image.provider), provider: AllImageProviders.byName(image.provider),
@ -45,7 +44,7 @@
const url = image.osmTags[key] const url = image.osmTags[key]
if (isLinked) { if (isLinked) {
const action = new LinkImageAction(currentTags.id, key, url, tags, { const action = new LinkImageAction(currentTags.id, key, url, tags, {
theme: state.layout.id, theme: tags.data._orig_theme ?? state.layout.id,
changeType: "link-image", changeType: "link-image",
}) })
state.changes.applyAction(action) state.changes.applyAction(action)
@ -54,7 +53,7 @@
const v = currentTags[k] const v = currentTags[k]
if (v === url) { if (v === url) {
const action = new ChangeTagAction(currentTags.id, new Tag(k, ""), currentTags, { const action = new ChangeTagAction(currentTags.id, new Tag(k, ""), currentTags, {
theme: state.layout.id, theme: tags.data._orig_theme ?? state.layout.id,
changeType: "remove-image", changeType: "remove-image",
}) })
state.changes.applyAction(action) state.changes.applyAction(action)

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization";
import { HeartIcon as HeartSolidIcon } from "@babeard/svelte-heroicons/solid";
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline";
import Tr from "../Base/Tr.svelte";
import Translations from "../i18n/Translations";
import LoginToggle from "../Base/LoginToggle.svelte";
import type { Feature } from "geojson";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
/**
* A full-blown 'mark as favourite'-button
*/
export let state: SpecialVisualizationState;
export let feature: Feature
export let tags: Record<string, string>;
export let layer: LayerConfig
let isFavourite = tags?.map(tags => tags._favourite === "yes");
const t = Translations.t.favouritePoi;
function markFavourite(isFavourite: boolean) {
state.favourites.markAsFavourite(feature, layer.id, state.layout.id, tags, isFavourite)
}
</script>
<LoginToggle ignoreLoading={true} {state}>
{#if $isFavourite}
<div class="flex h-fit items-start">
<HeartSolidIcon class="w-16 shrink-0 mr-2" on:click={() => markFavourite(false)} />
<div class="flex flex-col w-full">
<button class="flex flex-col items-start" on:click={() => markFavourite(false)}>
<Tr t={t.button.unmark} />
<Tr cls="normal-font subtle" t={t.button.unmarkNotDeleted}/>
</button>
</div>
</div>
<Tr cls="font-bold thanks m-2 p-2 block" t={t.button.isFavourite} />
{:else}
<div class="flex items-start">
<HeartOutlineIcon class="w-16 shrink-0 mr-2" on:click={() => markFavourite(true)} />
<button class="flex w-full flex-col items-start" on:click={() => markFavourite(true)}>
<Tr t={t.button.markAsFavouriteTitle} />
<Tr cls="normal-font subtle" t={t.button.markDescription}/>
</button>
</div>
{/if}
</LoginToggle>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization";
import { HeartIcon as HeartSolidIcon } from "@babeard/svelte-heroicons/solid";
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline";
import Translations from "../i18n/Translations";
import LoginToggle from "../Base/LoginToggle.svelte";
import type { Feature } from "geojson";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
/**
* A small 'mark as favourite'-button to serve as title-icon
*/
export let state: SpecialVisualizationState;
export let feature: Feature;
export let tags: Record<string, string>;
export let layer: LayerConfig;
let isFavourite = tags?.map(tags => tags._favourite === "yes");
const t = Translations.t.favouritePoi;
function markFavourite(isFavourite: boolean) {
state.favourites.markAsFavourite(feature, layer.id, state.layout.id, tags, isFavourite);
}
</script>
<LoginToggle ignoreLoading={true} {state}>
{#if $isFavourite}
<button class="p-0 m-0 h-8 w-8" on:click={() => markFavourite(false)}>
<HeartSolidIcon/>
</button>
{:else}
<button class="p-0 m-0 h-8 w-8 no-image-background" on:click={() => markFavourite(true)} >
<HeartOutlineIcon/>
</button>
{/if}
</LoginToggle>

View file

@ -3,16 +3,15 @@
* Shows all questions for which the answers are unknown. * Shows all questions for which the answers are unknown.
* The questions can either be shown all at once or one at a time (in which case they can be skipped) * The questions can either be shown all at once or one at a time (in which case they can be skipped)
*/ */
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig" import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig";
import { UIEventSource } from "../../../Logic/UIEventSource" import { UIEventSource } from "../../../Logic/UIEventSource";
import type { Feature } from "geojson" import type { Feature } from "geojson";
import type { SpecialVisualizationState } from "../../SpecialVisualization" import type { SpecialVisualizationState } from "../../SpecialVisualization";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import If from "../../Base/If.svelte" import TagRenderingQuestion from "./TagRenderingQuestion.svelte";
import TagRenderingQuestion from "./TagRenderingQuestion.svelte" import Tr from "../../Base/Tr.svelte";
import Tr from "../../Base/Tr.svelte" import Translations from "../../i18n/Translations.js";
import Translations from "../../i18n/Translations.js" import { Utils } from "../../../Utils";
import { Utils } from "../../../Utils"
export let layer: LayerConfig export let layer: LayerConfig
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>

View file

@ -26,6 +26,7 @@
onDestroy( onDestroy(
tags.addCallbackAndRun((tags) => { tags.addCallbackAndRun((tags) => {
_tags = tags _tags = tags
console.log("Getting render value for", _tags,config)
trs = Utils.NoNull(config?.GetRenderValues(_tags)) trs = Utils.NoNull(config?.GetRenderValues(_tags))
}) })
) )

View file

@ -11,7 +11,7 @@
import Translations from "../../i18n/Translations.js" import Translations from "../../i18n/Translations.js"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import { twMerge } from "tailwind-merge"
export let config: TagRenderingConfig export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>
export let selectedElement: Feature | undefined export let selectedElement: Feature | undefined
@ -71,7 +71,7 @@
} }
</script> </script>
<div bind:this={htmlElem} class={clss}> <div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}>
{#if config.question && (!editingEnabled || $editingEnabled)} {#if config.question && (!editingEnabled || $editingEnabled)}
{#if editMode} {#if editMode}
<TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}> <TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}>

View file

@ -6,6 +6,7 @@
import { UIEventSource } from "../../../Logic/UIEventSource" import { UIEventSource } from "../../../Logic/UIEventSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { twJoin } from "tailwind-merge" import { twJoin } from "tailwind-merge"
import Icon from "../../Map/Icon.svelte";
export let selectedElement: Feature export let selectedElement: Feature
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>
@ -27,13 +28,8 @@
</script> </script>
{#if mapping.icon !== undefined} {#if mapping.icon !== undefined}
<div class="inline-flex items-center"> <div class="inline-flex">
<img <Icon icon={mapping.icon} clss={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")}/>
class={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")}
src={mapping.icon}
aria-hidden="true"
alt=""
/>
<SpecialTranslation t={mapping.then} {tags} {state} {layer} feature={selectedElement} /> <SpecialTranslation t={mapping.then} {tags} {state} {layer} feature={selectedElement} />
</div> </div>
{:else if mapping.then !== undefined} {:else if mapping.then !== undefined}

View file

@ -1,188 +1,210 @@
<script lang="ts"> <script lang="ts">
import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource" import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource";
import type { SpecialVisualizationState } from "../../SpecialVisualization" import type { SpecialVisualizationState } from "../../SpecialVisualization";
import Tr from "../../Base/Tr.svelte" import Tr from "../../Base/Tr.svelte";
import type { Feature } from "geojson" import type { Feature } from "geojson";
import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig" import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig";
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig" import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig";
import { TagsFilter } from "../../../Logic/Tags/TagsFilter" import { TagsFilter } from "../../../Logic/Tags/TagsFilter";
import FreeformInput from "./FreeformInput.svelte" import FreeformInput from "./FreeformInput.svelte";
import Translations from "../../i18n/Translations.js" import Translations from "../../i18n/Translations.js";
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction" import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction";
import { createEventDispatcher, onDestroy } from "svelte" import { createEventDispatcher, onDestroy } from "svelte";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import SpecialTranslation from "./SpecialTranslation.svelte" import SpecialTranslation from "./SpecialTranslation.svelte";
import TagHint from "../TagHint.svelte" import TagHint from "../TagHint.svelte";
import LoginToggle from "../../Base/LoginToggle.svelte" import LoginToggle from "../../Base/LoginToggle.svelte";
import SubtleButton from "../../Base/SubtleButton.svelte" import SubtleButton from "../../Base/SubtleButton.svelte";
import Loading from "../../Base/Loading.svelte" import Loading from "../../Base/Loading.svelte";
import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte" import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte";
import { Translation } from "../../i18n/Translation" import { Translation } from "../../i18n/Translation";
import Constants from "../../../Models/Constants" import Constants from "../../../Models/Constants";
import { Unit } from "../../../Models/Unit" import { Unit } from "../../../Models/Unit";
import UserRelatedState from "../../../Logic/State/UserRelatedState" import UserRelatedState from "../../../Logic/State/UserRelatedState";
import { twJoin } from "tailwind-merge" import { twJoin } from "tailwind-merge";
import { TagUtils } from "../../../Logic/Tags/TagUtils" import { TagUtils } from "../../../Logic/Tags/TagUtils";
import Search from "../../../assets/svg/Search.svelte" import Search from "../../../assets/svg/Search.svelte";
import Login from "../../../assets/svg/Login.svelte" import Login from "../../../assets/svg/Login.svelte";
export let config: TagRenderingConfig export let config: TagRenderingConfig;
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>;
export let selectedElement: Feature export let selectedElement: Feature;
export let state: SpecialVisualizationState export let state: SpecialVisualizationState;
export let layer: LayerConfig | undefined export let layer: LayerConfig | undefined;
export let selectedTags: TagsFilter = undefined;
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined) let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined);
let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key)) let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
// Will be bound if a freeform is available // Will be bound if a freeform is available
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]) let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]);
let selectedMapping: number = undefined let selectedMapping: number = undefined;
let checkedMappings: boolean[] let checkedMappings: boolean[];
/** let mappings: Mapping[] = config?.mappings;
* Prepares and fills the checkedMappings let searchTerm: UIEventSource<string> = new UIEventSource("");
*/
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) { let dispatch = createEventDispatcher<{
mappings = confg.mappings?.filter((m) => { saved: {
if (typeof m.hideInAnswer === "boolean") { config: TagRenderingConfig
return !m.hideInAnswer applied: TagsFilter
} }
return !m.hideInAnswer.matchesProperties(tgs) }>();
/**
* Prepares and fills the checkedMappings
*/
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) {
mappings = confg.mappings?.filter((m) => {
if (typeof m.hideInAnswer === "boolean") {
return !m.hideInAnswer;
}
return !m.hideInAnswer.matchesProperties(tgs);
});
// We received a new config -> reinit
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
if (
confg.mappings?.length > 0 &&
confg.multiAnswer &&
(checkedMappings === undefined ||
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0))
) {
const seenFreeforms = [];
TagUtils.FlattenMultiAnswer();
checkedMappings = [
...confg.mappings.map((mapping) => {
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs);
if (matches && confg.freeform) {
const newProps = TagUtils.changeAsProperties(mapping.if.asChange());
seenFreeforms.push(newProps[confg.freeform.key]);
}
return matches;
}) })
// We received a new config -> reinit ];
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
if ( if (tgs !== undefined && confg.freeform) {
confg.mappings?.length > 0 && const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? [];
confg.multiAnswer && for (const seenFreeform of seenFreeforms) {
(checkedMappings === undefined || if (!seenFreeform) {
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0)) continue;
) { }
const seenFreeforms = [] const index = unseenFreeformValues.indexOf(seenFreeform);
TagUtils.FlattenMultiAnswer() if (index < 0) {
checkedMappings = [ continue;
...confg.mappings.map((mapping) => { }
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs) unseenFreeformValues.splice(index, 1);
if (matches && confg.freeform) {
const newProps = TagUtils.changeAsProperties(mapping.if.asChange())
seenFreeforms.push(newProps[confg.freeform.key])
}
return matches
}),
]
if (tgs !== undefined && confg.freeform) {
const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []
for (const seenFreeform of seenFreeforms) {
if (!seenFreeform) {
continue
}
const index = unseenFreeformValues.indexOf(seenFreeform)
if (index < 0) {
continue
}
unseenFreeformValues.splice(index, 1)
}
// TODO this has _to much_ values
freeformInput.setData(unseenFreeformValues.join(";"))
checkedMappings.push(unseenFreeformValues.length > 0)
}
} }
if (confg.freeform?.key) { // TODO this has _to much_ values
if (!confg.multiAnswer) { freeformInput.setData(unseenFreeformValues.join(";"));
// Somehow, setting multi-answer freeform values is broken if this is not set checkedMappings.push(unseenFreeformValues.length > 0);
freeformInput.setData(tgs[confg.freeform.key]) }
} }
if (confg.freeform?.key) {
if (!confg.multiAnswer) {
// Somehow, setting multi-answer freeform values is broken if this is not set
freeformInput.setData(tgs[confg.freeform.key]);
}
} else {
freeformInput.setData(undefined);
}
feedback.setData(undefined);
}
$: {
// Even though 'config' is not declared as a store, Svelte uses it as one to update the component
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes
initialize($tags, config);
}
$: {
try {
selectedTags = config?.constructChangeSpecification(
$freeformInput,
selectedMapping,
checkedMappings,
tags.data
);
} catch (e) {
console.error("Could not calculate changeSpecification:", e);
selectedTags = undefined;
}
}
function onSave() {
if (selectedTags === undefined) {
console.log("SelectedTags is undefined, ignoring 'onSave'-event");
return;
}
if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) {
/**
* This is a special, priviliged layer.
* We simply apply the tags onto the records
*/
const kv = selectedTags.asChange(tags.data);
for (const { k, v } of kv) {
if (v === undefined || v === "") {
delete tags.data[k];
} else { } else {
freeformInput.setData(undefined) freeformInput.setData(undefined);
} }
feedback.setData(undefined) feedback.setData(undefined);
}
} }
$: { dispatch("saved", { config, applied: selectedTags });
// Even though 'config' is not declared as a store, Svelte uses it as one to update the component const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, {
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes theme: tags.data["_orig_theme"] ?? state.layout.id,
initialize($tags, config) changeType: "answer"
});
freeformInput.setData(undefined);
selectedMapping = undefined;
selectedTags = undefined;
change
.CreateChangeDescriptions()
.then((changes) => state.changes.applyChanges(changes))
.catch(console.error);
}
function onInputKeypress(e: Event) {
if (e.key === "Enter") {
onSave();
} }
export let selectedTags: TagsFilter = undefined }
let mappings: Mapping[] = config?.mappings $: {
let searchTerm: UIEventSource<string> = new UIEventSource("") try {
selectedTags = config?.constructChangeSpecification(
$: { $freeformInput,
try { selectedMapping,
selectedTags = config?.constructChangeSpecification( checkedMappings,
$freeformInput, tags.data
selectedMapping, );
checkedMappings, } catch (e) {
tags.data, console.error("Could not calculate changeSpecification:", e);
) selectedTags = undefined;
} catch (e) {
console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined
}
} }
}
let dispatch = createEventDispatcher<{
saved: {
config: TagRenderingConfig
applied: TagsFilter
}
}>()
function onSave() { let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false);
if (selectedTags === undefined) { let featureSwitchIsDebugging =
console.log("SelectedTags is undefined, ignoring 'onSave'-event") state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false);
return let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined);
} let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0;
if (layer === undefined || layer?.source === null) { let question = config.question;
/** $: question = config.question;
* This is a special, priviliged layer. if (state?.osmConnection) {
* We simply apply the tags onto the records onDestroy(
*/ state.osmConnection?.userDetails?.addCallbackAndRun((ud) => {
const kv = selectedTags.asChange(tags.data) numberOfCs = ud.csCount;
for (const { k, v } of kv) { })
if (v === undefined || v === "") { );
delete tags.data[k] }
} else {
tags.data[k] = v
}
}
tags.ping()
return
}
dispatch("saved", { config, applied: selectedTags })
const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, {
theme: state.layout.id,
changeType: "answer",
})
freeformInput.setData(undefined)
selectedMapping = undefined
selectedTags = undefined
change
.CreateChangeDescriptions()
.then((changes) => state.changes.applyChanges(changes))
.catch(console.error)
}
let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false)
let featureSwitchIsDebugging =
state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined)
let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0
let question = config.question
$: question = config.question
if (state?.osmConnection) {
onDestroy(
state.osmConnection?.userDetails?.addCallbackAndRun((ud) => {
numberOfCs = ud.csCount
}),
)
}
</script> </script>
{#if question !== undefined} {#if question !== undefined}
@ -246,9 +268,8 @@
bind:group={selectedMapping} bind:group={selectedMapping}
name={"mappings-radio-" + config.id} name={"mappings-radio-" + config.id}
value={i} value={i}
on:keypress={(e) => { on:keypress={e => onInputKeypress(e)}
if (e.key === "Enter") onSave()
}}
/> />
</TagRenderingMappingInput> </TagRenderingMappingInput>
{/each} {/each}
@ -259,6 +280,7 @@
bind:group={selectedMapping} bind:group={selectedMapping}
name={"mappings-radio-" + config.id} name={"mappings-radio-" + config.id}
value={config.mappings?.length} value={config.mappings?.length}
on:keypress={e => onInputKeypress(e)}
/> />
<FreeformInput <FreeformInput
{config} {config}
@ -290,6 +312,7 @@
type="checkbox" type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + i} name={"mappings-checkbox-" + config.id + "-" + i}
bind:checked={checkedMappings[i]} bind:checked={checkedMappings[i]}
on:keypress={e => onInputKeypress(e)}
/> />
</TagRenderingMappingInput> </TagRenderingMappingInput>
{/each} {/each}
@ -299,6 +322,7 @@
type="checkbox" type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length} name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length}
bind:checked={checkedMappings[config.mappings.length]} bind:checked={checkedMappings[config.mappings.length]}
on:keypress={e => onInputKeypress(e)}
/> />
<FreeformInput <FreeformInput
{config} {config}
@ -307,7 +331,6 @@
{unit} {unit}
feature={selectedElement} feature={selectedElement}
value={freeformInput} value={freeformInput}
on:selected={() => (checkedMappings[config.mappings.length] = true)}
on:submit={onSave} on:submit={onSave}
/> />
</label> </label>

View file

@ -17,6 +17,7 @@ import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
import { RasterLayerPolygon } from "../Models/RasterLayers" import { RasterLayerPolygon } from "../Models/RasterLayers"
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { OsmTags } from "../Models/OsmFeature" import { OsmTags } from "../Models/OsmFeature"
import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource"
/** /**
* The state needed to render a special Visualisation. * The state needed to render a special Visualisation.
@ -33,7 +34,6 @@ export interface SpecialVisualizationState {
} }
readonly indexedFeatures: IndexedFeatureSource readonly indexedFeatures: IndexedFeatureSource
/** /**
* Some features will create a new element that should be displayed. * Some features will create a new element that should be displayed.
* These can be injected by appending them to this featuresource (and pinging it) * These can be injected by appending them to this featuresource (and pinging it)
@ -59,6 +59,8 @@ export interface SpecialVisualizationState {
readonly selectedLayer: UIEventSource<LayerConfig> readonly selectedLayer: UIEventSource<LayerConfig>
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }> readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
readonly favourites: FavouritesFeatureSource
/** /**
* If data is currently being fetched from external sources * If data is currently being fetched from external sources
*/ */

View file

@ -79,6 +79,9 @@ import ThemeViewState from "../Models/ThemeViewState"
import LanguagePicker from "./InputElement/LanguagePicker.svelte" import LanguagePicker from "./InputElement/LanguagePicker.svelte"
import LogoutButton from "./Base/LogoutButton.svelte" import LogoutButton from "./Base/LogoutButton.svelte"
import OpenJosm from "./Base/OpenJosm.svelte" import OpenJosm from "./Base/OpenJosm.svelte"
import MarkAsFavourite from "./Popup/MarkAsFavourite.svelte"
import MarkAsFavouriteMini from "./Popup/MarkAsFavouriteMini.svelte"
import NextChangeViz from "./OpeningHours/NextChangeViz.svelte"
class NearbyImageVis implements SpecialVisualization { class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -532,6 +535,9 @@ export default class SpecialVisualizations {
feature: Feature, feature: Feature,
layer: LayerConfig layer: LayerConfig
): BaseUIElement { ): BaseUIElement {
if (!layer.deletion) {
return undefined
}
return new SvelteUIElement(DeleteWizard, { return new SvelteUIElement(DeleteWizard, {
tags: tagSource, tags: tagSource,
deleteConfig: layer.deletion, deleteConfig: layer.deletion,
@ -822,6 +828,46 @@ export default class SpecialVisualizations {
) )
}, },
}, },
{
funcName: "opening_hours_state",
docs: "A small element, showing if the POI is currently open and when the next change is",
args: [
{
name: "key",
defaultValue: "opening_hours",
doc: "The tagkey from which the opening hours are read.",
},
{
name: "prefix",
defaultValue: "",
doc: "Remove this string from the start of the value before parsing. __Note: use `&LPARENs` to indicate `(` if needed__",
},
{
name: "postfix",
defaultValue: "",
doc: "Remove this string from the end of the value before parsing. __Note: use `&RPARENs` to indicate `)` if needed__",
},
],
needsUrls: [],
constr(
state: SpecialVisualizationState,
tags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
const keyToUse = args[0]
const prefix = args[1]
const postfix = args[2]
return new SvelteUIElement(NextChangeViz, {
state,
keyToUse,
tags,
prefix,
postfix,
})
},
},
{ {
funcName: "canonical", funcName: "canonical",
needsUrls: [], needsUrls: [],
@ -872,20 +918,22 @@ export default class SpecialVisualizations {
t.downloadFeatureAsGeojson.SetClass("font-bold text-lg"), t.downloadFeatureAsGeojson.SetClass("font-bold text-lg"),
t.downloadGeoJsonHelper.SetClass("subtle"), t.downloadGeoJsonHelper.SetClass("subtle"),
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
).onClick(() => { )
console.log("Exporting as Geojson") .onClick(() => {
const tags = tagSource.data console.log("Exporting as Geojson")
const title = const tags = tagSource.data
layer?.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "geojson" const title =
const data = JSON.stringify(feature, null, " ") layer?.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "geojson"
Utils.offerContentsAsDownloadableFile( const data = JSON.stringify(feature, null, " ")
data, Utils.offerContentsAsDownloadableFile(
title + "_mapcomplete_export.geojson", data,
{ title + "_mapcomplete_export.geojson",
mimetype: "application/vnd.geo+json", {
} mimetype: "application/vnd.geo+json",
) }
}) )
})
.SetClass("w-full")
}, },
}, },
{ {
@ -1482,7 +1530,7 @@ export default class SpecialVisualizations {
const tags = (<ThemeViewState>( const tags = (<ThemeViewState>(
state state
)).geolocation.currentUserLocation.features.map( )).geolocation.currentUserLocation.features.map(
(features) => features[0].properties (features) => features[0]?.properties
) )
return new SvelteUIElement(AllTagsPanel, { return new SvelteUIElement(AllTagsPanel, {
state, state,
@ -1490,6 +1538,46 @@ export default class SpecialVisualizations {
}) })
}, },
}, },
{
funcName: "favourite_status",
needsUrls: [],
docs: "A button that allows a (logged in) contributor to mark a location as a favourite location",
args: [],
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
return new SvelteUIElement(MarkAsFavourite, {
tags: tagSource,
state,
layer,
feature,
})
},
},
{
funcName: "favourite_icon",
needsUrls: [],
docs: "A small button that allows a (logged in) contributor to mark a location as a favourite location, sized to fit a title-icon",
args: [],
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
return new SvelteUIElement(MarkAsFavouriteMini, {
tags: tagSource,
state,
layer,
feature,
})
},
},
] ]
specialVisualizations.push(new AutoApplyButton(specialVisualizations)) specialVisualizations.push(new AutoApplyButton(specialVisualizations))

View file

@ -1,30 +1,30 @@
<script lang="ts"> <script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource" import { UIEventSource } from "../../Logic/UIEventSource";
import { OsmConnection } from "../../Logic/Osm/OsmConnection" import { OsmConnection } from "../../Logic/Osm/OsmConnection";
import Marker from "../Map/Marker.svelte" import Marker from "../Map/Marker.svelte";
import NextButton from "../Base/NextButton.svelte" import NextButton from "../Base/NextButton.svelte";
import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts" import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts";
import { AllSharedLayers } from "../../Customizations/AllSharedLayers" import { AllSharedLayers } from "../../Customizations/AllSharedLayers";
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte";
export let info: { id: string; owner: number } export let info: { id: string; owner: number };
export let category: "layers" | "themes" export let category: "layers" | "themes";
export let osmConnection: OsmConnection export let osmConnection: OsmConnection;
const dispatch = createEventDispatcher<{ layerSelected: string }>();
let displayName = UIEventSource.FromPromise( let displayName = UIEventSource.FromPromise(
osmConnection.getInformationAboutUser(info.owner), osmConnection.getInformationAboutUser(info.owner)
).mapD((response) => response.display_name) ).mapD((response) => response.display_name);
let selfId = osmConnection.userDetails.mapD((ud) => ud.uid);
let selfId = osmConnection.userDetails.mapD((ud) => ud.uid)
function fetchIconDescription(layerId): any { function fetchIconDescription(layerId): any {
if (category === "themes") { if (category === "themes") {
return AllKnownLayouts.allKnownLayouts.get(layerId).icon return AllKnownLayouts.allKnownLayouts.get(layerId).icon;
}
return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon
} }
return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon;
}
const dispatch = createEventDispatcher<{ layerSelected: string }>()
</script> </script>
<NextButton clss="small" on:click={() => dispatch("layerSelected", info)}> <NextButton clss="small" on:click={() => dispatch("layerSelected", info)}>

View file

@ -1,90 +1,91 @@
<script lang="ts"> <script lang="ts">
import { Store, UIEventSource } from "../Logic/UIEventSource" import { Store, UIEventSource } from "../Logic/UIEventSource";
import { Map as MlMap } from "maplibre-gl" import { Map as MlMap } from "maplibre-gl";
import MaplibreMap from "./Map/MaplibreMap.svelte" import MaplibreMap from "./Map/MaplibreMap.svelte";
import FeatureSwitchState from "../Logic/State/FeatureSwitchState" import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
import MapControlButton from "./Base/MapControlButton.svelte" import MapControlButton from "./Base/MapControlButton.svelte";
import ToSvelte from "./Base/ToSvelte.svelte" import ToSvelte from "./Base/ToSvelte.svelte";
import If from "./Base/If.svelte" import If from "./Base/If.svelte";
import { GeolocationControl } from "./BigComponents/GeolocationControl" import { GeolocationControl } from "./BigComponents/GeolocationControl";
import type { Feature } from "geojson" import type { Feature } from "geojson";
import SelectedElementView from "./BigComponents/SelectedElementView.svelte" import SelectedElementView from "./BigComponents/SelectedElementView.svelte";
import LayerConfig from "../Models/ThemeConfig/LayerConfig" import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import Filterview from "./BigComponents/Filterview.svelte" import Filterview from "./BigComponents/Filterview.svelte";
import ThemeViewState from "../Models/ThemeViewState" import ThemeViewState from "../Models/ThemeViewState";
import type { MapProperties } from "../Models/MapProperties" import type { MapProperties } from "../Models/MapProperties";
import Geosearch from "./BigComponents/Geosearch.svelte" import Geosearch from "./BigComponents/Geosearch.svelte";
import Translations from "./i18n/Translations" import Translations from "./i18n/Translations";
import { CogIcon, EyeIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid" import { CogIcon, EyeIcon, HeartIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid";
import Tr from "./Base/Tr.svelte" import Tr from "./Base/Tr.svelte";
import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte" import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte";
import FloatOver from "./Base/FloatOver.svelte" import FloatOver from "./Base/FloatOver.svelte";
import PrivacyPolicy from "./BigComponents/PrivacyPolicy" import PrivacyPolicy from "./BigComponents/PrivacyPolicy";
import Constants from "../Models/Constants" import Constants from "../Models/Constants";
import TabbedGroup from "./Base/TabbedGroup.svelte" import TabbedGroup from "./Base/TabbedGroup.svelte";
import UserRelatedState from "../Logic/State/UserRelatedState" import UserRelatedState from "../Logic/State/UserRelatedState";
import LoginToggle from "./Base/LoginToggle.svelte" import LoginToggle from "./Base/LoginToggle.svelte";
import LoginButton from "./Base/LoginButton.svelte" import LoginButton from "./Base/LoginButton.svelte";
import CopyrightPanel from "./BigComponents/CopyrightPanel" import CopyrightPanel from "./BigComponents/CopyrightPanel";
import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte" import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte";
import ModalRight from "./Base/ModalRight.svelte" import ModalRight from "./Base/ModalRight.svelte";
import { Utils } from "../Utils" import { Utils } from "../Utils";
import Hotkeys from "./Base/Hotkeys" import Hotkeys from "./Base/Hotkeys";
import { VariableUiElement } from "./Base/VariableUIElement" import { VariableUiElement } from "./Base/VariableUIElement";
import SvelteUIElement from "./Base/SvelteUIElement" import SvelteUIElement from "./Base/SvelteUIElement";
import OverlayToggle from "./BigComponents/OverlayToggle.svelte" import OverlayToggle from "./BigComponents/OverlayToggle.svelte";
import LevelSelector from "./BigComponents/LevelSelector.svelte" import LevelSelector from "./BigComponents/LevelSelector.svelte";
import ExtraLinkButton from "./BigComponents/ExtraLinkButton" import ExtraLinkButton from "./BigComponents/ExtraLinkButton";
import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte" import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte";
import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte" import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte";
import type { RasterLayerPolygon } from "../Models/RasterLayers" import type { RasterLayerPolygon } from "../Models/RasterLayers";
import { AvailableRasterLayers } from "../Models/RasterLayers" import { AvailableRasterLayers } from "../Models/RasterLayers";
import RasterLayerOverview from "./Map/RasterLayerOverview.svelte" import RasterLayerOverview from "./Map/RasterLayerOverview.svelte";
import IfHidden from "./Base/IfHidden.svelte" import IfHidden from "./Base/IfHidden.svelte";
import { onDestroy } from "svelte" import { onDestroy } from "svelte";
import MapillaryLink from "./BigComponents/MapillaryLink.svelte" import MapillaryLink from "./BigComponents/MapillaryLink.svelte";
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte" import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte";
import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte" import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte";
import StateIndicator from "./BigComponents/StateIndicator.svelte" import StateIndicator from "./BigComponents/StateIndicator.svelte";
import ShareScreen from "./BigComponents/ShareScreen.svelte" import ShareScreen from "./BigComponents/ShareScreen.svelte";
import UploadingImageCounter from "./Image/UploadingImageCounter.svelte" import UploadingImageCounter from "./Image/UploadingImageCounter.svelte";
import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte" import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte";
import Cross from "../assets/svg/Cross.svelte" import Cross from "../assets/svg/Cross.svelte";
import Summary from "./BigComponents/Summary.svelte" import Summary from "./BigComponents/Summary.svelte";
import Mastodon from "../assets/svg/Mastodon.svelte" import LanguagePicker from "./InputElement/LanguagePicker.svelte";
import Bug from "../assets/svg/Bug.svelte" import Mastodon from "../assets/svg/Mastodon.svelte";
import Liberapay from "../assets/svg/Liberapay.svelte" import Bug from "../assets/svg/Bug.svelte";
import Min from "../assets/svg/Min.svelte" import Liberapay from "../assets/svg/Liberapay.svelte";
import Plus from "../assets/svg/Plus.svelte" import OpenJosm from "./Base/OpenJosm.svelte";
import Filter from "../assets/svg/Filter.svelte" import Min from "../assets/svg/Min.svelte";
import Add from "../assets/svg/Add.svelte" import Plus from "../assets/svg/Plus.svelte";
import Statistics from "../assets/svg/Statistics.svelte" import Filter from "../assets/svg/Filter.svelte";
import Community from "../assets/svg/Community.svelte" import Add from "../assets/svg/Add.svelte";
import Download from "../assets/svg/Download.svelte" import Statistics from "../assets/svg/Statistics.svelte";
import Share from "../assets/svg/Share.svelte" import Community from "../assets/svg/Community.svelte";
import LanguagePicker from "./InputElement/LanguagePicker.svelte" import Download from "../assets/svg/Download.svelte";
import OpenJosm from "./Base/OpenJosm.svelte" import Share from "../assets/svg/Share.svelte";
import Favourites from "./Favourites/Favourites.svelte";
export let state: ThemeViewState export let state: ThemeViewState
let layout = state.layout let layout = state.layout
let maplibremap: UIEventSource<MlMap> = state.map let maplibremap: UIEventSource<MlMap> = state.map
let selectedElement: UIEventSource<Feature> = state.selectedElement let selectedElement: UIEventSource<Feature> = state.selectedElement
let selectedLayer: UIEventSource<LayerConfig> = state.selectedLayer let selectedLayer: UIEventSource<LayerConfig> = state.selectedLayer
let currentZoom = state.mapProperties.zoom let currentZoom = state.mapProperties.zoom;
let showCrosshair = state.userRelatedState.showCrosshair let showCrosshair = state.userRelatedState.showCrosshair;
let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation;
let centerFeatures = state.closestFeatures.features let centerFeatures = state.closestFeatures.features;
const selectedElementView = selectedElement.map( const selectedElementView = selectedElement.map(
(selectedElement) => { (selectedElement) => {
// Svelte doesn't properly reload some of the legacy UI-elements // Svelte doesn't properly reload some of the legacy UI-elements
// As such, we _reconstruct_ the selectedElementView every time a new feature is selected // As such, we _reconstruct_ the selectedElementView every time a new feature is selected
// This is a bit wasteful, but until everything is a svelte-component, this should do the trick // This is a bit wasteful, but until everything is a svelte-component, this should do the trick
const layer = selectedLayer.data const layer = selectedLayer.data;
if (selectedElement === undefined || layer === undefined) { if (selectedElement === undefined || layer === undefined) {
return undefined return undefined;
} }
if (!(layer.tagRenderings?.length > 0) || layer.title === undefined) { if (!(layer.tagRenderings?.length > 0) || layer.title === undefined) {
return undefined return undefined
@ -230,17 +231,15 @@
</a> </a>
</div> </div>
</div> </div>
{#if $arrowKeysWereUsed !== undefined}
{#if $centerFeatures.length > 0} {#if $arrowKeysWereUsed !== undefined && $centerFeatures?.length > 0}
<div class="interactive pointer-events-auto p-1"> <div class="pointer-events-auto interactive p-1">
{#each $centerFeatures as feat, i (feat.properties.id)} {#each $centerFeatures as feat, i (feat.properties.id)}
<div class="flex"> <div class="flex">
<b>{i + 1}.</b> <b>{i+1}.</b><Summary {state} feature={feat}/>
<Summary {state} feature={feat} /> </div>
</div> {/each}
{/each} </div>
</div>
{/if}
{/if} {/if}
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<!-- bottom right elements --> <!-- bottom right elements -->
@ -495,22 +494,31 @@
</div> </div>
<div class="flex" slot="title2"> <div class="flex" slot="title2">
<HeartIcon class="h-6 w-6" />
<Tr t={Translations.t.favouritePoi.tab}/>
</div>
<div class="flex flex-col m-2" slot="content2">
<h3> <Tr t={Translations.t.favouritePoi.title}/></h3>
<Favourites {state}/>
</div>
<div class="flex" slot="title3">
<Community class="h-6 w-6" /> <Community class="h-6 w-6" />
<Tr t={Translations.t.communityIndex.title} /> <Tr t={Translations.t.communityIndex.title} />
</div> </div>
<div class="m-2" slot="content2"> <div class="m-2" slot="content3">
<CommunityIndexView location={state.mapProperties.location} /> <CommunityIndexView location={state.mapProperties.location} />
</div> </div>
<div class="flex" slot="title3"> <div class="flex" slot="title4">
<EyeIcon class="w-6" /> <EyeIcon class="w-6" />
<Tr t={Translations.t.privacy.title} /> <Tr t={Translations.t.privacy.title} />
</div> </div>
<div class="m-2" slot="content3"> <div class="m-2" slot="content4">
<ToSvelte construct={() => new PrivacyPolicy()} /> <ToSvelte construct={() => new PrivacyPolicy()} />
</div> </div>
<Tr slot="title4" t={Translations.t.advanced.title} /> <Tr slot="title5" t={Translations.t.advanced.title} />
<div class="m-2 flex flex-col" slot="content4"> <div class="m-2 flex flex-col" slot="content5">
<If condition={featureSwitches.featureSwitchEnableLogin}> <If condition={featureSwitches.featureSwitchEnableLogin}>
<OpenIdEditor mapProperties={state.mapProperties} /> <OpenIdEditor mapProperties={state.mapProperties} />
<OpenJosm {state} /> <OpenJosm {state} />

View file

@ -301,10 +301,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
if (str === undefined || str === null) { if (str === undefined || str === null) {
return undefined return undefined
} }
if (typeof str !== "string") {
console.error("Not a string:", str)
return undefined
}
if (str.length <= l) { if (str.length <= l) {
return str return str
} }
return str.substr(0, l - 3) + "..." return str.substr(0, l - 1) + "…"
} }
/** /**

View file

@ -1,229 +1,426 @@
{ {
"#": "Generated with generateStats.ts",
"date": "2023-12-04T14:42:01.299Z",
"keys": { "keys": {
"addr:street": 117211930, "FIXME": 119237,
"addr:housenumber": 125040768, "access": 20023328,
"emergency": 1939478, "addr:housenumber": 146524978,
"barrier": 18424246, "addr:street": 137485111,
"tourism": 2683525, "advertising": 158347,
"amenity": 20541353, "amenity": 25340913,
"bench": 894256, "area": 1803451,
"rental": 8838, "association": 757,
"bicycle_rental": 7447, "barrier": 23634152,
"vending": 206755, "bench": 1300789,
"service:bicycle:rental": 3570, "bicycle": 7507086,
"pub": 316, "bicycle_rental": 26948,
"theme": 426, "boundary": 2366033,
"service:bicycle:.*": 0, "brand": 2317628,
"service:bicycle:cleaning": 807, "building": 585543589,
"shop": 5062252, "camera:direction": 61201,
"service:bicycle:retail": 9162, "climbing": 9051,
"network": 2181336, "club": 53046,
"sport": 2194801, "construction:amenity": 1943,
"service:bicycle:repair": 11381, "conveying": 27311,
"association": 369, "craft": 296376,
"ngo": 42, "crossing": 8736722,
"leisure": 7368076, "cyclestreet": 12505,
"club": 38429, "cycleway": 1016837,
"disused:amenity": 40880, "direction": 2978834,
"planned:amenity": 205, "disused:amenity": 63413,
"tileId": 0, "dog": 95086,
"construction:amenity": 1206, "door": 280843,
"cycleway": 906487, "drinking_water": 136067,
"highway": 218189453, "emergency": 2542692,
"bicycle": 6218071, "entrance": 3769592,
"cyclestreet": 8185, "fixme": 1746318,
"camera:direction": 40676, "footway": 7540651,
"direction": 1896015, "generator:source": 2387982,
"access": 16030036, "healthcare": 790125,
"entrance": 2954076, "highway": 249307936,
"name:etymology": 24485, "indoor": 562051,
"memorial": 132172, "information": 1073014,
"indoor": 353116, "isced:2011:level": 27,
"name:etymology:wikidata": 285224, "isced:level:2011": 74,
"landuse": 35524214, "landuse": 41730047,
"name": 88330405, "leisure": 8955744,
"protect_class": 73801, "man_made": 6799900,
"information": 831513, "memorial": 209327,
"man_made": 5116088, "motorcar": 621864,
"boundary": 2142378, "name": 98684655,
"tower:type": 451658, "name:etymology": 56375,
"playground": 109175, "name:etymology:wikidata": 1174439,
"route": 939184, "name:nl": 80468,
"surveillance:type": 116760, "natural": 64176097,
"natural": 52353504, "ngo": 57,
"building": 500469053 "office": 1092855,
"parking_space": 600707,
"planned:amenity": 237,
"playground": 182188,
"post_office": 16379,
"protect_class": 83815,
"pub": 324,
"public_transport": 5111577,
"railway": 7068070,
"recycling_type": 385569,
"ref": 18607577,
"rental": 13611,
"route": 1075802,
"service:bicycle:cleaning": 1179,
"service:bicycle:pump": 14053,
"service:bicycle:pump:operational_status": 344,
"service:bicycle:rental": 4599,
"service:bicycle:repair": 15470,
"service:bicycle:retail": 11467,
"service:bicycle:tools": 6227,
"shelter": 1647743,
"shop": 5860878,
"species": 1656206,
"species:wikidata": 107778,
"sport": 2580042,
"subject": 40076,
"surface:colour": 17851,
"surveillance:type": 171923,
"theme": 906,
"toilets": 90842,
"tourism": 3211694,
"tower:type": 596349,
"type": 11757856,
"vending": 252016
}, },
"tags": { "tags": {
"emergency": { "advertising": {
"defibrillator": 51273, "billboard": 76420,
"ambulance_station": 11047, "board": 15040,
"fire_extinguisher": 7355, "column": 21212,
"fire_hydrant": 1598739 "flag": 4264,
}, "poster_box": 22932,
"barrier": { "screen": 1352,
"cycle_barrier": 104166, "sculpture": 145,
"bollard": 502220, "sign": 6172,
"wall": 3535056 "tarp": 407,
}, "totem": 7097,
"tourism": { "wall_painting": 132
"artwork": 187470,
"map": 51,
"viewpoint": 191765
}, },
"amenity": { "amenity": {
"bench": 1736979, "animal_shelter": 6056,
"bicycle_library": 36, "atm": 207899,
"bicycle_rental": 49082, "bank": 389470,
"vending_machine": 201871, "bar": 219208,
"bar": 199662, "bench": 2313183,
"pub": 174979, "bicycle_library": 46,
"cafe": 467521, "bicycle_parking": 616881,
"restaurant": 1211671, "bicycle_rental": 63710,
"bicycle_wash": 44, "bicycle_repair_station": 14026,
"bike_wash": 0, "bicycle_wash": 79,
"bicycle_repair_station": 9247, "biergarten": 10323,
"bicycle_parking": 435959, "bike_wash": 1,
"binoculars": 479, "binoculars": 1109,
"biergarten": 10309, "cafe": 530066,
"charging_station": 65402, "car_rental": 26726,
"drinking_water": 250463, "charging_station": 111996,
"fast_food": 460079, "childcare": 50390,
"fire_station": 122200, "clinic": 179739,
"parking": 4255206, "clock": 25274,
"public_bookcase": 13120, "college": 64379,
"toilets": 350648, "dentist": 122076,
"recycling": 333925, "doctors": 166850,
"waste_basket": 550357, "drinking_water": 294750,
"waste_disposal": 156765 "fast_food": 533335,
}, "fire_station": 131842,
"bench": { "hospital": 204756,
"stand_up_bench": 87, "ice_cream": 48853,
"yes": 524993 "kindergarten": 294441,
}, "nightclub": 22779,
"service:bicycle:rental": { "parcel_locker": 44270,
"yes": 3054 "parking": 5158899,
}, "parking_space": 2292063,
"pub": { "pharmacy": 383181,
"cycling": 9, "post_box": 370286,
"bicycle": 0 "post_office": 198908,
}, "pub": 185475,
"theme": { "public_bookcase": 21608,
"cycling": 8, "reception_desk": 2426,
"bicycle": 16 "recycling": 417512,
}, "restaurant": 1346895,
"service:bicycle:cleaning": { "school": 1286594,
"yes": 607, "shelter": 494594,
"diy": 0 "shower": 27029,
}, "ticket_validator": 7730,
"shop": { "toilets": 417991,
"bicycle": 46488, "university": 54299,
"sports": 37024 "vending_machine": 247257,
}, "veterinary": 52813,
"sport": { "waste_basket": 759718,
"cycling": 6045, "waste_disposal": 219245
"bicycle": 96
}, },
"association": { "association": {
"cycling": 5, "bicycle": 47,
"bicycle": 20 "cycling": 5
}, },
"ngo": { "barrier": {
"cycling": 0, "bollard": 668017,
"bicycle": 0 "cycle_barrier": 122201,
"kerb": 1178769,
"retaining_wall": 472454,
"wall": 4448788
}, },
"leisure": { "bench": {
"bird_hide": 5669, "stand_up_bench": 212,
"nature_reserve": 117016, "yes": 778144
"picnic_table": 206322,
"pitch": 1990293,
"playground": 705102
},
"club": {
"cycling": 3,
"bicycle": 49
},
"disused:amenity": {
"charging_station": 164
},
"planned:amenity": {
"charging_station": 115
},
"construction:amenity": {
"charging_station": 221
},
"cycleway": {
"lane": 314576,
"track": 86541,
"shared_lane": 60824
},
"highway": {
"residential": 61321708,
"crossing": 6119521,
"cycleway": 1423789,
"traffic_signals": 1512639,
"tertiary": 7051727,
"unclassified": 15756878,
"secondary": 4486617,
"primary": 3110552,
"footway": 16496620,
"path": 11438303,
"steps": 1327396,
"corridor": 27051,
"pedestrian": 685989,
"bridleway": 102280,
"track": 22670967,
"living_street": 1519108,
"street_lamp": 2811705
}, },
"bicycle": { "bicycle": {
"designated": 1110839 "designated": 1499247,
}, "no": 1614544,
"cyclestreet": { "yes": 3753651
"yes": 8164
},
"access": {
"public": 6222,
"yes": 1363526
},
"memorial": {
"ghost_bike": 503
},
"indoor": {
"door": 9722
},
"landuse": {
"grass": 4898559,
"village_green": 104681
},
"name": {
"Park Oude God": 1
},
"information": {
"board": 242007,
"map": 85912,
"office": 24139,
"visitor_centre": 285
},
"man_made": {
"surveillance": 148172,
"watermill": 9699
}, },
"boundary": { "boundary": {
"protected_area": 97075 "protected_area": 111282
}, },
"tower:type": { "climbing": {
"observation": 19654 "area": 191,
"crag": 2873,
"route": 1040,
"site": 14
}, },
"playground": { "club": {
"forest": 56 "bicycle": 60,
"climbing": 1,
"cycling": 7
}, },
"surveillance:type": { "construction:amenity": {
"camera": 112963, "charging_station": 259
"ALPR": 2522, },
"ANPR": 3 "conveying": {
"yes": 12153
},
"craft": {
"key_cutter": 3711,
"shoe_repair": 64
},
"crossing": {
"traffic_signals": 1408141
},
"cyclestreet": {
"yes": 12480
},
"cycleway": {
"lane": 300810,
"shared_lane": 71051,
"track": 77166
},
"disused:amenity": {
"charging_station": 289,
"drinking_water": 2758
},
"dog": {
"unleashed": 727
},
"drinking_water": {
"yes": 74561
},
"emergency": {
"ambulance_station": 13020,
"defibrillator": 80699,
"fire_extinguisher": 11605,
"fire_hydrant": 1928477
},
"footway": {
"crossing": 3111184
},
"generator:source": {
"wind": 390537
},
"healthcare": {
"physiotherapist": 17548
},
"highway": {
"bridleway": 107507,
"bus_stop": 3459595,
"corridor": 46847,
"crossing": 8505991,
"cycleway": 1693405,
"elevator": 39221,
"footway": 21573091,
"living_street": 1753722,
"motorway": 1182914,
"motorway_link": 829035,
"path": 13690001,
"pedestrian": 767066,
"primary": 3462637,
"primary_link": 433106,
"residential": 65553821,
"secondary": 5008689,
"secondary_link": 340521,
"service": 54202864,
"speed_camera": 61915,
"speed_display": 2621,
"steps": 1618344,
"street_lamp": 3879570,
"tertiary": 7809143,
"tertiary_link": 245867,
"track": 25718176,
"traffic_signals": 1709993,
"trunk": 1679773,
"trunk_link": 519826,
"unclassified": 16914480
},
"indoor": {
"area": 25332,
"corridor": 17609,
"door": 19157,
"level": 4253,
"room": 157006,
"wall": 32366
},
"information": {
"board": 321201,
"guidepost": 520873,
"map": 108166,
"office": 27749,
"route_marker": 59596,
"visitor_centre": 523
},
"isced:level:2011": {
"early_childhood": 0
},
"landuse": {
"village_green": 102589
},
"leisure": {
"bird_hide": 6607,
"dog_park": 21993,
"fitness_centre": 72920,
"fitness_station": 62923,
"hackerspace": 1537,
"nature_reserve": 129575,
"park": 1168747,
"picnic_table": 302582,
"pitch": 2307262,
"playground": 821692,
"sports_centre": 231823,
"track": 124600
},
"man_made": {
"surveillance": 205953
},
"memorial": {
"ghost_bike": 748,
"plaque": 45536
},
"motorcar": {
"no": 270350,
"yes": 190966
}, },
"natural": { "natural": {
"tree": 18245059 "cliff": 761375,
"rock": 229114,
"stone": 52141,
"tree": 23309774
},
"ngo": {
"bicycle": 0,
"cycling": 0
},
"office": {
"government": 250353
},
"parking_space": {
"disabled": 161162
},
"planned:amenity": {
"charging_station": 72
},
"playground": {
"forest": 77
},
"post_office": {
"post_partner": 7560
},
"pub": {
"bicycle": 0,
"cycling": 12
},
"public_transport": {
"platform": 3254387
},
"railway": {
"platform": 167408
},
"recycling_type": {
"centre": 29508,
"container": 355016
},
"route": {
"bus": 272174
},
"service:bicycle:cleaning": {
"diy": 4,
"yes": 909
},
"service:bicycle:pump": {
"no": 1548,
"yes": 12452
},
"service:bicycle:pump:operational_status": {
"broken": 122
},
"service:bicycle:rental": {
"yes": 3902
},
"service:bicycle:repair": {
"yes": 15134
},
"service:bicycle:tools": {
"no": 354,
"yes": 5872
},
"shelter": {
"yes": 884942
},
"shop": {
"bicycle": 51336,
"bicycle_rental": 1,
"rental": 5206,
"sports": 40802
},
"sport": {
"bicycle": 114,
"climbing": 29028,
"cycling": 8225
},
"surface:colour": {
"rainbow": 217
},
"surveillance:type": {
"ALPR": 4424,
"ANPR": 3,
"camera": 165247
},
"theme": {
"bicycle": 16,
"cycling": 7
},
"toilets": {
"yes": 70811
},
"tourism": {
"artwork": 245861,
"hotel": 407208,
"map": 51,
"viewpoint": 219932
},
"tower:type": {
"observation": 23057
},
"type": {
"route": 1005677
},
"vending": {
"elongated_coin": 816,
"parcel_pickup;parcel_mail_in": 522,
"parking_tickets": 70753,
"public_transport_tickets": 26895
} }
} }
} }

View file

@ -0,0 +1,4 @@
<script>
export let color = "#000000"
</script>
<svg {...$$restProps} on:click on:mouseover on:mouseenter on:mouseleave on:keydown width="544.02838" height="544.02838" viewBox="0 0 544.02838 544.02838" version="1.1" id="svg1" sodipodi:docname="center.svg" inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <defs id="defs1" /> <sodipodi:namedview id="namedview1" pagecolor="#505050" bordercolor="#eeeeee" borderopacity="1" inkscape:showpageshadow="0" inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showguides="true" inkscape:zoom="0.90326851" inkscape:cx="393.57068" inkscape:cy="250.756" inkscape:window-width="1920" inkscape:window-height="995" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="svg1"> <sodipodi:guide position="171.95879,103.32864" orientation="0,-1" id="guide4" inkscape:locked="false" /> <sodipodi:guide position="271.68286,132.35281" orientation="1,0" id="guide5" inkscape:locked="false" /> </sodipodi:namedview> <path d="m 365.63918,111.75001 h -62.375 V 15.9375 c 0,-8.75 -7,-15.9375 -15.625,-15.9375 h -31.1875 c -8.5625,0 -15.625,7.1875 -15.625,15.9375 v 95.81251 h -62.375 l 93.5625,127.75 z" id="path1" sodipodi:nodetypes="ccsssscccc" /> <path d="m 432.27837,365.63919 v -62.375 h 95.8125 c 8.75,0 15.9375,-7 15.9375,-15.625 v -31.1875 c 0,-8.5625 -7.1875,-15.625 -15.9375,-15.625 h -95.8125 v -62.375 l -127.75,93.5625 z" id="path1-5" sodipodi:nodetypes="ccsssscccc" /> <path d="m 178.38918,432.27838 h 62.375 v 95.8125 c 0,8.75 7,15.9375 15.625,15.9375 h 31.1875 c 8.5625,0 15.625,-7.1875 15.625,-15.9375 v -95.8125 h 62.375 l -93.5625,-127.75 z" id="path2" sodipodi:nodetypes="ccsssscccc" /> <path d="m 111.75,178.38919 v 62.375 H 15.9375 c -8.75,0 -15.9375,7 -15.9375,15.625 v 31.1875 c 0,8.5625 7.1875,15.625 15.9375,15.625 H 111.75 v 62.375 l 127.74999,-93.5625 z" id="path3" sodipodi:nodetypes="ccsssscccc" /> </svg>

View file

@ -280,6 +280,16 @@ button.disabled:hover, .button.disabled:hover {
color: unset; color: unset;
} }
button.link {
border: none;
text-decoration: underline;
background-color: unset;
}
button.link:hover {
color:unset;
}
.interactive button.disabled svg path, .interactive .button.disabled svg path { .interactive button.disabled svg path, .interactive .button.disabled svg path {
fill: var(--interactive-foreground) !important;; fill: var(--interactive-foreground) !important;;
} }

View file

@ -125,7 +125,21 @@ describe("PrepareTheme", () => {
en: "Test layer - please ignore", en: "Test layer - please ignore",
}, },
titleIcons: [], titleIcons: [],
pointRendering: [{ location: ["point"], label: "xyz" }], pointRendering: [
{
location: ["point"],
label: "xyz",
iconBadges: [
{
if: "_favourite=yes",
then: <any>{
id: "circlewhiteheartred",
render: "circle:white;heart:red",
},
},
],
},
],
lineRendering: [{ width: 1 }], lineRendering: [{ width: 1 }],
} }
const sharedLayers = constructSharedLayers() const sharedLayers = constructSharedLayers()
@ -165,7 +179,21 @@ describe("PrepareTheme", () => {
id: "layer-example", id: "layer-example",
name: null, name: null,
minzoom: 18, minzoom: 18,
pointRendering: [{ location: ["point"], label: "xyz" }], pointRendering: [
{
location: ["point"],
label: "xyz",
iconBadges: [
{
if: "_favourite=yes",
then: {
id: "circlewhiteheartred",
render: "circle:white;heart:red",
},
},
],
},
],
lineRendering: [{ width: 1 }], lineRendering: [{ width: 1 }],
titleIcons: [], titleIcons: [],
}) })