Merge pull request #1731 from pietervdvn/feature/favourites

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

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,8 @@
{
"id": "wikipedialink",
"labels": [
"defaults"
"defaults",
"in_favourite"
],
"render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank' rel='noopener'><img src='./assets/svg/wikipedia.svg' textmode='📖' alt='Wikipedia'/></a>",
"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": "<a href='tel:{phone}'><img textmode='📞' alt='phone' src='./assets/layers/questions/phone.svg'/></a>",
"mappings": [
@ -89,7 +103,8 @@
{
"id": "emaillink",
"labels": [
"defaults"
"defaults",
"in_favourite"
],
"render": "<a href='mailto:{email}'><img textmode='✉️' alt='email' src='./assets/layers/questions/send_email.svg'/></a>",
"mappings": [
@ -109,7 +124,8 @@
{
"id": "websitelink",
"labels": [
"defaults"
"defaults",
"in_favourite"
],
"render": "<a href='{website}' target='_blank' rel='noopener'><img textmode='🌐' alt='website' src='./assets/layers/icons/website.svg'/></a>",
"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"
}
]
}

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

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

View file

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

View file

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

View file

@ -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ć"
}
},
{

View file

@ -349,4 +349,4 @@
}
}
]
}
}

View file

@ -50,6 +50,22 @@
"panelIntro": "<h3>Your personal theme</h3>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",

View file

@ -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",

View file

@ -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;
}

View file

@ -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)

View file

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

View file

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

View file

@ -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()

View file

@ -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)),
},

View file

@ -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<string, { data: LayoutConfig } | { func: () => LayoutConfig }> =
new Map()
constructor() {
private readonly raw: Map<string, LayoutConfigJson> = new Map()
private readonly dict: Map<string, LayoutConfig> = new Map()
constructor(includeFavouriteLayer = true) {
for (const layoutConfigJson of known_themes["themes"]) {
this.dict.set(layoutConfigJson.id, {
func: () => {
const layout = new LayoutConfig(<LayoutConfigJson>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() {

View file

@ -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<Feature>
featureProperties: { getStore: (id: string) => UIEventSource<Record<string, string>> }
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<Feature>
featureProperties: FeaturePropertiesStore
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
indexedFeatures: IndexedFeatureSource
}
constructor(state: {
selectedElement: UIEventSource<Feature>
featureProperties: FeaturePropertiesStore
indexedFeatures: IndexedFeatureSource
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
}) {
this.state = state
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)
}

View file

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

View file

@ -6,10 +6,14 @@ import FilteringFeatureSource from "./FilteringFeatureSource"
import LayerState from "../../State/LayerState"
export default class NearbyFeatureSource implements FeatureSource {
private readonly _result = new UIEventSource<Feature[]>(undefined)
public readonly features: Store<Feature[]>
private readonly _targetPoint: Store<{ lon: number; lat: number }>
private readonly _numberOfNeededFeatures: number
private readonly _layerState?: LayerState
private readonly _currentZoom: Store<number>
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<number>
) {
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<Feature[]>(undefined)
this.features = Stores.ListStabilized(result)
function update() {
let features: { feat: Feature; d: number }[] = []
for (const src of allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (numberOfNeededFeatures !== undefined) {
features = features.slice(0, numberOfNeededFeatures)
}
result.setData(features.map((f) => f.feat))
}
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
*/

View file

@ -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<Point, { date?: string; altitude?: number | string }>[],
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<string, edge>()
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 = ` <wpt lat="${l.geometry.coordinates[1]}" lon="${l.geometry.coordinates[0]}">`
for (const key in l.properties) {
const keyCleaned = key.replaceAll(":", "__")
trkpt += ` <${keyCleaned}>${l.properties[key]}</${keyCleaned}>\n`
if (key === "website") {
trkpt += ` <link>${l.properties[key]}</link>\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 += " </wpt>\n"
trackPoints.push(trkpt)
}
// Lets merge them back together!
let didMergeSomething = false
let allMergedEdges = Array.from(allEdgesByKey.values())
const allEdgesByStartPoint = new Map<string, edge[]>()
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<edge>()
for (const edge of allMergedEdges) {
// Can we make this edge longer at the end?
if (consumed.has(edge)) {
continue
}
console.log("Considering edge", edge)
const matchingEndEdges = allEdgesByStartPoint.get(edge.end + "")
console.log("Matchign endpoints:", matchingEndEdges)
if (matchingEndEdges === undefined) {
continue
}
for (let i = 0; i < matchingEndEdges.length; i++) {
const endEdge = matchingEndEdges[i]
if (consumed.has(endEdge)) {
continue
}
if (!membersAreCompatible(edge, endEdge)) {
continue
}
// We can make the segment longer!
didMergeSomething = true
console.log("Merging ", edge, "with ", endEdge)
edge.intermediate.push(edge.end)
edge.end = endEdge.end
consumed.add(endEdge)
matchingEndEdges.splice(i, 1)
break
}
}
allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge))
} while (didMergeSomething)
return []
const header =
'<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">'
return (
header +
"\n<name>" +
title +
"</name>\n<trk><trkseg>\n" +
trackPoints.join("\n") +
"\n</trkseg></trk></gpx>"
)
}
/**

View file

@ -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<LinkImageAction> {
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)

View file

@ -12,6 +12,10 @@ export class OsmPreferences {
"all-osm-preferences",
{}
)
/**
* A map containing the individual preference sources
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any
private userDetails: UIEventSource<UserDetails>
@ -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<string>()
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()
}
}

View file

@ -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")

View file

@ -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<string, string>) {
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

View file

@ -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<string, string>): { k: string; v: string }[] {
@ -20,15 +25,64 @@ export default class ComparingTag implements TagsFilter {
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
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
}

View file

@ -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))
}
}

View file

@ -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 }[] {

View file

@ -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"
}

View file

@ -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")

View file

@ -15,13 +15,14 @@ type Tags = Record<string, string>
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

View file

@ -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, string>
): string
abstract asJson(): TagConfigJson
abstract usedKeys(): string[]
/**

View file

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

View file

@ -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[] = <any>Constants._defaultPinIcons

View file

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

View file

@ -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<string, QuestionableTagRenderingConfigJson>
@ -81,18 +82,36 @@ export class Pure<TIn, TOut> extends Conversion<TIn, TOut> {
}
}
export class Bypass<T> extends DesugaringStep<T> {
private readonly _applyIf: (t: T) => boolean
private readonly _step: DesugaringStep<T>
constructor(applyIf: (t: T) => boolean, step: DesugaringStep<T>) {
super("Applies the step on the object, if the object satisfies the predicate", [], "Bypass")
this._applyIf = applyIf
this._step = step
}
convert(json: T, context: ConversionContext): T {
if (!this._applyIf(json)) {
return json
}
return this._step.convert(json, context)
}
}
export class Each<X, Y> extends Conversion<X[], Y[]> {
private readonly _step: Conversion<X, Y>
private readonly _msg: string
private readonly _filter: (x: X) => boolean
constructor(step: Conversion<X, Y>, msg?: string) {
constructor(step: Conversion<X, Y>, options?: { msg?: string }) {
super(
"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[] {

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { Conversion, DesugaringStep, Each, Fuse, On, Pipe, Pure } from "./Conversion"
import { Bypass, Conversion, DesugaringStep, Each, Fuse, On } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import 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<any> {
class ValidateLanguageCompleteness extends DesugaringStep<LayoutConfig> {
private readonly _languages: string[]
constructor(...languages: string[]) {
@ -35,7 +34,9 @@ class ValidateLanguageCompleteness extends DesugaringStep<any> {
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<any> {
)
})
}
obj.layers = origLayers
return obj
}
}
@ -276,9 +277,9 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
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(<any>layer.id) < 0,
new ValidateLayerConfig(undefined, isBuiltin, doesImageExist, false, true)
)
)
)
@ -974,7 +975,7 @@ export class ValidateTagRenderings extends Fuse<TagRenderingConfigJson> {
"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<LayerConfigJson> {
}
}
export class ValidateLayerConfig extends DesugaringStep<LayerConfigJson> {
private readonly validator: ValidateLayer
constructor(
path: string,
isBuiltin: boolean,
doesImageExist: DoesImageExist,
studioValidations: boolean = false,
skipDefaultLayers: boolean = false
) {
super("Thin wrapper around 'ValidateLayer", [], "ValidateLayerConfig")
this.validator = new ValidateLayer(
path,
isBuiltin,
doesImageExist,
studioValidations,
skipDefaultLayers
)
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
const prepared = this.validator.convert(json, context)
if (!prepared) {
context.err("Preparing layer failed")
return undefined
}
return prepared?.raw
}
}
export class ValidateLayer extends Conversion<
LayerConfigJson,
{ parsed: LayerConfig; raw: LayerConfigJson }

View file

@ -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.

View file

@ -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)) {

View file

@ -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)
}
}

View file

@ -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<Feature<Polygon>>
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<string, GeoIndexedStoreForLayer>
@ -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, {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -1,5 +1,6 @@
import { Utils } from "../../Utils"
import 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<Record<string, string>>,
key: string = "opening_hours",
prefixToIgnore?: string,
postfixToIgnore?: string
): Store<opening_hours | undefined | "error"> {
prefixToIgnore ??= ""
postfixToIgnore ??= ""
const country = tags.map((tags) => tags._country)
return tags
.mapD((tags) => {
const value: string = tags[key]
if (value === undefined) {
return undefined
}
if (
(prefixToIgnore || postfixToIgnore) &&
value.startsWith(prefixToIgnore) &&
value.endsWith(postfixToIgnore)
) {
return value
.substring(prefixToIgnore.length, value.length - postfixToIgnore.length)
.trim()
}
return value
})
.mapD(
(ohtext) => {
try {
return OH.CreateOhObject(<any>tags.data, ohtext, country.data)
} catch (e) {
return "error"
}
},
[country]
)
}
public static CreateOhObject(
tags: Record<string, string> & { _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,
},
},

View file

@ -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(<any>tags.data, ohtext)
)
} catch (e) {
console.warn(e, e.stack)
return new Combine([
Translations.t.general.opening_hours.error_loading,
new Toggle(
new FixedUiElement(e).SetClass("subtle"),
undefined,
state?.osmConnection?.userDetails.map(
(userdetails) =>
userdetails.csCount >=
Constants.userJourney.tagsVisibleAndWikiLinked
)
),
])
}
},
[country]
)
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(

View file

@ -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, ...) -->
<LoginButton osmConnection={state.osmConnection} slot="not-logged-in">
<Tr slot="message" t={Translations.t.general.add.pleaseLogin} />
<Tr t={Translations.t.general.add.pleaseLogin} />
</LoginButton>
<div class="h-full w-full">
{#if $zoom < Constants.minZoomLevelToAddNewPoint}

View file

@ -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]

View file

@ -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<LineString>>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<LineString>>feature, title)
Utils.offerContentsAsDownloadableFile(gpx, title + "_mapcomplete_export.gpx", {
mimetype: "{gpx=application/gpx+xml}",
})
})
})
}
}

View file

@ -28,7 +28,6 @@
const t = Translations.t.image.nearby
const 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)

View file

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

View file

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

View file

@ -3,16 +3,15 @@
* Shows all questions for which the answers are unknown.
* 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<Record<string, string>>

View file

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

View file

@ -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<Record<string, string>>
export let selectedElement: Feature | undefined
@ -71,7 +71,7 @@
}
</script>
<div bind:this={htmlElem} class={clss}>
<div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}>
{#if config.question && (!editingEnabled || $editingEnabled)}
{#if editMode}
<TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}>

View file

@ -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<Record<string, string>>
@ -27,13 +28,8 @@
</script>
{#if mapping.icon !== undefined}
<div class="inline-flex items-center">
<img
class={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")}
src={mapping.icon}
aria-hidden="true"
alt=""
/>
<div class="inline-flex">
<Icon icon={mapping.icon} clss={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-1")}/>
<SpecialTranslation t={mapping.then} {tags} {state} {layer} feature={selectedElement} />
</div>
{:else if mapping.then !== undefined}

View file

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

View file

@ -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<LayerConfig>
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
readonly favourites: FavouritesFeatureSource
/**
* If data is currently being fetched from external sources
*/

View file

@ -79,6 +79,9 @@ import ThemeViewState from "../Models/ThemeViewState"
import LanguagePicker from "./InputElement/LanguagePicker.svelte"
import 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<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
const keyToUse = args[0]
const prefix = args[1]
const postfix = args[2]
return new SvelteUIElement(NextChangeViz, {
state,
keyToUse,
tags,
prefix,
postfix,
})
},
},
{
funcName: "canonical",
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 = (<ThemeViewState>(
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<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
return new SvelteUIElement(MarkAsFavourite, {
tags: tagSource,
state,
layer,
feature,
})
},
},
{
funcName: "favourite_icon",
needsUrls: [],
docs: "A small button that allows a (logged in) contributor to mark a location as a favourite location, sized to fit a title-icon",
args: [],
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
return new SvelteUIElement(MarkAsFavouriteMini, {
tags: tagSource,
state,
layer,
feature,
})
},
},
]
specialVisualizations.push(new AutoApplyButton(specialVisualizations))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -280,6 +280,16 @@ button.disabled:hover, .button.disabled:hover {
color: unset;
}
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;;
}

View file

@ -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: <any>{
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: [],
})