diff --git a/.gitignore b/.gitignore index c5a6e0be26..7124fdc776 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ scratch assets/editor-layer-index.json assets/generated/* src/assets/generated/ +assets/layers/favourite/favourite.json public/*.webmanifest /*.html !/index.html diff --git a/Docs/UserTests/2023-12-4 User Test Favourites.md b/Docs/UserTests/2023-12-4 User Test Favourites.md new file mode 100644 index 0000000000..27231f9f29 --- /dev/null +++ b/Docs/UserTests/2023-12-4 User Test Favourites.md @@ -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. diff --git a/assets/layers/climbing_opportunity/climbing_opportunity.json b/assets/layers/climbing_opportunity/climbing_opportunity.json index 89fd5d6795..11d7727910 100644 --- a/assets/layers/climbing_opportunity/climbing_opportunity.json +++ b/assets/layers/climbing_opportunity/climbing_opportunity.json @@ -29,7 +29,8 @@ "natural=stone" ] }, - "climbing=" + "climbing=", + "sport!=climbing" ] } }, diff --git a/assets/layers/favourite/favourite.proto.json b/assets/layers/favourite/favourite.proto.json new file mode 100644 index 0000000000..7ad0e3d256 --- /dev/null +++ b/assets/layers/favourite/favourite.proto.json @@ -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": [ + + ] +} diff --git a/assets/layers/icons/icons.json b/assets/layers/icons/icons.json index 8c34354282..4e8c386074 100644 --- a/assets/layers/icons/icons.json +++ b/assets/layers/icons/icons.json @@ -14,7 +14,8 @@ { "id": "wikipedialink", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "render": "Wikipedia", "condition": { @@ -66,10 +67,23 @@ ], "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", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "render": "phone", "mappings": [ @@ -89,7 +103,8 @@ { "id": "emaillink", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "render": "email", "mappings": [ @@ -109,7 +124,8 @@ { "id": "websitelink", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "render": "website", "condition": "website~*" @@ -117,7 +133,8 @@ { "id": "smokingicon", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "mappings": [ { @@ -140,6 +157,15 @@ "render": "{share_link()}", "metacondition": "_supports_sharing=yes" }, + { + "id": "favourite_title_icon", + "labels": [ + "defaults" + ], + "render": { + "*": "{favourite_icon()}" + } + }, { "id": "osmlink", "labels": [ @@ -162,7 +188,8 @@ { "id": "dogicon", "labels": [ - "defaults" + "defaults", + "in_favourite" ], "mappings": [ { @@ -193,6 +220,13 @@ "class": "w-20 mx-1 flex items-center" }, "render": "{rating()}" + }, + { + "id": "favourite_icon", + "description": "Only for rendering", + "condition": "_favourite=yes", + "icon": "circle:white;heart:red", + "metacondition": "__showTimeSensitiveIcons!=no" } ] } diff --git a/assets/svg/center.svg b/assets/svg/center.svg new file mode 100644 index 0000000000..d2c0ce8d41 --- /dev/null +++ b/assets/svg/center.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/assets/svg/center.svg.license b/assets/svg/center.svg.license new file mode 100644 index 0000000000..ed02883002 --- /dev/null +++ b/assets/svg/center.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Pieter Vander Vennet +SPDX-License-Identifier: CC0-1.0 \ No newline at end of file diff --git a/assets/svg/license_info.json b/assets/svg/license_info.json index d6ea4605ae..0ba4ac2b90 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -153,6 +153,14 @@ "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", "license": "CC0-1.0", diff --git a/assets/themes/climbing/climbing.json b/assets/themes/climbing/climbing.json index da398b01a1..82a23b6be1 100644 --- a/assets/themes/climbing/climbing.json +++ b/assets/themes/climbing/climbing.json @@ -69,10 +69,12 @@ }, "+titleIcons": [ { + "id": "climbing_length", "render": "
{climbing:length}m
", "condition": "climbing:length~*" }, { + "id": "climbing_bolts", "mappings": [ { "if": "__bolts_max~*", @@ -95,6 +97,7 @@ "render": "
{__difficulty_max}
" }, { + "id": "difficulty", "render": "
{climbing:grade:french}
", "condition": "__difficulty:char~*" } diff --git a/assets/themes/sidewalks/sidewalks.json b/assets/themes/sidewalks/sidewalks.json index 03b439468d..459225a4a7 100644 --- a/assets/themes/sidewalks/sidewalks.json +++ b/assets/themes/sidewalks/sidewalks.json @@ -166,31 +166,31 @@ { "if": "sidewalk:left|right=yes", "then": { - "en": "Yes, there is a sidewalk on this side of the road", - "de": "Ja, es gibt einen Bürgersteig auf dieser Straßenseite", - "da": "Ja, der er et fortov på denne side af vejen", - "nl": "Ja, er is een stoep aan deze kant van de weg", - "fr": "Oui, il y a un trottoir de ce côté de la route", - "ca": "Sí, hi ha una vorera a aquest costat del carrer", - "es": "Sí, hay una acera en este lado de la calle", - "cs": "Ano, na této straně silnice je chodník", - "it": "Sì, c'è un marciapiede su questo lato della strada", - "pl": "Tak, jest chodnik z boku drogi" + "en": "There is a sidewalk on this side of the road", + "de": "Es gibt einen Bürgersteig auf dieser Straßenseite", + "da": "Der er et fortov på denne side af vejen", + "nl": "Er is een stoep aan deze kant van de weg", + "fr": "Il y a un trottoir de ce côté de la route", + "ca": "Hi ha una vorera a aquest costat del carrer", + "es": "Hay una acera en este lado de la calle", + "cs": "Na této straně silnice je chodník", + "it": "C'è un marciapiede su questo lato della strada", + "pl": "Jest chodnik z boku drogi" } }, { "if": "sidewalk:left|right=no", "then": { - "en": "No, there is no sidewalk to walk on", - "de": "Nein, es gibt keinen Bürgersteig für Fußgänger", - "da": "Nej, der er ikke noget fortov at gå på", - "nl": "Nee, er is geen stoep om op te lopen", - "fr": "Non, il n'y a pas de trottoir où marcher", - "ca": "No, no hi ha vorera per la que caminar", - "es": "No, no hay acera por la que caminar", - "cs": "Ne, není tu žádný chodník", - "it": "No, non c'è un marciapiede su cui camminare", - "pl": "Nie, nie ma chodnika, którym można chodzić" + "en": "There is no sidewalk to walk on", + "de": "Es gibt keinen Bürgersteig für Fußgänger", + "da": "Der er ikke noget fortov at gå på", + "nl": "Er is geen stoep om op te lopen", + "fr": "Il n'y a pas de trottoir où marcher", + "ca": "No hi ha vorera per la que caminar", + "es": "No hay acera por la que caminar", + "cs": "Není tu žádný chodník", + "it": "Non c'è un marciapiede su cui camminare", + "pl": "Nie ma chodnika, którym można chodzić" } }, { diff --git a/assets/themes/walkingnodes/walkingnodes.json b/assets/themes/walkingnodes/walkingnodes.json index d27754160f..65076cb820 100644 --- a/assets/themes/walkingnodes/walkingnodes.json +++ b/assets/themes/walkingnodes/walkingnodes.json @@ -349,4 +349,4 @@ } } ] -} \ No newline at end of file +} diff --git a/langs/en.json b/langs/en.json index 2a9968421a..b722dd1d4a 100644 --- a/langs/en.json +++ b/langs/en.json @@ -50,6 +50,22 @@ "panelIntro": "

Your personal theme

Activate your favourite layers from all the official themes", "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": { "aerial": "This map uses a different background, namely aerial imagery by Agentschap Informatie Vlaanderen", "callToAction": "Test it on mapcomplete.org", @@ -404,6 +420,7 @@ "key": "Key combination", "openLayersPanel": "Opens the layers and filters panel", "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", "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", diff --git a/package.json b/package.json index 05473cf6e9..d51d0025cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcomplete", - "version": "0.35.2", + "version": "0.36.0", "repository": "https://github.com/pietervdvn/MapComplete", "description": "A small website to edit OSM easily", "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", "optimize-images": "cd assets/generated/ && find -name '*.png' -exec optipng '{}' \\; && echo 'PNGs are optimized'", "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:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -", "prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh", diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index 26b1638ae8..ae394847be 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -745,6 +745,10 @@ video { top: 2.5rem; } +.left-1\/4 { + left: 25%; +} + .isolate { isolation: isolate; } @@ -765,10 +769,6 @@ video { float: left; } -.m-8 { - margin: 2rem; -} - .m-4 { margin: 1rem; } @@ -781,6 +781,10 @@ video { margin: 0px; } +.m-8 { + margin: 2rem; +} + .m-2 { margin: 0.5rem; } @@ -841,10 +845,6 @@ video { margin-right: 3rem; } -.mb-4 { - margin-bottom: 1rem; -} - .mt-4 { margin-top: 1rem; } @@ -881,6 +881,10 @@ video { margin-right: 0.25rem; } +.mb-4 { + margin-bottom: 1rem; +} + .ml-1 { margin-left: 0.25rem; } @@ -1088,6 +1092,10 @@ video { height: 2.75rem; } +.h-5 { + height: 1.25rem; +} + .h-48 { height: 12rem; } @@ -1198,6 +1206,14 @@ video { width: 50%; } +.w-14 { + width: 3.5rem; +} + +.w-5 { + width: 1.25rem; +} + .w-10 { width: 2.5rem; } @@ -1289,6 +1305,10 @@ video { appearance: none; } +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -1441,6 +1461,14 @@ video { align-self: center; } +.justify-self-start { + justify-self: start; +} + +.justify-self-end { + justify-self: end; +} + .overflow-auto { overflow: auto; } @@ -2335,6 +2363,16 @@ button.disabled:hover, .button.disabled:hover { 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 { fill: var(--interactive-foreground) !important; } diff --git a/scripts/build.sh b/scripts/build.sh index d352e3166c..697ddff4a4 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -10,7 +10,7 @@ mkdir dist 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 npm run generate:editor-layer-index && @@ -48,7 +48,7 @@ else exit 1 fi -export NODE_OPTIONS=--max-old-space-size=7000 +export NODE_OPTIONS=--max-old-space-size=16000 which vite vite build --sourcemap # Copy the layer files, as these might contain assets (e.g. svgs) diff --git a/scripts/generateFavouritesLayer.ts b/scripts/generateFavouritesLayer.ts new file mode 100644 index 0000000000..29b25d17d2 --- /dev/null +++ b/scripts/generateFavouritesLayer.ts @@ -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 = 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 = 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() + 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 = 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: tr.render, + }) + } + } else { + mappings.push({ if: tags, then: >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 { + 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() diff --git a/scripts/generateIncludedImages.ts b/scripts/generateIncludedImages.ts index dd7160a40e..6e0d643481 100644 --- a/scripts/generateIncludedImages.ts +++ b/scripts/generateIncludedImages.ts @@ -27,7 +27,8 @@ function genImages(dryrun = false) { "star_outline", "star", "osm_logo_us", - + "triangle", + "teardrop_with_hole_green", "SocialImageForeground", "wikipedia", "Upload", diff --git a/scripts/generateLayerOverview.ts b/scripts/generateLayerOverview.ts index 32f06ee64a..dcfbb71ad7 100644 --- a/scripts/generateLayerOverview.ts +++ b/scripts/generateLayerOverview.ts @@ -28,6 +28,7 @@ import { QuestionableTagRenderingConfigJson } from "../src/Models/ThemeConfig/Js import LayerConfig from "../src/Models/ThemeConfig/LayerConfig" import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig" 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. // It spits out an overview of those to be used to load them @@ -381,16 +382,11 @@ class LayerOverviewUtils extends Script { forceReload ) - writeFileSync( - "./src/assets/generated/known_themes.json", - JSON.stringify({ - themes: Array.from(sharedThemes.values()), - }) - ) - writeFileSync( "./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" @@ -428,6 +424,19 @@ class LayerOverviewUtils extends Script { 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 millisNeeded = end.getTime() - start.getTime() if (AllSharedLayers.getSharedLayersConfigs().size == 0) { @@ -791,4 +800,5 @@ class LayerOverviewUtils extends Script { } } +new GenerateFavouritesLayer().run() new LayerOverviewUtils().run() diff --git a/scripts/generateStats.ts b/scripts/generateStats.ts index 1dd5ced370..2931cbaf79 100644 --- a/scripts/generateStats.ts +++ b/scripts/generateStats.ts @@ -4,6 +4,8 @@ import { TagUtils } from "../src/Logic/Tags/TagUtils" import { Utils } from "../src/Utils" import { writeFileSync } from "fs" 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 */ @@ -21,7 +23,12 @@ async function main(includeTags = true) { 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() for (const key of allKeys) { if (!keysAndTags.has(key)) { @@ -68,6 +75,8 @@ async function main(includeTags = true) { "./src/assets/key_totals.json", JSON.stringify( { + "#": "Generated with generateStats.ts", + date: new Date().toISOString(), keys: Utils.MapToObj(keyTotal, (t) => t), tags: Utils.MapToObj(tagTotal, (v) => Utils.MapToObj(v, (t) => t)), }, diff --git a/src/Customizations/AllKnownLayouts.ts b/src/Customizations/AllKnownLayouts.ts index 12c27223c5..6d3ada24bb 100644 --- a/src/Customizations/AllKnownLayouts.ts +++ b/src/Customizations/AllKnownLayouts.ts @@ -1,45 +1,54 @@ import known_themes from "../assets/generated/known_themes.json" import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" +import favourite from "../assets/generated/layers/favourite.json" 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 */ export class AllKnownLayoutsLazy { - private readonly dict: Map LayoutConfig }> = - new Map() - constructor() { + private readonly raw: Map = new Map() + private readonly dict: Map = new Map() + + constructor(includeFavouriteLayer = true) { for (const layoutConfigJson of known_themes["themes"]) { - this.dict.set(layoutConfigJson.id, { - func: () => { - const layout = new LayoutConfig(layoutConfigJson, true) - for (let i = 0; i < layout.layers.length; i++) { - let layer = layout.layers[i] - if (typeof layer === "string") { - throw "Layer " + layer + " was not expanded in " + layout.id - } + for (const layerId of Constants.added_by_default) { + if (layerId === "favourite" && favourite.id) { + if (includeFavouriteLayer) { + layoutConfigJson.layers.push(favourite) } - 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 { - const thunk = this.dict.get(key) - if (thunk === undefined) { - return undefined + const cached = this.dict.get(key) + if (cached !== undefined) { + return cached } - if (thunk["data"]) { - return thunk["data"] - } - const layout = thunk["func"]() - this.dict.set(key, { data: layout }) + + const layout = new LayoutConfig(this.getConfig(key)) + this.dict.set(key, layout) return layout } public keys() { - return this.dict.keys() + return this.raw.keys() } public values() { diff --git a/src/Logic/Actors/SelectedElementTagsUpdater.ts b/src/Logic/Actors/SelectedElementTagsUpdater.ts index 91078a53b5..8bc510c56b 100644 --- a/src/Logic/Actors/SelectedElementTagsUpdater.ts +++ b/src/Logic/Actors/SelectedElementTagsUpdater.ts @@ -6,13 +6,21 @@ import { Changes } from "../Osm/Changes" import { OsmConnection } from "../Osm/OsmConnection" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import SimpleMetaTagger from "../SimpleMetaTagger" -import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" import { Feature } from "geojson" import { OsmTags } from "../../Models/OsmFeature" import OsmObjectDownloader from "../Osm/OsmObjectDownloader" import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" import { Utils } from "../../Utils" +interface TagsUpdaterState { + selectedElement: UIEventSource + featureProperties: { getStore: (id: string) => UIEventSource> } + changes: Changes + osmConnection: OsmConnection + layout: LayoutConfig + osmObjectDownloader: OsmObjectDownloader + indexedFeatures: IndexedFeatureSource +} export default class SelectedElementTagsUpdater { private static readonly metatags = new Set([ "timestamp", @@ -23,38 +31,18 @@ export default class SelectedElementTagsUpdater { "id", ]) - private readonly state: { - selectedElement: UIEventSource - featureProperties: FeaturePropertiesStore - changes: Changes - osmConnection: OsmConnection - layout: LayoutConfig - osmObjectDownloader: OsmObjectDownloader - indexedFeatures: IndexedFeatureSource - } - - constructor(state: { - selectedElement: UIEventSource - featureProperties: FeaturePropertiesStore - indexedFeatures: IndexedFeatureSource - changes: Changes - osmConnection: OsmConnection - layout: LayoutConfig - osmObjectDownloader: OsmObjectDownloader - }) { - this.state = state + constructor(state: TagsUpdaterState) { state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => { if (!isLoggedIn && !Utils.runningFromConsole) { return } - this.installCallback() + this.installCallback(state) // We only have to do this once... return true }) } - private installCallback() { - const state = this.state + private installCallback(state: TagsUpdaterState) { state.selectedElement.addCallbackAndRunD(async (s) => { let id = s.properties?.id if (!id) { @@ -94,7 +82,7 @@ export default class SelectedElementTagsUpdater { oldFeature.geometry = newGeometry state.featureProperties.getStore(id)?.ping() } - this.applyUpdate(latestTags, id) + SelectedElementTagsUpdater.applyUpdate(latestTags, id, state) console.log("Updated", id) } catch (e) { @@ -102,8 +90,7 @@ export default class SelectedElementTagsUpdater { } }) } - private applyUpdate(latestTags: OsmTags, id: string) { - const state = this.state + public static applyUpdate(latestTags: OsmTags, id: string, state: TagsUpdaterState) { try { const leftRightSensitive = state.layout.isLeftRightSensitive() @@ -162,11 +149,16 @@ export default class SelectedElementTagsUpdater { } 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() } else { console.debug("Fetched latest tags for ", id, "but detected no changes") } + return currentTags } catch (e) { console.error("Updating the tags of selected element ", id, "failed due to", e) } diff --git a/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts b/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts new file mode 100644 index 0000000000..1f2764062b --- /dev/null +++ b/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts @@ -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 + + /** + * All favourites, including the ones which are filtered away because they are already displayed + */ + public readonly allFavourites: Store + + constructor(state: SpecialVisualizationState) { + const features: Store = Stores.ListStabilized( + state.osmConnection.preferencesHandler.preferences.map((prefs) => { + const feats: Feature[] = [] + const allIds = new Set() + 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): 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, + id: string + ): Record { + const properties: Record = {} + 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) { + 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>) { + 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 & { 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> }, + 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() + } + } + } + } +} diff --git a/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts b/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts index 669c86a114..703103bc01 100644 --- a/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts @@ -6,10 +6,14 @@ import FilteringFeatureSource from "./FilteringFeatureSource" import LayerState from "../../State/LayerState" export default class NearbyFeatureSource implements FeatureSource { + private readonly _result = new UIEventSource(undefined) + public readonly features: Store private readonly _targetPoint: Store<{ lon: number; lat: number }> private readonly _numberOfNeededFeatures: number + private readonly _layerState?: LayerState private readonly _currentZoom: Store + private readonly _allSources: Store<{ feat: Feature; d: number }[]>[] = [] constructor( targetPoint: Store<{ lon: number; lat: number }>, @@ -18,43 +22,46 @@ export default class NearbyFeatureSource implements FeatureSource { layerState?: LayerState, currentZoom?: Store ) { + this._layerState = layerState this._targetPoint = targetPoint.stabilized(100) this._numberOfNeededFeatures = numberOfNeededFeatures this._currentZoom = currentZoom.stabilized(500) - const allSources: Store<{ feat: Feature; d: number }[]>[] = [] - let minzoom = 999 - - const result = new UIEventSource(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)) - } + this.features = Stores.ListStabilized(this._result) sources.forEach((source, layer) => { - const flayer = layerState?.filteredLayers.get(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) + this.registerSource(source, layer) }) } + 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 */ diff --git a/src/Logic/GeoOperations.ts b/src/Logic/GeoOperations.ts index e03cc13b1b..61128d1b06 100644 --- a/src/Logic/GeoOperations.ts +++ b/src/Logic/GeoOperations.ts @@ -501,147 +501,43 @@ export class GeoOperations { ) } - public static IdentifieCommonSegments(coordinatess: [number, number][][]): { - originalIndex: number - segmentShardWith: number[] - coordinates: [] - }[] { - // 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]) - type edge = { - start: [number, number] - end: [number, number] - intermediate: [number, number][] - members: { index: number; isReversed: boolean }[] + /** + * Given a list of points, convert into a GPX-list, e.g. for favourites + * @param locations + * @param title + */ + public static toGpxPoints( + locations: Feature[], + title?: string + ) { + title = title?.trim() + if (title === undefined || title === "") { + title = "Created with MapComplete" } - - // The strategy: - // 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them - // 2. Join these edges back together - as long as their membership groups are the same - // 3. Convert to results - - const allEdgesByKey = new Map() - - for (let index = 0; index < coordinatess.length; index++) { - 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 + title = Utils.EncodeXmlValue(title) + const trackPoints: string[] = [] + for (const l of locations) { + let trkpt = ` ` + for (const key in l.properties) { + const keyCleaned = key.replaceAll(":", "__") + trkpt += ` <${keyCleaned}>${l.properties[key]}\n` + if (key === "website") { + trkpt += ` ${l.properties[key]}\n` } - 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 += " \n" + trackPoints.push(trkpt) } - - // Lets merge them back together! - - let didMergeSomething = false - let allMergedEdges = Array.from(allEdgesByKey.values()) - const allEdgesByStartPoint = new Map() - for (const edge of allMergedEdges) { - edge.members.sort((m0, m1) => m0.index - m1.index) - - 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() - 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 [] + const header = + '' + return ( + header + + "\n" + + title + + "\n\n" + + trackPoints.join("\n") + + "\n" + ) } /** diff --git a/src/Logic/ImageProviders/ImageUploadManager.ts b/src/Logic/ImageProviders/ImageUploadManager.ts index 07a9ff3aea..17b3b8f56a 100644 --- a/src/Logic/ImageProviders/ImageUploadManager.ts +++ b/src/Logic/ImageProviders/ImageUploadManager.ts @@ -107,7 +107,8 @@ export class ImageUploadManager { title, description, file, - targetKey + targetKey, + tags.data["_orig_theme"] ) if (!isNaN(Number(featureId))) { // This is a map note @@ -126,7 +127,8 @@ export class ImageUploadManager { title: string, description: string, blob: File, - targetKey: string | undefined + targetKey: string | undefined, + theme?: string ): Promise { this.increaseCountFor(this._uploadStarted, featureId) const properties = this._featureProperties.getStore(featureId) @@ -148,7 +150,7 @@ export class ImageUploadManager { console.log("Uploading done, creating action for", featureId) key = targetKey ?? key const action = new LinkImageAction(featureId, key, value, properties, { - theme: this._layout.id, + theme: theme ?? this._layout.id, changeType: "add-image", }) this.increaseCountFor(this._uploadFinished, featureId) diff --git a/src/Logic/Osm/OsmPreferences.ts b/src/Logic/Osm/OsmPreferences.ts index 778b741365..1035dec823 100644 --- a/src/Logic/Osm/OsmPreferences.ts +++ b/src/Logic/Osm/OsmPreferences.ts @@ -12,6 +12,10 @@ export class OsmPreferences { "all-osm-preferences", {} ) + /** + * A map containing the individual preference sources + * @private + */ private readonly preferenceSources = new Map>() private auth: any private userDetails: UIEventSource @@ -21,7 +25,10 @@ export class OsmPreferences { this.auth = auth this.userDetails = osmConnection.userDetails const self = this - osmConnection.OnLoggedIn(() => self.UpdatePreferences()) + osmConnection.OnLoggedIn(() => { + self.UpdatePreferences(true) + return true + }) } /** @@ -72,11 +79,19 @@ export class OsmPreferences { let i = 0 while (str !== "") { if (str === undefined || str === "undefined") { + source.setData(undefined) throw ( "Got 'undefined' or a literal string containing 'undefined' for a long preference with name " + key ) } + if (str === "undefined") { + source.setData(undefined) + throw ( + "Got a literal string containing 'undefined' for a long preference with name " + + key + ) + } if (i > 100) { throw "This long preference is getting very long... " } @@ -197,7 +212,7 @@ export class OsmPreferences { }) } - private UpdatePreferences() { + private UpdatePreferences(forceUpdate?: boolean) { const self = this this.auth.xhr( { @@ -210,11 +225,22 @@ export class OsmPreferences { return } const prefs = value.getElementsByTagName("preference") + const seenKeys = new Set() for (let i = 0; i < prefs.length; i++) { const pref = prefs[i] const k = pref.getAttribute("k") const v = pref.getAttribute("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 @@ -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() + } } diff --git a/src/Logic/State/UserRelatedState.ts b/src/Logic/State/UserRelatedState.ts index f38f32badd..2fbbdc3697 100644 --- a/src/Logic/State/UserRelatedState.ts +++ b/src/Logic/State/UserRelatedState.ts @@ -294,6 +294,9 @@ export default class UserRelatedState { osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => { for (const k in newPrefs) { const v = newPrefs[k] + if (v === "undefined" || !v) { + continue + } if (k.endsWith("-combined-length")) { const l = Number(v) const key = k.substring(0, k.length - "length".length) @@ -308,7 +311,6 @@ export default class UserRelatedState { } amendedPrefs.ping() - console.log("Amended prefs are:", amendedPrefs.data) }) const translationMode = osmConnection.GetPreference("translation-mode") diff --git a/src/Logic/Tags/And.ts b/src/Logic/Tags/And.ts index 378518dd14..dff0919f87 100644 --- a/src/Logic/Tags/And.ts +++ b/src/Logic/Tags/And.ts @@ -3,6 +3,7 @@ import { Or } from "./Or" import { TagUtils } from "./TagUtils" import { Tag } from "./Tag" import { RegexTag } from "./RegexTag" +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" export class And extends TagsFilter { public and: TagsFilter[] @@ -72,6 +73,10 @@ export class And extends TagsFilter { return allChoices } + asJson(): TagConfigJson { + return { and: this.and.map((a) => a.asJson()) } + } + asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record) { return this.and .map((t) => { @@ -228,6 +233,15 @@ export class And extends TagsFilter { 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 { if (this.and.length === 0) { return true @@ -289,9 +303,17 @@ export class And extends TagsFilter { optimized.splice(i, 1) i-- } - } else if (v !== opt.value) { - // detected an internal conflict - return false + } else { + if (!v.match(opt.value)) { + // 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( (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() if (result === false) { return false diff --git a/src/Logic/Tags/ComparingTag.ts b/src/Logic/Tags/ComparingTag.ts index 685cd2550a..8009662f4b 100644 --- a/src/Logic/Tags/ComparingTag.ts +++ b/src/Logic/Tags/ComparingTag.ts @@ -1,18 +1,23 @@ import { TagsFilter } from "./TagsFilter" +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" +import { Tag } from "./Tag" export default class ComparingTag implements TagsFilter { private readonly _key: string private readonly _predicate: (value: string) => boolean - private readonly _representation: string + private readonly _representation: "<" | ">" | "<=" | ">=" + private readonly _boundary: string constructor( key: string, predicate: (value: string | undefined) => boolean, - representation: string = "" + representation: "<" | ">" | "<=" | ">=", + boundary: string ) { this._key = key this._predicate = predicate this._representation = representation + this._boundary = boundary } asChange(properties: Record): { k: string; v: string }[] { @@ -20,15 +25,64 @@ export default class ComparingTag implements TagsFilter { } asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record) { - return this._key + this._representation + return this._key + this._representation + this._boundary } asOverpass(): string[] { 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 { - 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 { @@ -38,7 +92,7 @@ export default class ComparingTag implements TagsFilter { /** * 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: 41}) // => true * t.matchesProperties({key: 0}) // => true @@ -56,6 +110,10 @@ export default class ComparingTag implements TagsFilter { return [] } + asJson(): TagConfigJson { + return this._key + this._representation + } + optimize(): TagsFilter | boolean { return this } diff --git a/src/Logic/Tags/Or.ts b/src/Logic/Tags/Or.ts index f7018c40b0..a0c0f6622c 100644 --- a/src/Logic/Tags/Or.ts +++ b/src/Logic/Tags/Or.ts @@ -1,6 +1,7 @@ import { TagsFilter } from "./TagsFilter" import { TagUtils } from "./TagUtils" import { And } from "./And" +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" export class Or extends TagsFilter { public or: TagsFilter[] @@ -27,6 +28,10 @@ export class Or extends TagsFilter { return false } + asJson(): TagConfigJson { + return { or: this.or.map((o) => o.asJson()) } + } + /** * * import {Tag} from "./Tag"; @@ -157,6 +162,12 @@ export class Or extends TagsFilter { 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 { if (this.or.length === 0) { return false @@ -174,9 +185,9 @@ export class Or extends TagsFilter { const newOrs: TagsFilter[] = [] let containedAnds: And[] = [] for (const tf of optimized) { - if (tf instanceof Or) { + if (tf["or"]) { // expand all the nested ors... - newOrs.push(...tf.or) + newOrs.push(...tf["or"]) } else if (tf instanceof And) { // partition of all the ands containedAnds.push(tf) @@ -191,7 +202,7 @@ export class Or extends TagsFilter { const cleanedContainedANds: And[] = [] outer: for (let containedAnd of containedAnds) { 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) // newOrs (and thus known): (K=V) --> false const cleaned = containedAnd.removePhraseConsideredKnown(known, false) @@ -236,16 +247,21 @@ export class Or extends TagsFilter { const elements = containedAnd.and.filter( (candidate) => !commonValues.some((cv) => cv.shadows(candidate)) ) + if (elements.length == 0) { + continue + } 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() if (result === true) { return true } else if (result === false) { // neutral element: skip - } else { + } else if (commonValues.length > 0) { newOrs.push(And.construct(commonValues)) } } diff --git a/src/Logic/Tags/RegexTag.ts b/src/Logic/Tags/RegexTag.ts index e6486113a7..f024f82517 100644 --- a/src/Logic/Tags/RegexTag.ts +++ b/src/Logic/Tags/RegexTag.ts @@ -1,5 +1,6 @@ import { Tag } from "./Tag" import { TagsFilter } from "./TagsFilter" +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" export class RegexTag extends TagsFilter { public readonly key: RegExp | string @@ -11,6 +12,9 @@ export class RegexTag extends TagsFilter { super() this.key = key 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.matchesEmpty = RegexTag.doesMatch("", this.value) } @@ -41,11 +45,21 @@ export class RegexTag extends TagsFilter { return possibleRegex.test(fromTag) } - private static source(r: string | RegExp) { + private static source(r: string | RegExp, includeStartMarker: boolean = true) { if (typeof r === "string") { 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 { return false } @@ -293,7 +325,7 @@ export class RegexTag extends TagsFilter { if (typeof this.key === "string") { return [this.key] } - throw "Key cannot be determined as it is a regex" + return [] } usedTags(): { key: string; value: string }[] { diff --git a/src/Logic/Tags/SubstitutingTag.ts b/src/Logic/Tags/SubstitutingTag.ts index 7d5435a068..6e9d293116 100644 --- a/src/Logic/Tags/SubstitutingTag.ts +++ b/src/Logic/Tags/SubstitutingTag.ts @@ -1,6 +1,7 @@ import { TagsFilter } from "./TagsFilter" import { Tag } from "./Tag" 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. @@ -45,6 +46,10 @@ export default class SubstitutingTag implements TagsFilter { ) } + asJson(): TagConfigJson { + return this._key + (this._invert ? "!" : "") + ":=" + this._value + } + asOverpass(): string[] { throw "A variable with substitution can not be used to query overpass" } diff --git a/src/Logic/Tags/Tag.ts b/src/Logic/Tags/Tag.ts index 7cb01a9d5e..b532b7053c 100644 --- a/src/Logic/Tags/Tag.ts +++ b/src/Logic/Tags/Tag.ts @@ -1,5 +1,6 @@ import { Utils } from "../../Utils" import { TagsFilter } from "./TagsFilter" +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" export class Tag extends TagsFilter { public key: string @@ -67,6 +68,10 @@ export class Tag extends TagsFilter { return [`["${this.key}"="${this.value}"]`] } + asJson(): TagConfigJson { + return this.key + "=" + this.value + } + /** const t = new Tag("key", "value") diff --git a/src/Logic/Tags/TagUtils.ts b/src/Logic/Tags/TagUtils.ts index adc8629229..1893399cec 100644 --- a/src/Logic/Tags/TagUtils.ts +++ b/src/Logic/Tags/TagUtils.ts @@ -15,13 +15,14 @@ type Tags = Record export type UploadableTag = Tag | SubstitutingTag | And export class TagUtils { - public static readonly comparators: ReadonlyArray<[string, (a: number, b: number) => boolean]> = - [ - ["<=", (a, b) => a <= b], - [">=", (a, b) => a >= b], - ["<", (a, b) => a < b], - [">", (a, b) => a > b], - ] + 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], + ] public static modeDocumentation: Record< string, { name: string; docs: string; uploadable?: boolean; overpassSupport: boolean } @@ -324,6 +325,14 @@ export class TagUtils { 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. * @@ -735,11 +744,10 @@ export class TagUtils { const tag = json as string for (const [operator, comparator] of TagUtils.comparators) { if (tag.indexOf(operator) >= 0) { - const split = Utils.SplitFirst(tag, operator) - - let val = Number(split[1].trim()) + const split = Utils.SplitFirst(tag, operator).map((v) => v.trim()) + let val = Number(split[1]) if (isNaN(val)) { - val = new Date(split[1].trim()).getTime() + val = new Date(split[1]).getTime() } const f = (value: string | number | undefined) => { @@ -762,7 +770,7 @@ export class TagUtils { } 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] } + 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 { const rta = a instanceof RegexTag const rtb = b instanceof RegexTag diff --git a/src/Logic/Tags/TagsFilter.ts b/src/Logic/Tags/TagsFilter.ts index b06158b4f9..e925a76ef6 100644 --- a/src/Logic/Tags/TagsFilter.ts +++ b/src/Logic/Tags/TagsFilter.ts @@ -1,3 +1,5 @@ +import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson" + export abstract class TagsFilter { abstract asOverpass(): string[] @@ -17,6 +19,8 @@ export abstract class TagsFilter { properties: Record ): string + abstract asJson(): TagConfigJson + abstract usedKeys(): string[] /** diff --git a/src/Logic/Web/MangroveReviews.ts b/src/Logic/Web/MangroveReviews.ts index 19e2228d49..8bcfe38076 100644 --- a/src/Logic/Web/MangroveReviews.ts +++ b/src/Logic/Web/MangroveReviews.ts @@ -14,7 +14,7 @@ export class MangroveIdentity { const keypairEventSource = new UIEventSource(undefined) this.keypair = keypairEventSource mangroveIdentity.addCallbackAndRunD(async (data) => { - if (data === "") { + if (!data) { return } const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data)) diff --git a/src/Models/Constants.ts b/src/Models/Constants.ts index f15232e3d7..bf2e0e5ccf 100644 --- a/src/Models/Constants.ts +++ b/src/Models/Constants.ts @@ -23,6 +23,7 @@ export default class Constants { "gps_track", "range", "last_click", + "favourite", ] as const /** * Special layers which are not included in a theme by default @@ -131,6 +132,8 @@ export default class Constants { "clock", "invalid", "close", + "heart", + "heart_outline", ] as const public static readonly defaultPinIcons: string[] = Constants._defaultPinIcons diff --git a/src/Models/MenuState.ts b/src/Models/MenuState.ts index 63dda397cf..1238580237 100644 --- a/src/Models/MenuState.ts +++ b/src/Models/MenuState.ts @@ -24,6 +24,7 @@ export class MenuState { public static readonly _menuviewTabs = [ "about", "settings", + "favourites", "community", "privacy", "advanced", diff --git a/src/Models/ThemeConfig/Conversion/Conversion.ts b/src/Models/ThemeConfig/Conversion/Conversion.ts index b6422bd55b..d740287316 100644 --- a/src/Models/ThemeConfig/Conversion/Conversion.ts +++ b/src/Models/ThemeConfig/Conversion/Conversion.ts @@ -2,6 +2,7 @@ import { LayerConfigJson } from "../Json/LayerConfigJson" import { Utils } from "../../../Utils" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" import { ConversionContext } from "./ConversionContext" +import { T } from "vitest/dist/types-aac763a5" export interface DesugaringContext { tagRenderings: Map @@ -81,18 +82,36 @@ export class Pure extends Conversion { } } +export class Bypass extends DesugaringStep { + private readonly _applyIf: (t: T) => boolean + private readonly _step: DesugaringStep + constructor(applyIf: (t: T) => boolean, step: DesugaringStep) { + 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 extends Conversion { private readonly _step: Conversion private readonly _msg: string + private readonly _filter: (x: X) => boolean - constructor(step: Conversion, msg?: string) { + constructor(step: Conversion, options?: { msg?: string }) { super( "Applies the given step on every element of the list", [], "OnEach(" + step.name + ")" ) this._step = step - this._msg = msg + this._msg = options?.msg } convert(values: X[], context: ConversionContext): Y[] { diff --git a/src/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts b/src/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts index c88eb65417..aa587e65e2 100644 --- a/src/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts +++ b/src/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts @@ -85,7 +85,7 @@ export default class CreateNoteImportLayer extends Conversion { } export class AddEditingElements extends DesugaringStep { + static addedElements: string[] = [ + "minimap", + "just_created", + "split_button", + "move_button", + "delete_button", + "last_edit", + "favourite_state", + "all_tags", + ] private readonly _desugaring: DesugaringContext constructor(desugaring: DesugaringContext) { @@ -636,6 +649,13 @@ export class AddEditingElements extends DesugaringStep { 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")) { const trc: QuestionableTagRenderingConfigJson = { id: "all-tags", @@ -1190,6 +1210,31 @@ class ExpandMarkerRenderings extends DesugaringStep { } } +class AddFavouriteBadges extends DesugaringStep { + 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 { constructor() { super( @@ -1203,6 +1248,10 @@ export class AddRatingBadge extends DesugaringStep { if (!json.tagRenderings) { return json } + if (json.titleIcons.some((ti) => ti === "icons.rating" || ti["id"] === "rating")) { + // already added + return json + } const specialVis: Exclude[] = < Exclude[] @@ -1238,23 +1287,28 @@ export class AutoTitleIcon extends DesugaringStep { continue } const trId = titleIcon.substring("auto:".length) - const tr = json.tagRenderings.find((tr) => tr["id"] === trId) + const tr = ( + json.tagRenderings.find((tr) => tr["id"] === trId) + ) if (tr === undefined) { - context - .enters("titleIcons", i) - .err("TagRendering with id " + trId + " not found") + context.enters("titleIcons", i).err("TagRendering with id " + trId + " not found") continue } - const mappings: { if: TagConfigJson, then: string }[] = tr.mappings?.filter(m => m.icon !== undefined) - .map(m => { + const mappings: { if: TagConfigJson; then: string }[] = tr.mappings + ?.filter((m) => m.icon !== undefined) + .map((m) => { const path: string = typeof m.icon === "string" ? m.icon : m.icon.path const img = `` - return ({ if: m.if, then: img }) + return { if: m.if, then: img } }) if (mappings.length === 0) { context .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 } json.titleIcons[i] = { @@ -1292,6 +1346,7 @@ export class PrepareLayer extends Fuse { ), new SetDefault("titleIcons", ["icons.defaults"]), new AddRatingBadge(), + new AddFavouriteBadges(), new AutoTitleIcon(), new On( "titleIcons", diff --git a/src/Models/ThemeConfig/Conversion/Validation.ts b/src/Models/ThemeConfig/Conversion/Validation.ts index 59130c1e9e..07c04ed307 100644 --- a/src/Models/ThemeConfig/Conversion/Validation.ts +++ b/src/Models/ThemeConfig/Conversion/Validation.ts @@ -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 LayerConfig from "../LayerConfig" import { Utils } from "../../../Utils" @@ -11,7 +11,6 @@ import { TagUtils } from "../../../Logic/Tags/TagUtils" import { ExtractImages } from "./FixImages" import { And } from "../../../Logic/Tags/And" import Translations from "../../../UI/i18n/Translations" -import Svg from "../../../Svg" import FilterConfigJson from "../Json/FilterConfigJson" import DeleteConfig from "../DeleteConfig" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" @@ -23,7 +22,7 @@ import { TagsFilter } from "../../../Logic/Tags/TagsFilter" import { Translatable } from "../Json/Translatable" import { ConversionContext } from "./ConversionContext" -class ValidateLanguageCompleteness extends DesugaringStep { +class ValidateLanguageCompleteness extends DesugaringStep { private readonly _languages: string[] constructor(...languages: string[]) { @@ -35,7 +34,9 @@ class ValidateLanguageCompleteness extends DesugaringStep { 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) for (const neededLanguage of this._languages) { translations @@ -57,7 +58,7 @@ class ValidateLanguageCompleteness extends DesugaringStep { ) }) } - + obj.layers = origLayers return obj } } @@ -276,9 +277,9 @@ export class ValidateThemeAndLayers extends Fuse { new On( "layers", new Each( - new Pipe( - new ValidateLayer(undefined, isBuiltin, doesImageExist, false, true), - new Pure((x) => x?.raw) + new Bypass( + (layer) => Constants.added_by_default.indexOf(layer.id) < 0, + new ValidateLayerConfig(undefined, isBuiltin, doesImageExist, false, true) ) ) ) @@ -974,7 +975,7 @@ export class ValidateTagRenderings extends Fuse { "Various validation on tagRenderingConfigs", new DetectShadowedMappings(layerConfig), new DetectConflictingAddExtraTags(), - new DetectNonErasedKeysInMappings(), + // TODO enable new DetectNonErasedKeysInMappings(), new DetectMappingsWithImages(doesImageExist), new On("render", new ValidatePossibleLinks()), new On("question", new ValidatePossibleLinks()), @@ -1356,6 +1357,34 @@ export class PrevalidateLayer extends DesugaringStep { } } +export class ValidateLayerConfig extends DesugaringStep { + 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< LayerConfigJson, { parsed: LayerConfig; raw: LayerConfigJson } diff --git a/src/Models/ThemeConfig/Json/LayerConfigJson.ts b/src/Models/ThemeConfig/Json/LayerConfigJson.ts index 50e5870bc4..7c81ddf4ca 100644 --- a/src/Models/ThemeConfig/Json/LayerConfigJson.ts +++ b/src/Models/ThemeConfig/Json/LayerConfigJson.ts @@ -245,7 +245,7 @@ export interface LayerConfigJson { * Type: icon[] * group: infobox */ - titleIcons?: (string | TagRenderingConfigJson)[] | ["defaults"] + titleIcons?: (string | (TagRenderingConfigJson & { id?: string }))[] | ["defaults"] /** * Creates points to render on the map. diff --git a/src/Models/ThemeConfig/LayoutConfig.ts b/src/Models/ThemeConfig/LayoutConfig.ts index ce538d6b29..77ee2ef489 100644 --- a/src/Models/ThemeConfig/LayoutConfig.ts +++ b/src/Models/ThemeConfig/LayoutConfig.ts @@ -305,6 +305,9 @@ export default class LayoutConfig implements LayoutInformation { } for (const layer of this.layers) { if (!layer.source) { + if (layer.isShown?.matchesProperties(tags)) { + return layer + } continue } if (layer.source.osmTags.matchesProperties(tags)) { diff --git a/src/Models/ThemeConfig/TagRenderingConfig.ts b/src/Models/ThemeConfig/TagRenderingConfig.ts index 424141bd48..17c8404893 100644 --- a/src/Models/ThemeConfig/TagRenderingConfig.ts +++ b/src/Models/ThemeConfig/TagRenderingConfig.ts @@ -16,10 +16,10 @@ import { } from "./Json/QuestionableTagRenderingConfigJson" import { FixedUiElement } from "../../UI/Base/FixedUiElement" import { Paragraph } from "../../UI/Base/Paragraph" -import Svg from "../../Svg" import Validators, { ValidatorType } from "../../UI/InputElement/Validators" import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" import Constants from "../Constants" +import { RegexTag } from "../../Logic/Tags/RegexTag" export interface Icon {} @@ -800,4 +800,25 @@ export default class TagRenderingConfig { labels, ]).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) + } } diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index 0cc0119e6c..1db7f7ac5e 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -58,6 +58,7 @@ import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLay import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" import { Imgur } from "../Logic/ImageProviders/Imgur" 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 currentView: FeatureSource> readonly featuresInView: FeatureSource + readonly favourites: FavouritesFeatureSource /** * Contains a few (<10) >features that are near the center of the map. */ - readonly closestFeatures: FeatureSource + readonly closestFeatures: NearbyFeatureSource readonly newFeatures: WritableFeatureSource readonly layerState: LayerState readonly perLayer: ReadonlyMap @@ -220,8 +222,6 @@ export default class ThemeViewState implements SpecialVisualizationState { this.fullNodeDatabase ) - this.indexedFeatures = layoutSource - let currentViewIndex = 0 const empty = [] this.currentView = new StaticFeatureSource( @@ -242,13 +242,13 @@ export default class ThemeViewState implements SpecialVisualizationState { this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds) 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( { dryRun: this.featureSwitches.featureSwitchIsTesting, - allElements: indexedElements, + allElements: layoutSource, featurePropertiesStore: this.featureProperties, osmConnection: this.osmConnection, historicalUserLocations: this.geolocation.historicalUserLocations, @@ -258,7 +258,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.historicalUserLocations = this.geolocation.historicalUserLocations this.newFeatures = new NewGeometryFromChangesFeatureSource( this.changes, - indexedElements, + layoutSource, this.featureProperties ) layoutSource.addSource(this.newFeatures) @@ -327,10 +327,10 @@ export default class ThemeViewState implements SpecialVisualizationState { return sorted }) - const lastClick = (this.lastClickObject = new LastClickFeatureSource( + this.lastClickObject = new LastClickFeatureSource( this.mapProperties.lastClickLocation, this.layout - )) + ) this.osmObjectDownloader = new OsmObjectDownloader( this.osmConnection.Backend(), @@ -353,6 +353,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.osmConnection, this.changes ) + this.favourites = new FavouritesFeatureSource(this) this.initActors() this.drawSpecialLayers() @@ -456,6 +457,7 @@ export default class ThemeViewState implements SpecialVisualizationState { * @private */ private selectClosestAtCenter(i: number = 0) { + this.mapProperties.lastKeyNavigation.setData(Date.now() / 1000) const toSelect = this.closestFeatures.features.data[i] if (!toSelect) { return @@ -465,6 +467,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.selectedLayer.setData(layer) this.selectedElement.setData(toSelect) } + private initHotkeys() { Hotkeys.RegisterHotkey( { 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((_) => { 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 */ @@ -627,7 +599,10 @@ export default class ThemeViewState implements SpecialVisualizationState { ) ), current_view: this.currentView, + favourite: this.favourites, } + + this.closestFeatures.registerSource(specialLayers.favourite, "favourite") if (this.layout?.lockLocation) { const bbox = new BBox(this.layout.lockLocation) this.mapProperties.maxbounds.setData(bbox) @@ -654,21 +629,23 @@ export default class ThemeViewState implements SpecialVisualizationState { } const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range") - const rangeIsDisplayed = rangeFLayer?.isDisplayed - if ( !QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef)) ) { 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) => { const id = flayer.layerDef.id const features: FeatureSource = specialLayers[id] if (features === undefined) { return } + if (id === "favourite") { + console.log("Matching special layer", id, flayer) + } this.featureProperties.trackFeatureSource(features) new ShowDataLayer(this.map, { diff --git a/src/UI/Base/LoginButton.svelte b/src/UI/Base/LoginButton.svelte index 504d487132..7381f7b781 100644 --- a/src/UI/Base/LoginButton.svelte +++ b/src/UI/Base/LoginButton.svelte @@ -11,7 +11,7 @@ diff --git a/src/UI/Base/LogoutButton.svelte b/src/UI/Base/LogoutButton.svelte index f42bcf7511..996fea08a8 100644 --- a/src/UI/Base/LogoutButton.svelte +++ b/src/UI/Base/LogoutButton.svelte @@ -4,12 +4,12 @@ import Translations from "../i18n/Translations" import Tr from "./Tr.svelte" - export let osmConnection: OsmConnection + export let osmConnection: OsmConnection; + + + + diff --git a/src/UI/Favourites/Favourites.svelte b/src/UI/Favourites/Favourites.svelte new file mode 100644 index 0000000000..eb438f11eb --- /dev/null +++ b/src/UI/Favourites/Favourites.svelte @@ -0,0 +1,68 @@ + + + +
+ + + +
+ +
console.log("Got keypress", e)}> + + + + {#each $favourites as feature (feature.properties.id)} + + {/each} + +
+ + +
+
+
diff --git a/src/UI/Map/DynamicIcon.svelte b/src/UI/Map/DynamicIcon.svelte index 51d7668c14..e7a28699e6 100644 --- a/src/UI/Map/DynamicIcon.svelte +++ b/src/UI/Map/DynamicIcon.svelte @@ -1,27 +1,7 @@ {#if icon} -
{#if icon === "pin"} - + {:else if icon === "square"} - + {:else if icon === "circle"} - + {:else if icon === "checkmark"} - + {:else if icon === "clock"} - + {:else if icon === "close"} - + {:else if icon === "crosshair"} - + {:else if icon === "help"} - + {:else if icon === "home"} - + {:else if icon === "invalid"} - + {:else if icon === "location"} - + {:else if icon === "location_empty"} - + {:else if icon === "location_locked"} - + {:else if icon === "note"} - + {:else if icon === "resolved"} - + {:else if icon === "ring"} - + {:else if icon === "scissors"} - + {:else if icon === "teardrop"} - + {:else if icon === "teardrop_with_hole_green"} - + {:else if icon === "triangle"} - + {:else if icon === "brick_wall_square"} - + {:else if icon === "brick_wall_round"} - + {:else if icon === "gps_arrow"} - + {:else if icon === "checkmark"} - + {:else if icon === "help"} - + {:else if icon === "close"} - + {:else if icon === "invalid"} - + + {:else if icon === "heart"} + + {:else if icon === "heart_outline"} + {:else} - + {/if} -
{/if} diff --git a/src/UI/Map/Marker.svelte b/src/UI/Map/Marker.svelte index f7b0d12e2d..6afd6b68e0 100644 --- a/src/UI/Map/Marker.svelte +++ b/src/UI/Map/Marker.svelte @@ -1,16 +1,18 @@ {#if icons !== undefined && icons.length > 0}
{#each icons as icon} - +
+ +
{/each}
{/if} diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 33be0dcdea..2f286148ab 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -12,11 +12,9 @@ import { Feature, Point } from "geojson" import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" import { Utils } from "../../Utils" import * as range_layer from "../../../assets/layers/range/range.json" -import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" import FilteredLayer from "../../Models/FilteredLayer" import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" -import { CLIENT_RENEG_LIMIT } from "tls" class PointRenderingLayer { private readonly _config: PointRenderingConfig diff --git a/src/UI/OpeningHours/NextChangeViz.svelte b/src/UI/OpeningHours/NextChangeViz.svelte new file mode 100644 index 0000000000..cdafa89037 --- /dev/null +++ b/src/UI/OpeningHours/NextChangeViz.svelte @@ -0,0 +1,50 @@ + + +{#if $currentState !== undefined} +
+ {#if $currentState === true} + + + {:else if $currentState === false} + + + {/if} + + {#if $nextChange !== undefined} + + {$nextChange} + + {/if} + +
+ +{/if} diff --git a/src/UI/OpeningHours/OpeningHours.ts b/src/UI/OpeningHours/OpeningHours.ts index cd62fdd794..400f44c56f 100644 --- a/src/UI/OpeningHours/OpeningHours.ts +++ b/src/UI/OpeningHours/OpeningHours.ts @@ -1,5 +1,6 @@ import { Utils } from "../../Utils" import opening_hours from "opening_hours" +import { Store } from "../../Logic/UIEventSource" export interface OpeningHour { weekday: number // 0 is monday, 1 is tuesday, ... @@ -494,10 +495,48 @@ This list will be sorted return [changeHours, changeHourText] } + public static CreateOhObjectStore( + tags: Store>, + key: string = "opening_hours", + prefixToIgnore?: string, + postfixToIgnore?: string + ): Store { + 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(tags.data, ohtext, country.data) + } catch (e) { + return "error" + } + }, + [country] + ) + } public static CreateOhObject( tags: Record & { _lat: number; _lon: number; _country?: string }, - textToParse: string + textToParse: string, + country?: string ) { // noinspection JSPotentiallyInvalidConstructorUsage return new opening_hours( @@ -506,7 +545,7 @@ This list will be sorted lat: tags._lat, lon: tags._lon, address: { - country_code: tags._country?.toLowerCase(), + country_code: country.toLowerCase(), state: undefined, }, }, diff --git a/src/UI/OpeningHours/OpeningHoursVisualization.ts b/src/UI/OpeningHours/OpeningHoursVisualization.ts index f51c225dff..560a2276d5 100644 --- a/src/UI/OpeningHours/OpeningHoursVisualization.ts +++ b/src/UI/OpeningHours/OpeningHoursVisualization.ts @@ -3,7 +3,6 @@ import Combine from "../Base/Combine" import { FixedUiElement } from "../Base/FixedUiElement" import { OH } from "./OpeningHours" import Translations from "../i18n/Translations" -import Constants from "../../Models/Constants" import BaseUIElement from "../BaseUIElement" import Toggle from "../Input/Toggle" import { VariableUiElement } from "../Base/VariableUIElement" @@ -30,48 +29,20 @@ export default class OpeningHoursVisualization extends Toggle { prefix = "", postfix = "" ) { - const country = tags.map((tags) => tags._country) + const openingHoursStore = OH.CreateOhObjectStore(tags, key, prefix, postfix) const ohTable = new VariableUiElement( - tags - .map((tags) => { - const value: string = tags[key] - if (value === undefined) { - return undefined - } - if (value.startsWith(prefix) && value.endsWith(postfix)) { - return value.substring(prefix.length, value.length - postfix.length).trim() - } - return value - }) // This mapping will absorb all other changes to tags in order to prevent regeneration - .map( - (ohtext) => { - if (ohtext === undefined) { - return new FixedUiElement( - "No opening hours defined with key " + key - ).SetClass("alert") - } - try { - return OpeningHoursVisualization.CreateFullVisualisation( - OH.CreateOhObject(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] - ) + openingHoursStore.map((opening_hours_obj) => { + if (opening_hours_obj === undefined) { + return new FixedUiElement("No opening hours defined with key " + key).SetClass( + "alert" + ) + } + + if (opening_hours_obj === "error") { + return Translations.t.general.opening_hours.error_loading + } + return OpeningHoursVisualization.CreateFullVisualisation(opening_hours_obj) + }) ) super( diff --git a/src/UI/Popup/AddNewPoint/AddNewPoint.svelte b/src/UI/Popup/AddNewPoint/AddNewPoint.svelte index ef2616257f..d28c573faf 100644 --- a/src/UI/Popup/AddNewPoint/AddNewPoint.svelte +++ b/src/UI/Popup/AddNewPoint/AddNewPoint.svelte @@ -161,7 +161,7 @@ 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, ...) --> - +
{#if $zoom < Constants.minZoomLevelToAddNewPoint} diff --git a/src/UI/Popup/AllTagsPanel.svelte b/src/UI/Popup/AllTagsPanel.svelte index 3abd7e4029..22ab0a769d 100644 --- a/src/UI/Popup/AllTagsPanel.svelte +++ b/src/UI/Popup/AllTagsPanel.svelte @@ -19,7 +19,7 @@ ...(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)[][] = [] for (const key in tags) { let v = tags[key] diff --git a/src/UI/Popup/ExportAsGpxViz.ts b/src/UI/Popup/ExportAsGpxViz.ts index c7821da100..a5acf85823 100644 --- a/src/UI/Popup/ExportAsGpxViz.ts +++ b/src/UI/Popup/ExportAsGpxViz.ts @@ -31,14 +31,16 @@ export class ExportAsGpxViz implements SpecialVisualization { t.downloadFeatureAsGpx.SetClass("font-bold text-lg"), t.downloadGpxHelper.SetClass("subtle"), ]).SetClass("flex flex-col") - ).onClick(() => { - console.log("Exporting as GPX!") - const tags = tagSource.data - const title = layer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track" - const gpx = GeoOperations.toGpx(>feature, title) - Utils.offerContentsAsDownloadableFile(gpx, title + "_mapcomplete_export.gpx", { - mimetype: "{gpx=application/gpx+xml}", + ) + .SetClass("w-full") + .onClick(() => { + console.log("Exporting as GPX!") + const tags = tagSource.data + const title = layer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track" + const gpx = GeoOperations.toGpx(>feature, title) + Utils.offerContentsAsDownloadableFile(gpx, title + "_mapcomplete_export.gpx", { + mimetype: "{gpx=application/gpx+xml}", + }) }) - }) } } diff --git a/src/UI/Popup/LinkableImage.svelte b/src/UI/Popup/LinkableImage.svelte index d9290fbddb..a4776dc812 100644 --- a/src/UI/Popup/LinkableImage.svelte +++ b/src/UI/Popup/LinkableImage.svelte @@ -28,7 +28,6 @@ const t = Translations.t.image.nearby const c = [lon, lat] - console.log(">>>", image) let attributedImage = new AttributedImage({ url: image.thumbUrl ?? image.pictureUrl, provider: AllImageProviders.byName(image.provider), @@ -45,7 +44,7 @@ const url = image.osmTags[key] if (isLinked) { const action = new LinkImageAction(currentTags.id, key, url, tags, { - theme: state.layout.id, + theme: tags.data._orig_theme ?? state.layout.id, changeType: "link-image", }) state.changes.applyAction(action) @@ -54,7 +53,7 @@ const v = currentTags[k] if (v === url) { 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", }) state.changes.applyAction(action) diff --git a/src/UI/Popup/MarkAsFavourite.svelte b/src/UI/Popup/MarkAsFavourite.svelte new file mode 100644 index 0000000000..394e4a1c39 --- /dev/null +++ b/src/UI/Popup/MarkAsFavourite.svelte @@ -0,0 +1,48 @@ + + + +{#if $isFavourite} +
+ markFavourite(false)} /> +
+ +
+
+ +{:else} +
+ markFavourite(true)} /> + +
+{/if} +
diff --git a/src/UI/Popup/MarkAsFavouriteMini.svelte b/src/UI/Popup/MarkAsFavouriteMini.svelte new file mode 100644 index 0000000000..b8d242aacd --- /dev/null +++ b/src/UI/Popup/MarkAsFavouriteMini.svelte @@ -0,0 +1,36 @@ + + + + {#if $isFavourite} + + {:else} + + {/if} + diff --git a/src/UI/Popup/TagRendering/Questionbox.svelte b/src/UI/Popup/TagRendering/Questionbox.svelte index 33c0fbe105..412156b76d 100644 --- a/src/UI/Popup/TagRendering/Questionbox.svelte +++ b/src/UI/Popup/TagRendering/Questionbox.svelte @@ -3,16 +3,15 @@ * 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) */ - import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig" - import { UIEventSource } from "../../../Logic/UIEventSource" - import type { Feature } from "geojson" - import type { SpecialVisualizationState } from "../../SpecialVisualization" - import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" - import If from "../../Base/If.svelte" - import TagRenderingQuestion from "./TagRenderingQuestion.svelte" - import Tr from "../../Base/Tr.svelte" - import Translations from "../../i18n/Translations.js" - import { Utils } from "../../../Utils" + import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"; + import { UIEventSource } from "../../../Logic/UIEventSource"; + import type { Feature } from "geojson"; + import type { SpecialVisualizationState } from "../../SpecialVisualization"; + import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; + import TagRenderingQuestion from "./TagRenderingQuestion.svelte"; + import Tr from "../../Base/Tr.svelte"; + import Translations from "../../i18n/Translations.js"; + import { Utils } from "../../../Utils"; export let layer: LayerConfig export let tags: UIEventSource> diff --git a/src/UI/Popup/TagRendering/TagRenderingAnswer.svelte b/src/UI/Popup/TagRendering/TagRenderingAnswer.svelte index 243413fafa..62fdee263b 100644 --- a/src/UI/Popup/TagRendering/TagRenderingAnswer.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingAnswer.svelte @@ -26,6 +26,7 @@ onDestroy( tags.addCallbackAndRun((tags) => { _tags = tags + console.log("Getting render value for", _tags,config) trs = Utils.NoNull(config?.GetRenderValues(_tags)) }) ) diff --git a/src/UI/Popup/TagRendering/TagRenderingEditable.svelte b/src/UI/Popup/TagRendering/TagRenderingEditable.svelte index 877dfdcea1..8ff0794ab9 100644 --- a/src/UI/Popup/TagRendering/TagRenderingEditable.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingEditable.svelte @@ -11,7 +11,7 @@ import Translations from "../../i18n/Translations.js" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import { Utils } from "../../../Utils" - + import { twMerge } from "tailwind-merge" export let config: TagRenderingConfig export let tags: UIEventSource> export let selectedElement: Feature | undefined @@ -71,7 +71,7 @@ } -
+
{#if config.question && (!editingEnabled || $editingEnabled)} {#if editMode} diff --git a/src/UI/Popup/TagRendering/TagRenderingMapping.svelte b/src/UI/Popup/TagRendering/TagRenderingMapping.svelte index 56d3086818..242f206e7b 100644 --- a/src/UI/Popup/TagRendering/TagRenderingMapping.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingMapping.svelte @@ -6,6 +6,7 @@ import { UIEventSource } from "../../../Logic/UIEventSource" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import { twJoin } from "tailwind-merge" + import Icon from "../../Map/Icon.svelte"; export let selectedElement: Feature export let tags: UIEventSource> @@ -27,13 +28,8 @@ {#if mapping.icon !== undefined} -
- +
+
{:else if mapping.then !== undefined} diff --git a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte index 83c37af8eb..3705ec2361 100644 --- a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -1,188 +1,210 @@ {#if question !== undefined} @@ -246,9 +268,8 @@ bind:group={selectedMapping} name={"mappings-radio-" + config.id} value={i} - on:keypress={(e) => { - if (e.key === "Enter") onSave() - }} + on:keypress={e => onInputKeypress(e)} + /> {/each} @@ -259,6 +280,7 @@ bind:group={selectedMapping} name={"mappings-radio-" + config.id} value={config.mappings?.length} + on:keypress={e => onInputKeypress(e)} /> onInputKeypress(e)} /> {/each} @@ -299,6 +322,7 @@ type="checkbox" name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length} bind:checked={checkedMappings[config.mappings.length]} + on:keypress={e => onInputKeypress(e)} /> (checkedMappings[config.mappings.length] = true)} on:submit={onSave} /> diff --git a/src/UI/SpecialVisualization.ts b/src/UI/SpecialVisualization.ts index be3ffee159..6d1b595c11 100644 --- a/src/UI/SpecialVisualization.ts +++ b/src/UI/SpecialVisualization.ts @@ -17,6 +17,7 @@ import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" import { RasterLayerPolygon } from "../Models/RasterLayers" import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" import { OsmTags } from "../Models/OsmFeature" +import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource" /** * The state needed to render a special Visualisation. @@ -33,7 +34,6 @@ export interface SpecialVisualizationState { } readonly indexedFeatures: IndexedFeatureSource - /** * Some features will create a new element that should be displayed. * These can be injected by appending them to this featuresource (and pinging it) @@ -59,6 +59,8 @@ export interface SpecialVisualizationState { readonly selectedLayer: UIEventSource readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }> + readonly favourites: FavouritesFeatureSource + /** * If data is currently being fetched from external sources */ diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 5400f83160..7bfdf0b937 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -79,6 +79,9 @@ import ThemeViewState from "../Models/ThemeViewState" import LanguagePicker from "./InputElement/LanguagePicker.svelte" import LogoutButton from "./Base/LogoutButton.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 must be in SpecialVisualisations due to weird cyclical import that breaks the tests @@ -532,6 +535,9 @@ export default class SpecialVisualizations { feature: Feature, layer: LayerConfig ): BaseUIElement { + if (!layer.deletion) { + return undefined + } return new SvelteUIElement(DeleteWizard, { tags: tagSource, 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>, + 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", needsUrls: [], @@ -872,20 +918,22 @@ export default class SpecialVisualizations { t.downloadFeatureAsGeojson.SetClass("font-bold text-lg"), t.downloadGeoJsonHelper.SetClass("subtle"), ]).SetClass("flex flex-col") - ).onClick(() => { - console.log("Exporting as Geojson") - const tags = tagSource.data - const title = - layer?.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "geojson" - const data = JSON.stringify(feature, null, " ") - Utils.offerContentsAsDownloadableFile( - data, - title + "_mapcomplete_export.geojson", - { - mimetype: "application/vnd.geo+json", - } - ) - }) + ) + .onClick(() => { + console.log("Exporting as Geojson") + const tags = tagSource.data + const title = + layer?.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "geojson" + const data = JSON.stringify(feature, null, " ") + Utils.offerContentsAsDownloadableFile( + data, + title + "_mapcomplete_export.geojson", + { + mimetype: "application/vnd.geo+json", + } + ) + }) + .SetClass("w-full") }, }, { @@ -1482,7 +1530,7 @@ export default class SpecialVisualizations { const tags = (( state )).geolocation.currentUserLocation.features.map( - (features) => features[0].properties + (features) => features[0]?.properties ) return new SvelteUIElement(AllTagsPanel, { 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>, + 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>, + argument: string[], + feature: Feature, + layer: LayerConfig + ): BaseUIElement { + return new SvelteUIElement(MarkAsFavouriteMini, { + tags: tagSource, + state, + layer, + feature, + }) + }, + }, ] specialVisualizations.push(new AutoApplyButton(specialVisualizations)) diff --git a/src/UI/Studio/EditItemButton.svelte b/src/UI/Studio/EditItemButton.svelte index f5f1c10010..fcc57e6159 100644 --- a/src/UI/Studio/EditItemButton.svelte +++ b/src/UI/Studio/EditItemButton.svelte @@ -1,30 +1,30 @@ dispatch("layerSelected", info)}> diff --git a/src/UI/ThemeViewGUI.svelte b/src/UI/ThemeViewGUI.svelte index 4edddb4241..79e258195b 100644 --- a/src/UI/ThemeViewGUI.svelte +++ b/src/UI/ThemeViewGUI.svelte @@ -1,90 +1,91 @@ + \ No newline at end of file diff --git a/src/index.css b/src/index.css index 84cf251b0f..c9f89ccb78 100644 --- a/src/index.css +++ b/src/index.css @@ -280,6 +280,16 @@ button.disabled:hover, .button.disabled:hover { 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 { fill: var(--interactive-foreground) !important;; } diff --git a/test/Models/ThemeConfig/Conversion/PrepareTheme.spec.ts b/test/Models/ThemeConfig/Conversion/PrepareTheme.spec.ts index f6ab8d60a3..ec4118934c 100644 --- a/test/Models/ThemeConfig/Conversion/PrepareTheme.spec.ts +++ b/test/Models/ThemeConfig/Conversion/PrepareTheme.spec.ts @@ -125,7 +125,21 @@ describe("PrepareTheme", () => { en: "Test layer - please ignore", }, titleIcons: [], - 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 }], } const sharedLayers = constructSharedLayers() @@ -165,7 +179,21 @@ describe("PrepareTheme", () => { id: "layer-example", name: null, 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 }], titleIcons: [], })