diff --git a/Customizations/AllKnownLayouts.ts b/Customizations/AllKnownLayouts.ts index 1d92316f3..af3b2d20b 100644 --- a/Customizations/AllKnownLayouts.ts +++ b/Customizations/AllKnownLayouts.ts @@ -11,10 +11,10 @@ import {ClimbingTrees} from "./Layouts/ClimbingTrees"; import {Smoothness} from "./Layouts/Smoothness"; import {MetaMap} from "./Layouts/MetaMap"; import {Natuurpunt} from "./Layouts/Natuurpunt"; -import {Bookcases} from "./Layouts/Bookcases"; import {GhostBikes} from "./Layouts/GhostBikes"; -import * as bookcases from "../assets/themes/bookcases/Bookcases.json"; import {CustomLayoutFromJSON} from "./JSON/CustomLayoutFromJSON"; +import * as bookcases from "../assets/themes/bookcases/Bookcases.json"; +import * as aed from "../assets/themes/aed/aed.json"; export class AllKnownLayouts { @@ -26,8 +26,9 @@ export class AllKnownLayouts { new GRB(), new Cyclofix(), new GhostBikes(), - // new Bookcases(), CustomLayoutFromJSON.LayoutFromJSON(bookcases), + CustomLayoutFromJSON.LayoutFromJSON(aed), + new MetaMap(), new StreetWidth(), new ClimbingTrees(), diff --git a/Customizations/JSON/CustomLayoutFromJSON.ts b/Customizations/JSON/CustomLayoutFromJSON.ts index 4b79baa7a..505ef2fcf 100644 --- a/Customizations/JSON/CustomLayoutFromJSON.ts +++ b/Customizations/JSON/CustomLayoutFromJSON.ts @@ -2,15 +2,15 @@ import {TagRenderingOptions} from "../TagRenderingOptions"; import {LayerDefinition, Preset} from "../LayerDefinition"; import {Layout} from "../Layout"; import Translation from "../../UI/i18n/Translation"; -import {type} from "os"; import Combine from "../../UI/Base/Combine"; -import {UIElement} from "../../UI/UIElement"; -import {And, Tag, TagsFilter} from "../../Logic/TagsFilter"; +import {And, Tag} from "../../Logic/TagsFilter"; import FixedText from "../Questions/FixedText"; import {ImageCarouselWithUploadConstructor} from "../../UI/Image/ImageCarouselWithUpload"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {TagDependantUIElementConstructor} from "../UIElementConstructor"; -export interface TagRenderingConfigJson { +export interface TagRenderingConfigJson { // If this key is present, then... key?: string, // Use this string to render @@ -33,11 +33,11 @@ export interface TagRenderingConfigJson { export interface LayerConfigJson { id: string; - icon: string; + icon: TagRenderingConfigJson; title: TagRenderingConfigJson; description: string; minzoom: number, - color: string; + color: TagRenderingConfigJson; overpassTags: string | string[] | { k: string, v: string }[]; presets: [ { @@ -58,7 +58,8 @@ export interface LayoutConfigJson { name: string; title: string; description: string; - language: string; + maintainer: string; + language: string[]; layers: LayerConfigJson[], startZoom: number; startLat: number; @@ -71,86 +72,38 @@ export interface LayoutConfigJson { export class CustomLayoutFromJSON { - public static exampleLayer: LayerConfigJson = { - id: "bookcase", - icon: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgaWQ9InN2ZzExMzgyIgogICBoZWlnaHQ9IjkwMCIKICAgd2lkdGg9IjkwMCIKICAgdmVyc2lvbj0iMS4wIj4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczExMzg0IiAvPgogIDxnCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjkwMTAzMjU4LDAsMCwwLjkwMTAzMjU4LDExMi44NDA1OCwtMS45MDYwMTc3KSI+CiAgICA8ZwogICAgICAgaWQ9ImcxMTQ3NiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTE0NzIiCiAgICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zaXplOjEyMDEuOTI0OTI2NzZweDtmb250LWZhbWlseTonQml0c3RyZWFtIFZlcmEgU2Fucyc7dGV4dC1hbGlnbjpjZW50ZXI7dGV4dC1hbmNob3I6bWlkZGxlO2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgIGQ9Ik0gNDc0LjUwODg4LDcxOC4yMjg0MSBIIDMwMy40OTU0NyB2IC0yMi4zMDEzNCBjIC0yLjRlLTQsLTM3Ljk1MTA4IDQuMzAzNTIsLTY4Ljc2MjExIDEyLjkxMTMsLTkyLjQzMzE5IDguNjA3MjgsLTIzLjY3MDMyIDIzLjYzMzUyLC00NS4yODY5NSA0MC42NTMyNCwtNjQuODQ5OTYgMTcuMDE5MTQsLTE5LjU2MjExIDQxLjk4NzM0LC0yNi4zMzI2NCAxMDEuNDU3OTMsLTc1LjYzMDg1IDMxLjY5MDk1LC0yNS44MjIwMyA1NS4yODEzLC03Ny4xNTIzIDU1LjI4MTc1LC05OC42NzE3NCAyLjIxMjMyLC01Ni45MjI0NSAtMTMuOTM5ODMsLTc5LjM0MjIgLTM0LjU2Mjg3LC05OS45NjUyNCAtMjIuNjczNTUsLTE5LjY3NzE3IC02MC42NzAyNywtMzAuMDY5OTggLTkwLjk5ODkyLC0zMC4wNjk5OCAtMjcuNzc5MjEsNi45ZS00IC02OC40NjczNSw4LjA4ODcxIC04Ny43NjY2LDI1LjM3MDQ3IC0yNS45MzgxNywxNy4yODMwOCAtNjUuMjM3NDcsNzMuNzA2MTEgLTU3LjA0Njg3LDEzMC41NDU3NyBsIC0xOTQuNTE2OTQzLDEuNzAyMjIgYyAwLC0xNTcuMjEzOTkgMjkuMzkzNjk5LC0xOTguNjk0NjUgOTkuMDA0MTEzLC0yNjMuMDMwMzIgNjcuMzk3MzksLTU0LjM3NjY0MyAxMjYuNTMxMjgsLTczLjI2ODM2NSAyNDMuODQ3NTcsLTczLjI2ODM2NSA4OS43MTc5MSwwIDE2MS44OTcyOCwxNy44MDI4MSAyMTQuMzI1NTIsNTMuNDA1ODU1IDcxLjIwNzE0LDQ4LjEyNDcyIDEyMi4zMDEwNSwxMTEuMTgzNTQgMTIyLjMwMTA1LDIzMC4xMTI4MSAtNi45ZS00LDQ0LjMyMDgxIC0xOS4xNTI1Myw5MC43ODYzOCAtNDMuMDcyNiwxMjguMzMyOTkgLTE4LjM4OTQ3LDMwLjkwOTM4IC02MC4zNzUxMSw2Ni40NTIzNiAtMTE4LjIxMjM3LDEwNC40MTYyOCAtNDIuODM2MDcsMjUuNzY4NiAtNjYuNjcxOTYsNTMuMTE5MjYgLTc3LjAzOTY0LDcyLjA5NDYgLTEwLjM2ODYzLDE4Ljk3NjAzIC0xNS41NTI3MSw0My43MjI2NyAtMTUuNTUyMjUsNzQuMjM5OTkgeiIgLz4KICAgICAgPHBhdGgKICAgICAgICAgaWQ9InBhdGgxMTQ3NCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MztzdHJva2UtbGluZWNhcDpzcXVhcmU7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS4xMDYzODMsLTUuNTMxOTE0OSkiCiAgICAgICAgIGQ9Im0gNDgyLjM4Mjk4LDg2OS44MDkwMiBhIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgLTE4OC4wODUxMSwwIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgMTg4LjA4NTExLDAgeiIgLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo=", - title: {render: "Bookcase"}, - description: "A small, public cabinet with books. Anyone can leave or take a book", - minzoom: 12, - color: "#0000ff", - overpassTags: "amenity=public_bookcase", - presets: [ - { - title: "bookcase" - // icon: optional. Uses the layer icon by default - // title: optional. Uses the layer title by default - // description: optional. Uses the layer description by default - // tags: optional list {k:string, v:string}[] - } - ], - tagRenderings: [ - { - // If this key is present, then... - key: "name", - // Use this string to render - render: "{name}", - // One of string, int, nat, float, pfloat, email, phone. Default: string - type: "string", - // If it is not known (and no mapping below matches), this question is asked; a textfield is inserted in the rendering above - question: "Wat is de naam van dit boekenruilkastje?", - // If a value is added with the textfield, this extra tag is addded. Optional field - addExtraTags: [{ - "k": "fixme", - "v": "Added with mapcomplete, to be checked" - }], - // Alternatively, these tags are shown if they match - even if the key above is not there - // If unknown, these become a radio button - mappings: [ - { - if: "noname=yes", - then: "Dit boekenruilkastje heeft geen naam" - } - ] - } - ] - } - - public static exampleLayout: LayoutConfigJson = { - name: "bookcases", - title: "Custom Open bookcases map", - description: "Welcome to a custom layout", - language: "en", - layers: [CustomLayoutFromJSON.exampleLayer], - startZoom: 12, - startLat: 0, - startLon: 0, - icon: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgaWQ9InN2ZzExMzgyIgogICBoZWlnaHQ9IjkwMCIKICAgd2lkdGg9IjkwMCIKICAgdmVyc2lvbj0iMS4wIj4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczExMzg0IiAvPgogIDxnCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjkwMTAzMjU4LDAsMCwwLjkwMTAzMjU4LDExMi44NDA1OCwtMS45MDYwMTc3KSI+CiAgICA8ZwogICAgICAgaWQ9ImcxMTQ3NiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTE0NzIiCiAgICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zaXplOjEyMDEuOTI0OTI2NzZweDtmb250LWZhbWlseTonQml0c3RyZWFtIFZlcmEgU2Fucyc7dGV4dC1hbGlnbjpjZW50ZXI7dGV4dC1hbmNob3I6bWlkZGxlO2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgIGQ9Ik0gNDc0LjUwODg4LDcxOC4yMjg0MSBIIDMwMy40OTU0NyB2IC0yMi4zMDEzNCBjIC0yLjRlLTQsLTM3Ljk1MTA4IDQuMzAzNTIsLTY4Ljc2MjExIDEyLjkxMTMsLTkyLjQzMzE5IDguNjA3MjgsLTIzLjY3MDMyIDIzLjYzMzUyLC00NS4yODY5NSA0MC42NTMyNCwtNjQuODQ5OTYgMTcuMDE5MTQsLTE5LjU2MjExIDQxLjk4NzM0LC0yNi4zMzI2NCAxMDEuNDU3OTMsLTc1LjYzMDg1IDMxLjY5MDk1LC0yNS44MjIwMyA1NS4yODEzLC03Ny4xNTIzIDU1LjI4MTc1LC05OC42NzE3NCAyLjIxMjMyLC01Ni45MjI0NSAtMTMuOTM5ODMsLTc5LjM0MjIgLTM0LjU2Mjg3LC05OS45NjUyNCAtMjIuNjczNTUsLTE5LjY3NzE3IC02MC42NzAyNywtMzAuMDY5OTggLTkwLjk5ODkyLC0zMC4wNjk5OCAtMjcuNzc5MjEsNi45ZS00IC02OC40NjczNSw4LjA4ODcxIC04Ny43NjY2LDI1LjM3MDQ3IC0yNS45MzgxNywxNy4yODMwOCAtNjUuMjM3NDcsNzMuNzA2MTEgLTU3LjA0Njg3LDEzMC41NDU3NyBsIC0xOTQuNTE2OTQzLDEuNzAyMjIgYyAwLC0xNTcuMjEzOTkgMjkuMzkzNjk5LC0xOTguNjk0NjUgOTkuMDA0MTEzLC0yNjMuMDMwMzIgNjcuMzk3MzksLTU0LjM3NjY0MyAxMjYuNTMxMjgsLTczLjI2ODM2NSAyNDMuODQ3NTcsLTczLjI2ODM2NSA4OS43MTc5MSwwIDE2MS44OTcyOCwxNy44MDI4MSAyMTQuMzI1NTIsNTMuNDA1ODU1IDcxLjIwNzE0LDQ4LjEyNDcyIDEyMi4zMDEwNSwxMTEuMTgzNTQgMTIyLjMwMTA1LDIzMC4xMTI4MSAtNi45ZS00LDQ0LjMyMDgxIC0xOS4xNTI1Myw5MC43ODYzOCAtNDMuMDcyNiwxMjguMzMyOTkgLTE4LjM4OTQ3LDMwLjkwOTM4IC02MC4zNzUxMSw2Ni40NTIzNiAtMTE4LjIxMjM3LDEwNC40MTYyOCAtNDIuODM2MDcsMjUuNzY4NiAtNjYuNjcxOTYsNTMuMTE5MjYgLTc3LjAzOTY0LDcyLjA5NDYgLTEwLjM2ODYzLDE4Ljk3NjAzIC0xNS41NTI3MSw0My43MjI2NyAtMTUuNTUyMjUsNzQuMjM5OTkgeiIgLz4KICAgICAgPHBhdGgKICAgICAgICAgaWQ9InBhdGgxMTQ3NCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MztzdHJva2UtbGluZWNhcDpzcXVhcmU7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS4xMDYzODMsLTUuNTMxOTE0OSkiCiAgICAgICAgIGQ9Im0gNDgyLjM4Mjk4LDg2OS44MDkwMiBhIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgLTE4OC4wODUxMSwwIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgMTg4LjA4NTExLDAgeiIgLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo=" - } public static FromQueryParam(layoutFromBase64: string): Layout { - if(layoutFromBase64 === "test"){ - console.log(btoa(JSON.stringify(CustomLayoutFromJSON.exampleLayout))); - return CustomLayoutFromJSON.LayoutFromJSON(CustomLayoutFromJSON.exampleLayout); - } - const spec = JSON.parse(atob(layoutFromBase64)); - return CustomLayoutFromJSON.LayoutFromJSON(spec); + return CustomLayoutFromJSON.LayoutFromJSON(JSON.parse(atob(layoutFromBase64))); } - private static TagRenderingFromJson(json: any): TagRenderingOptions { + public static TagRenderingFromJson(json: any): TagDependantUIElementConstructor { if (typeof (json) === "string") { return new FixedText(json); } let freeform = undefined; - if (json.key !== undefined && json.key !== "" && json.render !== undefined) { + if (json.render !== undefined) { const type = json.type ?? "text"; + let renderTemplate = CustomLayoutFromJSON.MaybeTranslation(json.render);; + const template = renderTemplate.replace("{" + json.key + "}", "$" + type + "$"); + + if(type === "url"){ + renderTemplate = json.render.replace("{" + json.key + "}", + `{${json.key}}` + ); + } + freeform = { key: json.key, - template: json.render.replace("{" + json.key + "}", "$" + type + "$"), - renderTemplate: json.render, + template: template, + renderTemplate: renderTemplate, extraTags: CustomLayoutFromJSON.TagsFromJson(json.addExtraTags), } + if (freeform.key === "*") { + freeform.key = "id"; // Id is always there -> always take the rendering. Used for 'icon' and 'stroke' + } } let mappings = undefined; @@ -158,30 +111,37 @@ export class CustomLayoutFromJSON { mappings = []; for (const mapping of json.mappings) { mappings.push({ - k: new And(CustomLayoutFromJSON.TagsFromJson(mapping.if)), txt: mapping.then + k: new And(CustomLayoutFromJSON.TagsFromJson(mapping.if)), + txt: CustomLayoutFromJSON.MaybeTranslation(mapping.then) }) } } - return new TagRenderingOptions({ - question: json.question, + const rendering = new TagRenderingOptions({ + question: CustomLayoutFromJSON.MaybeTranslation(json.question), freeform: freeform, mappings: mappings - }) + }); + + if (json.condition) { + const conditionTags: Tag[] = CustomLayoutFromJSON.TagsFromJson(json.condition); + return rendering.OnlyShowIf(new And(conditionTags)); + } + return rendering; } private static PresetFromJson(layout: any, preset: any): Preset { const t = CustomLayoutFromJSON.MaybeTranslation; const tags = CustomLayoutFromJSON.TagsFromJson; return { - icon: preset.icon ?? layout.icon, + icon: preset.icon ?? CustomLayoutFromJSON.TagRenderingFromJson(layout.icon), tags: tags(preset.tags) ?? tags(layout.overpassTags), title: t(preset.title) ?? t(layout.title), description: t(preset.description) ?? t(layout.description) } } - private static StyleFromJson(layout: any, styleJson: any): ((tags) => { + private static StyleFromJson(layout: any, styleJson: any): ((tags: any) => { color: string, weight?: number, icon: { @@ -189,12 +149,17 @@ export class CustomLayoutFromJSON { iconSize: number[], }, }) { + const iconRendering: TagDependantUIElementConstructor = CustomLayoutFromJSON.TagRenderingFromJson(layout.icon); + const colourRendering = CustomLayoutFromJSON.TagRenderingFromJson(layout.color); + return (tags) => { + const iconUrl = iconRendering.GetContent(tags); + const stroke = colourRendering.GetContent(tags); return { - color: layout.color, + color: stroke, weight: 10, icon: { - iconUrl: layout.icon, + iconUrl: iconUrl, iconSize: [40, 40], }, } @@ -205,41 +170,76 @@ export class CustomLayoutFromJSON { if (json === undefined) { return undefined; } - console.log(json) if (typeof (json) === "string") { - const kv = json.split("="); - return new Tag(kv[0].trim(), kv[1].trim()); + let kv: string[] = undefined; + let invert = false; + if (json.indexOf("!=") >= 0) { + kv = json.split("!="); + invert = true; + } else { + kv = json.split("="); + } + + if (kv.length !== 2) { + return undefined; + } + if (kv[0].trim() === "") { + return undefined; + } + return new Tag(kv[0].trim(), kv[1].trim(), invert); } return new Tag(json.k.trim(), json.v.trim()) } - private static TagsFromJson(json: string | { k: string, v: string }[]): Tag[] { - if (json === undefined || json === "") { + public static TagsFromJson(json: string | { k: string, v: string }[]): Tag[] { + if (json === undefined) { return undefined; } - if (typeof (json) === "string") { - return json.split(",").map(CustomLayoutFromJSON.TagFromJson); + if (json === "") { + return []; } - return json.map(CustomLayoutFromJSON.TagFromJson) + let tags = []; + if (typeof (json) === "string") { + tags = json.split("&").map(CustomLayoutFromJSON.TagFromJson); + } else { + tags = json.map(CustomLayoutFromJSON.TagFromJson); + } + for (const tag of tags) { + if (tag === undefined) { + return undefined; + } + } + return tags; } private static LayerFromJson(json: any): LayerDefinition { const t = CustomLayoutFromJSON.MaybeTranslation; const tr = CustomLayoutFromJSON.TagRenderingFromJson; + const tags = CustomLayoutFromJSON.TagsFromJson(json.overpassTags); + // We run the icon rendering with the bare minimum of tags (the overpass tags) to get the actual icon + const properties = {}; + for (const tag of tags) { + tags[tag.key] = tag.value; + } + const icon = CustomLayoutFromJSON.TagRenderingFromJson(json.icon).construct({ + tags: new UIEventSource(properties) + }).InnerRender(); + + return new LayerDefinition( json.id, { description: t(json.description), name: t(json.title), - icon: json.icon, + icon: icon, minzoom: json.minzoom, - title: tr(json.title) , + title: tr(json.title), presets: json.presets.map((preset) => { return CustomLayoutFromJSON.PresetFromJson(json, preset) }), elementsToShow: [new ImageCarouselWithUploadConstructor()].concat(json.tagRenderings.map(tr)), - overpassFilter: new And(CustomLayoutFromJSON.TagsFromJson(json.overpassTags)), + overpassFilter: new And(tags), wayHandling: LayerDefinition.WAYHANDLING_CENTER_AND_WAY, maxAllowedOverlapPercentage: 0, style: CustomLayoutFromJSON.StyleFromJson(json, json.style) @@ -260,8 +260,12 @@ export class CustomLayoutFromJSON { public static LayoutFromJSON(json: any) { const t = CustomLayoutFromJSON.MaybeTranslation; + let languages = json.language; + if(typeof (json.language) === "string"){ + languages = [json.language]; + } const layout = new Layout(json.name, - [json.language], + languages, t(json.title), json.layers.map(CustomLayoutFromJSON.LayerFromJson), json.startZoom, @@ -270,6 +274,7 @@ export class CustomLayoutFromJSON { new Combine(['

', t(json.title), '


', t(json.description)]) ); layout.icon = json.icon; + layout.maintainer = json.maintainer; return layout; } diff --git a/Customizations/LayerDefinition.ts b/Customizations/LayerDefinition.ts index 9c02c019a..51f11ead9 100644 --- a/Customizations/LayerDefinition.ts +++ b/Customizations/LayerDefinition.ts @@ -7,7 +7,7 @@ export interface Preset { tags: Tag[], title: string | UIElement, description?: string | UIElement, - icon?: string + icon?: string | TagRenderingOptions } export class LayerDefinition { @@ -32,7 +32,7 @@ export class LayerDefinition { * Not really used anymore * This is meant to serve as icon in the buttons */ - icon: string; + icon: string | TagRenderingOptions; /** * Only show this layer starting at this zoom level */ @@ -58,7 +58,7 @@ export class LayerDefinition { /** * This UIElement is rendered as title element in the popup */ - title: TagRenderingOptions | UIElement | string; + title: TagDependantUIElementConstructor | UIElement | string; /** * These are the questions/shown attributes in the popup */ @@ -100,7 +100,7 @@ export class LayerDefinition { icon: string, minzoom: number, overpassFilter: TagsFilter, - title?: TagRenderingOptions, + title?: TagDependantUIElementConstructor, elementsToShow?: TagDependantUIElementConstructor[], maxAllowedOverlapPercentage?: number, wayHandling?: number, diff --git a/Customizations/Layers/Bookcases.ts b/Customizations/Layers/Bookcases.ts deleted file mode 100644 index 0252a0cb9..000000000 --- a/Customizations/Layers/Bookcases.ts +++ /dev/null @@ -1,183 +0,0 @@ -import {LayerDefinition} from "../LayerDefinition"; -import {And, Or, Tag} from "../../Logic/TagsFilter"; -import {NameInline} from "../Questions/NameInline"; -import {ImageCarouselWithUploadConstructor} from "../../UI/Image/ImageCarouselWithUpload"; -import Translations from "../../UI/i18n/Translations"; -import T from "../../UI/i18n/Translation"; -import {TagRenderingOptions} from "../TagRenderingOptions"; - -export class Bookcases extends LayerDefinition { - - constructor() { - super("bookcases"); - - this.name = "boekenkast"; - this.presets = [{ - tags: [new Tag("amenity", "public_bookcase")], - description: "Add a new bookcase here", - title: Translations.t.bookcases.bookcase, - }]; - this.icon = "./assets/bookcase.svg"; - this.overpassFilter = new Tag("amenity", "public_bookcase"); - this.minzoom = 11; - - const Tr = Translations.t; - const Trq = Tr.bookcases.questions; - this.title = new NameInline(Translations.t.bookcases.bookcase); - this.elementsToShow = [ - new ImageCarouselWithUploadConstructor(), - new TagRenderingOptions({ - question: Trq.hasName, - freeform: { - key: "name", - template: "$$$", - renderTemplate: "", // We don't actually render it, only ask - placeholder: "", - extraTags: new Tag("noname", "") - }, - mappings: [ - {k: new Tag("noname", "yes"), txt: Trq.noname}, - ] - }), - - new TagRenderingOptions( - { - question: Trq.capacity, - freeform: { - renderTemplate: Trq.capacityRender, - template: Trq.capacityInput, - key: "capacity", - placeholder: "aantal" - }, - } - ), - new TagRenderingOptions({ - question: Trq.bookkinds, - mappings: [ - {k: new Tag("books", "children"), txt: "Voornamelijk kinderboeken"}, - {k: new Tag("books", "adults"), txt: "Voornamelijk boeken voor volwassenen"}, - {k: new Tag("books", "children;adults"), txt: "Zowel kinderboeken als boeken voor volwassenen"} - ], - }), - - new TagRenderingOptions({ - question: "Staat dit boekenruilkastje binnen of buiten?", - mappings: [ - {k: new Tag("indoor", "yes"), txt: "Dit boekenruilkastje staat binnen"}, - {k: new Tag("indoor", "no"), txt: "Dit boekenruilkastje staat buiten"}, - {k: new Tag("indoor", ""), txt: "Dit boekenruilkastje staat buiten"} - ] - }), - - new TagRenderingOptions({ - question: "Is dit boekenruilkastje vrij toegankelijk?", - mappings: [ - {k: new Tag("access", "yes"), txt: "Ja, vrij toegankelijk"}, - {k: new Tag("access", "customers"), txt: "Enkel voor klanten"}, - ] - }).OnlyShowIf(new Tag("indoor", "yes")), - - new TagRenderingOptions({ - question: "Wie (welke organisatie) beheert dit boekenruilkastje?", - freeform: { - key: "operator", - renderTemplate: "Dit boekenruilkastje wordt beheerd door {operator}", - template: "Dit boekenruilkastje wordt beheerd door $$$" - } - }), - - new TagRenderingOptions({ - question: "Zijn er openingsuren voor dit boekenruilkastje?", - mappings: [ - {k: new Tag("opening_hours", "24/7"), txt: "Dag en nacht toegankelijk"}, - {k: new Tag("opening_hours", ""), txt: "Dag en nacht toegankelijk"}, - {k: new Tag("opening_hours", "sunrise-sunset"), txt: "Van zonsopgang tot zonsondergang"}, - ], - freeform: { - key: "opening_hours", - renderTemplate: "De openingsuren zijn {opening_hours}", - template: "De openingsuren zijn $$$" - } - }), - - new TagRenderingOptions({ - question: "Is dit boekenruilkastje deel van een netwerk?", - freeform: { - key: "brand", - renderTemplate: "Deel van het netwerk {brand}", - template: "Deel van het netwerk $$$" - }, - mappings: [{ - k: new And([new Tag("brand", "Little Free Library"), new Tag("nobrand", "")]), - txt: "Little Free Library" - }, - { - k: new And([new Tag("brand", ""), new Tag("nobrand", "yes")]), - txt: "Maakt geen deel uit van een groter netwerk" - }] - }).OnlyShowIf(new Or([ - new Tag("ref", ""), - new And([new Tag("ref","*"), new Tag("brand","")]) - ])), - - new TagRenderingOptions({ - question: "Wat is het referentienummer van dit boekenruilkastje?", - freeform: { - key: "ref", - template: "Het referentienummer is $$$", - renderTemplate: "Gekend als {brand} {ref}" - }, - mappings: [ - {k: new And([new Tag("brand",""), new Tag("nobrand","yes"), new Tag("ref", "")]), - txt: "Maakt geen deel uit van een netwerk"} - ] - }).OnlyShowIf(new Tag("brand","*")), - - new TagRenderingOptions({ - question: "Wanneer werd dit boekenruilkastje geinstalleerd?", - priority: -1, - freeform: { - key: "start_date", - renderTemplate: "Geplaatst op {start_date}", - template: "Geplaatst op $$$" - } - }), - - new TagRenderingOptions({ - question: "Is er een website waar we er meer informatie is over dit boekenruilkastje?", - freeform: { - key: "website", - renderTemplate: "Meer informatie over dit boekenruilkastje", - template: "$$$", - placeholder: "website" - } - }), - new TagRenderingOptions({ - freeform: { - key: "description", - renderTemplate: "Beschrijving door de uitbater:
{description}", - template: "$$$", - } - }) - - - ]; - - - this.style = function (tags) { - return { - icon: { - iconUrl: "assets/bookcase.svg", - iconSize: [40, 40], - iconAnchor: [20,20], - popupAnchor: [0, -15] - }, - color: "#0000ff" - }; - } - - - } - - -} \ No newline at end of file diff --git a/Customizations/Layout.ts b/Customizations/Layout.ts index 4dd18f208..df6b38d1f 100644 --- a/Customizations/Layout.ts +++ b/Customizations/Layout.ts @@ -10,8 +10,9 @@ export class Layout { public name: string; public icon: string = "./assets/logo.svg"; public title: UIElement; + public maintainer: string; public description: string | UIElement; - public socialImage: string = "" + public socialImage: string = ""; public layers: LayerDefinition[]; public welcomeMessage: UIElement; diff --git a/Customizations/Layouts/Bookcases.ts b/Customizations/Layouts/Bookcases.ts deleted file mode 100644 index 0a4f1aaff..000000000 --- a/Customizations/Layouts/Bookcases.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {Layout} from "../Layout"; -import * as Layer from "../Layers/Bookcases"; -import Translations from "../../UI/i18n/Translations"; -import Combine from "../../UI/Base/Combine"; - -export class Bookcases extends Layout { - constructor() { - super("bookcases", - ["nl", "en"], - Translations.t.bookcases.title, - [new Layer.Bookcases()], - 14, - 51.2, - 3.2, - - new Combine(["

",Translations.t.bookcases.title,"

", Translations.t.bookcases.description]) - ); - this.icon = "assets/bookcase.svg" - } -} \ No newline at end of file diff --git a/Customizations/OnlyShowIf.ts b/Customizations/OnlyShowIf.ts index 99783ec74..5e1b47304 100644 --- a/Customizations/OnlyShowIf.ts +++ b/Customizations/OnlyShowIf.ts @@ -40,7 +40,14 @@ export class OnlyShowIfConstructor implements TagDependantUIElementConstructor{ Priority(): number { return this._embedded.Priority(); } - + + GetContent(tags: any): string { + if(!this.IsKnown(tags)){ + return undefined; + } + return this._embedded.GetContent(tags); + } + private Matches(properties: any) : boolean{ return this._tagsFilter.matches(TagUtils.proprtiesToKV(properties)); } diff --git a/Customizations/TagRendering.ts b/Customizations/TagRendering.ts index 6dc136a08..ca443b4ba 100644 --- a/Customizations/TagRendering.ts +++ b/Customizations/TagRendering.ts @@ -5,7 +5,7 @@ import {FixedUiElement} from "../UI/Base/FixedUiElement"; import {SaveButton} from "../UI/SaveButton"; import {VariableUiElement} from "../UI/Base/VariableUIElement"; import {TagDependantUIElement} from "./UIElementConstructor"; -import {TextField} from "../UI/Input/TextField"; +import {TextField, ValidatedTextField} from "../UI/Input/TextField"; import {InputElement} from "../UI/Input/InputElement"; import {InputElementWrapper} from "../UI/Input/InputElementWrapper"; import {FixedInputElement} from "../UI/Input/FixedInputElement"; @@ -14,6 +14,7 @@ import Translations from "../UI/i18n/Translations"; import Locale from "../UI/i18n/Locale"; import {State} from "../State"; import {TagRenderingOptions} from "./TagRenderingOptions"; +import Translation from "../UI/i18n/Translation"; export class TagRendering extends UIElement implements TagDependantUIElement { @@ -22,15 +23,15 @@ export class TagRendering extends UIElement implements TagDependantUIElement { private _priority: number; - private _question: UIElement; - private _mapping: { k: TagsFilter, txt: string | UIElement, priority?: number }[]; + private _question: Translation; + private _mapping: { k: TagsFilter, txt: string | Translation, priority?: number }[]; private _tagsPreprocessor?: ((tags: any) => any); private _freeform: { - key: string, - template: string | UIElement, - renderTemplate: string | UIElement, - placeholder?: string | UIElement, + key: string, + template: string | Translation, + renderTemplate: string | Translation, + placeholder?: string | Translation, extraTags?: TagsFilter }; @@ -56,24 +57,25 @@ export class TagRendering extends UIElement implements TagDependantUIElement { constructor(tags: UIEventSource, options: { priority?: number - question?: string | UIElement, + question?: string | Translation, freeform?: { key: string, - template: string | UIElement, - renderTemplate: string | UIElement, - placeholder?: string | UIElement, + template: string | Translation, + renderTemplate: string | Translation, + placeholder?: string | Translation, extraTags?: TagsFilter, }, tagsPreprocessor?: ((tags: any) => any), - mappings?: { k: TagsFilter, txt: string | UIElement, priority?: number, substitute?: boolean }[] + mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean }[] }) { super(tags); this.ListenTo(Locale.language); this.ListenTo(this._questionSkipped); this.ListenTo(this._editMode); - this.ListenTo(State.state.osmConnection.userDetails); + this.ListenTo(State.state?.osmConnection?.userDetails); + console.log("Creating tagRendering with", options) const self = this; @@ -106,10 +108,10 @@ export class TagRendering extends UIElement implements TagDependantUIElement { }; if (choice.substitute) { + const newTags = this._tagsPreprocessor(this._source.data); choiceSubbed = { - k: choice.k.substituteValues( - options.tagsPreprocessor(this._source.data)), - txt: choice.txt, + k: choice.k.substituteValues(newTags), + txt: this.ApplyTemplate(choice.txt), priority: choice.priority } } @@ -168,12 +170,12 @@ export class TagRendering extends UIElement implements TagDependantUIElement { private InputElementFor(options: { freeform?: { key: string, - template: string | UIElement, - renderTemplate: string | UIElement, - placeholder?: string | UIElement, + template: string | Translation, + renderTemplate: string | Translation, + placeholder?: string | Translation, extraTags?: TagsFilter, }, - mappings?: { k: TagsFilter, txt: string | UIElement, priority?: number, substitute?: boolean }[] + mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean }[] }): InputElement { @@ -189,7 +191,7 @@ export class TagRendering extends UIElement implements TagDependantUIElement { if(previousTexts.indexOf(mapping.txt) >= 0){ continue; } - previousTexts.push(mapping.txt); + previousTexts.push(this.ApplyTemplate(mapping.txt)); elements.push(this.InputElementForMapping(mapping)); } @@ -201,7 +203,7 @@ export class TagRendering extends UIElement implements TagDependantUIElement { if (elements.length == 0) { - //console.warn("WARNING: no tagrendering with following options:", options); + console.warn("WARNING: no tagrendering with following options:", options); return new FixedInputElement("This should not happen: no tag renderings defined", undefined); } if (elements.length == 1) { @@ -224,15 +226,15 @@ export class TagRendering extends UIElement implements TagDependantUIElement { } const prepost = Translations.W(freeform.template).InnerRender() - .replace("$$$","$string$") + .replace("$$$", "$string$") .split("$"); const type = prepost[1]; - - let isValid = TagRenderingOptions.inputValidation[type]; + + let isValid = ValidatedTextField.inputValidation[type]; if (isValid === undefined) { isValid = (str) => true; } - let formatter = TagRenderingOptions.formatting[type] ?? ((str) => str); + let formatter = ValidatedTextField.formatting[type] ?? ((str) => str); const pickString = (string: any) => { @@ -272,7 +274,10 @@ export class TagRendering extends UIElement implements TagDependantUIElement { toString: toString }); - return new InputElementWrapper(prepost[0], textField, prepost[2]); + const pre = prepost[0] !== undefined ? this.ApplyTemplate(prepost[0]) : ""; + const post = prepost[2] !== undefined ? this.ApplyTemplate(prepost[2]) : ""; + + return new InputElementWrapper(pre, textField, post); } @@ -323,7 +328,7 @@ export class TagRendering extends UIElement implements TagDependantUIElement { return true; } - private RenderAnwser(): UIElement { + private RenderAnswer(): UIElement { const tags = TagUtils.proprtiesToKV(this._source.data); let freeform: UIElement = new FixedUiElement(""); @@ -357,10 +362,9 @@ export class TagRendering extends UIElement implements TagDependantUIElement { // we render the found template return this.ApplyTemplate(highestTemplate); } - - } + InnerRender(): string { if (this.IsQuestioning() || this._editMode.data) { // Not yet known or questioning, we have to ask a question @@ -378,13 +382,14 @@ export class TagRendering extends UIElement implements TagDependantUIElement { } if (this.IsKnown()) { - const answer = this.RenderAnwser() - if (answer.IsEmpty()) { + const html = this.RenderAnswer().Render(); + if (html === "") { return ""; } - const html = answer.Render(); + + let editButton = ""; - if (State.state.osmConnection.userDetails.data.loggedIn && this._question !== undefined) { + if (State.state?.osmConnection?.userDetails?.data?.loggedIn && this._question !== undefined) { editButton = this._editButton.Render(); } @@ -403,24 +408,18 @@ export class TagRendering extends UIElement implements TagDependantUIElement { return this._priority; } - private ApplyTemplate(template: string | UIElement): UIElement { + private ApplyTemplate(template: string | Translation): Translation { if (template === undefined || template === null) { throw "Trying to apply a template, but the template is null/undefined" } - - const contents = Translations.W(template).map(contents => - { - let templateStr = ""; - if (template instanceof UIElement) { - templateStr = template.Render(); - } else { - templateStr = template; - } - const tags = this._tagsPreprocessor(this._source.data); - return TagUtils.ApplyTemplate(templateStr, tags); - }, [this._source] - ); - return new VariableUiElement(contents); + + if (typeof (template) === "string") { + const tags = this._tagsPreprocessor(this._source.data); + return new Translation ({en:TagUtils.ApplyTemplate(template, tags)}); + } + const tags = this._tagsPreprocessor(this._source.data); + + return template.Subs(tags); } diff --git a/Customizations/TagRenderingOptions.ts b/Customizations/TagRenderingOptions.ts index 148e20faa..643e599d4 100644 --- a/Customizations/TagRenderingOptions.ts +++ b/Customizations/TagRenderingOptions.ts @@ -5,29 +5,12 @@ import {UIElement} from "../UI/UIElement"; import {TagsFilter, TagUtils} from "../Logic/TagsFilter"; import {OnlyShowIfConstructor} from "./OnlyShowIf"; import {UIEventSource} from "../Logic/UIEventSource"; +import Translation from "../UI/i18n/Translation"; export class TagRenderingOptions implements TagDependantUIElementConstructor { - public static inputValidation = { - "$": (str) => true, - "string": (str) => true, - "int": (str) => str.indexOf(".") < 0 && !isNaN(Number(str)), - "nat": (str) => str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) > 0, - "float": (str) => !isNaN(Number(str)), - "pfloat": (str) => !isNaN(Number(str)) && Number(str) > 0, - "email": (str) => EmailValidator.validate(str), - "phone": (str, country) => { - return parsePhoneNumberFromString(str, country.toUpperCase())?.isValid() ?? false; - }, - } - public static formatting = { - "phone": (str, country) => { - console.log("country formatting", country) - return parsePhoneNumberFromString(str, country.toUpperCase()).formatInternational() - } - } /** * Notes: by not giving a 'question', one disables the question form alltogether @@ -35,16 +18,16 @@ export class TagRenderingOptions implements TagDependantUIElementConstructor { public options: { priority?: number; - question?: string | UIElement; + question?: string | Translation; freeform?: { key: string; tagsPreprocessor?: (tags: any) => any; - template: string | UIElement; - renderTemplate: string | UIElement; - placeholder?: string | UIElement; + template: string | Translation; + renderTemplate: string | Translation; + placeholder?: string | Translation; extraTags?: TagsFilter }; - mappings?: { k: TagsFilter; txt: string | UIElement; priority?: number, substitute?: boolean }[] + mappings?: { k: TagsFilter; txt: string | Translation; priority?: number, substitute?: boolean }[] }; @@ -57,7 +40,7 @@ export class TagRenderingOptions implements TagDependantUIElementConstructor { * If 'question' is undefined, then the question is never asked at all * If the question is "" (empty string) then the question is */ - question?: UIElement | string, + question?: Translation | string, /** * What is the priority of the question. @@ -78,7 +61,7 @@ export class TagRenderingOptions implements TagDependantUIElementConstructor { * * */ - mappings?: { k: TagsFilter, txt: UIElement | string, priority?: number, substitute?: boolean }[], + mappings?: { k: TagsFilter, txt: Translation | string, priority?: number, substitute?: boolean }[], /** @@ -88,9 +71,9 @@ export class TagRenderingOptions implements TagDependantUIElementConstructor { */ freeform?: { key: string, - template: string | UIElement, - renderTemplate: string | UIElement - placeholder?: string | UIElement, + template: string | Translation, + renderTemplate: string | Translation + placeholder?: string | Translation, extraTags?: TagsFilter, }, @@ -129,8 +112,33 @@ export class TagRenderingOptions implements TagDependantUIElementConstructor { return true; } + GetContent(tags: any): string { + const tagsKV = TagUtils.proprtiesToKV(tags); + + for (const oneOnOneElement of this.options.mappings ?? []) { + if (oneOnOneElement.k === null || oneOnOneElement.k.matches(tagsKV)) { + const mapping = oneOnOneElement.txt; + if (typeof (mapping) === "string") { + return mapping; + } else { + return mapping.InnerRender(); + } + } + } + if (this.options.freeform !== undefined) { + let template = this.options.freeform.renderTemplate; + if (typeof (template) !== "string") { + template = template.InnerRender(); + } + return TagUtils.ApplyTemplate(template, tags); + } + + return undefined; + } + + + public static tagRendering: (tags: UIEventSource, options: { priority?: number; question?: string | Translation; freeform?: { key: string; tagsPreprocessor?: (tags: any) => any; template: string | Translation; renderTemplate: string | Translation; placeholder?: string | Translation; extraTags?: TagsFilter }; mappings?: { k: TagsFilter; txt: string | Translation; priority?: number; substitute?: boolean }[] }) => TagDependantUIElement; - public static tagRendering : (tags: UIEventSource, options: { priority?: number; question?: string | UIElement; freeform?: { key: string; tagsPreprocessor?: (tags: any) => any; template: string | UIElement; renderTemplate: string | UIElement; placeholder?: string | UIElement; extraTags?: TagsFilter }; mappings?: { k: TagsFilter; txt: string | UIElement; priority?: number; substitute?: boolean }[] }) => TagDependantUIElement; construct(dependencies: Dependencies): TagDependantUIElement { return TagRenderingOptions.tagRendering(dependencies.tags, this.options); } diff --git a/Customizations/UIElementConstructor.ts b/Customizations/UIElementConstructor.ts index f5380cf32..13de70b29 100644 --- a/Customizations/UIElementConstructor.ts +++ b/Customizations/UIElementConstructor.ts @@ -12,6 +12,8 @@ export interface TagDependantUIElementConstructor { IsKnown(properties: any): boolean; IsQuestioning(properties: any): boolean; Priority(): number; + GetContent(tags: any): string; + } export abstract class TagDependantUIElement extends UIElement { diff --git a/Logic/FilteredLayer.ts b/Logic/FilteredLayer.ts index dffbfef77..52397b8fc 100644 --- a/Logic/FilteredLayer.ts +++ b/Logic/FilteredLayer.ts @@ -53,7 +53,7 @@ export class FilteredLayer { this._style = layerDef.style; if (this._style === undefined) { this._style = function () { - return {icon: {iconUrl: "./assets/bug.svg"}, color: "#000000"}; + return {icon: {iconUrl: "./assets/bug.svg"}, color: "#000"}; } } this.name = name; @@ -94,9 +94,9 @@ export class FilteredLayer { var tags = TagUtils.proprtiesToKV(feature.properties); if (this.filters.matches(tags)) { const centerPoint = GeoOperations.centerpoint(feature); - feature.properties["_surface"] = GeoOperations.surfaceAreaInSqMeters(feature); - const lat = centerPoint.geometry.coordinates[1]; - const lon = centerPoint.geometry.coordinates[0] + feature.properties["_surface"] = ""+GeoOperations.surfaceAreaInSqMeters(feature); + const lat = ""+centerPoint.geometry.coordinates[1]; + const lon = ""+centerPoint.geometry.coordinates[0] feature.properties["_lon"] = lat; feature.properties["_lat"] = lon; FilteredLayer.grid.getCode(lat, lon, (error, code) => { @@ -233,8 +233,6 @@ export class FilteredLayer { const style = self._style(featureX.properties); if (featureX === feature) { console.log("Selected element is", featureX.properties.id) - // style.weight = style.weight * 2; - // console.log(style) } return style; }); diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 75bf40b4d..518937e2f 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -19,12 +19,9 @@ export class Changes { public readonly pendingChangesES = new UIEventSource(this._pendingChanges.length); public readonly isSaving = new UIEventSource(false); - private readonly _changesetComment: string; constructor( - changesetComment: string, state: State) { - this._changesetComment = changesetComment; this.SetupAutoSave(state); this.LastEffortSave(); @@ -74,6 +71,9 @@ export class Changes { const eventSource = State.state.allElements.getElement(elementId); eventSource.data[key] = value; + if(value === undefined || value === ""){ + delete eventSource.data[key]; + } eventSource.ping(); // We get the id from the event source, as that ID might be rewritten this._pendingChanges.push({elementId: eventSource.data.id, key: key, value: value}); @@ -223,7 +223,7 @@ export class Changes { console.log("Beginning upload..."); // At last, we build the changeset and upload - State.state.osmConnection.UploadChangeset(self._changesetComment, + State.state.osmConnection.UploadChangeset( function (csId) { let modifications = ""; diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index db9a504c0..16c98c988 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -2,6 +2,7 @@ import osmAuth from "osm-auth"; import {UIEventSource} from "../UIEventSource"; import {CustomLayersState} from "../CustomLayersState"; +import {State} from "../../State"; export class UserDetails { @@ -262,16 +263,16 @@ export class OsmConnection { const newId = parseInt(node.attributes.new_id.value); if (oldId !== undefined && newId !== undefined && !isNaN(oldId) && !isNaN(newId)) { - mapping["node/"+oldId] = "node/"+newId; + mapping["node/" + oldId] = "node/" + newId; } } return mapping; } - public UploadChangeset(comment: string, generateChangeXML: ((csid: string) => string), - handleMapping: ((idMapping: any) => void), - continuation: (() => void)) { + public UploadChangeset(generateChangeXML: (csid: string) => string, + handleMapping: (idMapping: any) => void, + continuation: () => void) { if (this._dryRun) { console.log("NOT UPLOADING as dryrun is true"); @@ -282,7 +283,7 @@ export class OsmConnection { } const self = this; - this.OpenChangeset(comment, + this.OpenChangeset( function (csId) { var changesetXML = generateChangeXML(csId); self.AddChange(csId, changesetXML, @@ -300,17 +301,20 @@ export class OsmConnection { } - private OpenChangeset(comment: string, continuation: ((changesetId: string) => void)) { + private OpenChangeset(continuation: (changesetId: string) => void) { + const layout = State.state.layoutToUse.data; this.auth.xhr({ method: 'PUT', path: '/api/0.6/changeset/create', - options: { header: { 'Content-Type': 'text/xml' } }, - content: '' + - '' + - '' + - '' + options: {header: {'Content-Type': 'text/xml'}}, + content: [``, + ``, + ``, + ``, + layout.maintainer !== undefined ? `` : "", + ``].join("") }, function (err, response) { if (response === undefined) { console.log("err", err); diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 7e0697c50..9e20a679e 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -83,6 +83,9 @@ export abstract class OsmObject { console.log("WARNING: overwriting ",oldV, " with ", v," for key ",k) } this.tags[k] = v; + if(v === undefined || v === ""){ + delete this.tags[k]; + } this.changed = true; } diff --git a/Logic/TagsFilter.ts b/Logic/TagsFilter.ts index a94de5086..303fe9a7c 100644 --- a/Logic/TagsFilter.ts +++ b/Logic/TagsFilter.ts @@ -60,9 +60,6 @@ export class Tag extends TagsFilter { public invertValue: boolean constructor(key: string | RegExp, value: string | RegExp, invertValue = false) { - if (value === "*" && invertValue) { - throw new Error("Invalid combination: invertValue && value == *") - } if (value instanceof RegExp && invertValue) { throw new Error("Unsupported combination: RegExp value and inverted value (use regex to invert the match)") @@ -88,12 +85,17 @@ export class Tag extends TagsFilter { matches(tags: { k: string; v: string }[]): boolean { for (const tag of tags) { if (Tag.regexOrStrMatches(this.key, tag.k)) { + if (tag.v === "") { - // This tag has been removed - return this.value === "" + // This tag has been removed -> always matches false + return false; } if (this.value === "*") { - // Any is allowed + // Any is allowed (as long as the tag is not empty) + return true; + } + + if(this.value === tag.v){ return true; } @@ -288,4 +290,12 @@ export class TagUtils { } return template; } + + static KVtoProperties(tags: Tag[]): any { + const properties = {}; + for (const tag of tags) { + properties[tag.key] = tag.value + } + return properties; + } } diff --git a/README.md b/README.md index e6c065d6c..1eff14309 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ Geolocation is available on mobile only throught hte device's GPS location (so n TODO: erase cookies of third party websites and API's +# Translating MapComplete + +Help to translate mapcomplete. Fork this project, open [the file containing all translations](https://github.com/pietervdvn/MapComplete/blob/master/UI/i18n/Translations.ts), add your language and send a pull request. + # Attributions: Data from OpenStreetMap diff --git a/State.ts b/State.ts index 4cd5c8290..3e2288c5f 100644 --- a/State.ts +++ b/State.ts @@ -24,7 +24,7 @@ export class State { // The singleton of the global state public static state: State; - public static vNumber = "0.0.4"; + public static vNumber = "0.0.5"; public static runningFromConsole: boolean = false; @@ -181,9 +181,7 @@ export class State { this.allElements = new ElementStorage(); - this.changes = new Changes( - "Beantwoorden van vragen met #MapComplete voor vragenset #" + this.layoutToUse.data.name, - this); + this.changes = new Changes(this); if(State.runningFromConsole){ console.warn("running from console - not initializing map. Assuming test.html"); diff --git a/UI/CustomThemeGenerator/Preview.ts b/UI/CustomThemeGenerator/Preview.ts index 9b69cc0eb..5ece0e886 100644 --- a/UI/CustomThemeGenerator/Preview.ts +++ b/UI/CustomThemeGenerator/Preview.ts @@ -2,32 +2,53 @@ import {LayoutConfigJson} from "../../Customizations/JSON/CustomLayoutFromJSON"; import {UIEventSource} from "../../Logic/UIEventSource"; import {UIElement} from "../UIElement"; import Combine from "../Base/Combine"; +import {Button} from "../Base/Button"; +import {VariableUiElement} from "../Base/VariableUIElement"; export class Preview extends UIElement { private url: UIEventSource; private config: UIEventSource; + private currentPreview = new UIEventSource("") + private reloadButton: Button; + private otherPreviews: VariableUiElement; + constructor(url: UIEventSource, config: UIEventSource) { - super(url); + super(undefined); this.config = config; this.url = url; + this.reloadButton = new Button("Reload the preview", () => { + this.currentPreview.setData(`` + + '

The above preview is in testmode. Changes will not be sent to OSM, so feel free to add points and answer questions

', + ); + }); + this.ListenTo(this.currentPreview); + + + this.otherPreviews = new VariableUiElement(this.url.map(url => { + + return [ + `

Your link

`, + 'Bookmark the link below
', + 'MapComplete has no backend. The entire theme configuration is saved in the following URL. This means that this URL is needed to revive and change your MapComplete instance.
', + `${this.url.data}
`, + '

JSON-configuration

', + 'You can see the configuration in JSON format below.
', + '', + JSON.stringify(this.config.data, null, 2).replace(/\n/g, "
").replace(/ /g, " "), + '
' + + ].join("") + + })); } InnerRender(): string { const url = this.url.data; return new Combine([ - ``, - '

The above preview is in testmode. Changes will not be sent to OSM, so feel free to add points and answer questions

', - `

Your link

`, - 'Bookmark the link below
', - 'MapComplete has no backend. The entire theme configuration is saved in the following URL. This means that this URL is needed to revive and change your MapComplete instance.
', - `${this.url.data}
`, - '

JSON-configuration

', - 'You can see the configuration in JSON format below.
', - '', - JSON.stringify(this.config.data, null, 2).replace(/\n/g, "
").replace(/ /g, " "), - '
' - + new VariableUiElement(this.currentPreview), + this.reloadButton, + this.otherPreviews ]).Render(); } diff --git a/UI/CustomThemeGenerator/ThemeGenerator.ts b/UI/CustomThemeGenerator/ThemeGenerator.ts index de8ab7262..3dfe6f28d 100644 --- a/UI/CustomThemeGenerator/ThemeGenerator.ts +++ b/UI/CustomThemeGenerator/ThemeGenerator.ts @@ -3,6 +3,7 @@ import {VerticalCombine} from "../Base/VerticalCombine"; import {VariableUiElement} from "../Base/VariableUIElement"; import Combine from "../Base/Combine"; import { + CustomLayoutFromJSON, LayerConfigJson, LayoutConfigJson, TagRenderingConfigJson @@ -12,9 +13,14 @@ import {UIEventSource} from "../../Logic/UIEventSource"; import {OsmConnection, UserDetails} from "../../Logic/Osm/OsmConnection"; import {Button} from "../Base/Button"; import {FixedUiElement} from "../Base/FixedUiElement"; -import {TextField} from "../Input/TextField"; +import {TextField, ValidatedTextField} from "../Input/TextField"; +import {Tag} from "../../Logic/TagsFilter"; +import {DropDown} from "../Input/DropDown"; +import {TagRendering} from "../../Customizations/TagRendering"; +TagRendering.injectFunction(); + function TagsToString(tags: string | string [] | { k: string, v: string }[]) { if (tags === undefined) { return undefined; @@ -34,30 +40,31 @@ function TagsToString(tags: string | string [] | { k: string, v: string }[]) { return newTags.join(","); } - -let createFieldUI: (label: string, key: string, root: any, options?: { deflt?: string }) => UIElement; +// Defined below, as it needs some context/closure +let createFieldUI: (label: string, key: string, root: any, options: { deflt?: string, type?: string, description: string, emptyAllowed?: boolean }) => UIElement; class MappingGenerator extends UIElement { private elements: UIElement[]; - constructor(fullConfig: UIEventSource, - layerConfig: LayerConfigJson, - tagRendering: TagRenderingConfigJson, + constructor(tagRendering: TagRenderingConfigJson, mapping: { if: string | string[] | { k: string, v: string }[] }) { super(undefined); - this.CreateElements(fullConfig, layerConfig, tagRendering, mapping) + this.CreateElements(tagRendering, mapping) } - private CreateElements(fullConfig: UIEventSource, layerConfig: LayerConfigJson, - tagRendering: TagRenderingConfigJson, + private CreateElements(tagRendering: TagRenderingConfigJson, mapping) { { const self = this; this.elements = [ - createFieldUI("If these tags apply", "if", mapping), - createFieldUI("Then: show this text", "then", mapping), + new FixedUiElement("A mapping shows a specific piece of text if a specific tag is present. If no mapping is known and no key matches (and the question is defined), then the mappings show up as radio buttons to answer the question and to update OSM"), + createFieldUI("If these tags apply", "if", mapping, { + type: "tags", + description: "The tags that have to be present. Use key= to indicate an implicit assumption. 'key=' can be used to indicate: 'if this key is missing'" + }), + createFieldUI("Then: show this text", "then", mapping, {description: "The text that is shown"}), new Button("Remove this mapping", () => { for (let i = 0; i < tagRendering.mappings.length; i++) { if (tagRendering.mappings[i] === mapping) { @@ -89,39 +96,75 @@ class TagRenderingGenerator constructor(fullConfig: UIEventSource, layerConfig: LayerConfigJson, tagRendering: TagRenderingConfigJson, - isTitle: boolean = false) { + options: { header: string, description: string, removable: boolean, hideQuestion: boolean }) { super(undefined); - this.CreateElements(fullConfig, layerConfig, tagRendering, isTitle) + this.CreateElements(fullConfig, layerConfig, tagRendering, options) } - private CreateElements(fullConfig: UIEventSource, layerConfig: LayerConfigJson, tagRendering: TagRenderingConfigJson, isTitle: boolean) { + private CreateElements(fullConfig: UIEventSource, layerConfig: LayerConfigJson, tagRendering: TagRenderingConfigJson, + options: { header: string, description: string, removable: boolean, hideQuestion: boolean }) { const self = this; this.elements = [ - new FixedUiElement(isTitle ? "

Popup title

" : "

TagRendering/TagQuestion

"), - createFieldUI("Key", "key", tagRendering), - createFieldUI("Rendering", "render", tagRendering), - createFieldUI("Type", "type", tagRendering), - createFieldUI("Question", "question", tagRendering), - createFieldUI("Extra tags", "addExtraTags", tagRendering), + new FixedUiElement(`

${options.header}

`), + new FixedUiElement(options.description), + createFieldUI("Key", "key", tagRendering, { + deflt: "name", + description: "Optional. If the object contains a tag with the specified key, the rendering below will be shown. Use '*' if you always want to show the rendering." + }), + createFieldUI("Rendering", "render", tagRendering, { + deflt: "The name of this object is {name}", + description: "Optional. If the above key is present, this rendering will be used. Note that {key} will be replaced by the value - if that key is present. This is _not_ limited to the given key above, it is allowed to use multiple subsitutions." + + "If the above key is _not_ present, the question will be shown and the rendering will be used as answer with {key} as textfield" + }), + options.hideQuestion ? new FixedUiElement("") : createFieldUI("Type", "type", tagRendering, { + deflt: "string", + description: "Input validation of this type", + type: "typeSelector", + + }), + options.hideQuestion ? new FixedUiElement("") : + createFieldUI("Question", "question", tagRendering, { + deflt: "", + description: "Optional. If 'key' is not present (or not given) and none of the mappings below match, then this will be shown as question. Users are then able to answer this question and save the data to OSM. If no question is given, values can still be shown but not answered", + type: "string" + }), + options.hideQuestion ? new FixedUiElement("") : + createFieldUI("Extra tags", "addExtraTags", tagRendering, + { + deflt: "", + type: "tags", + emptyAllowed: true, + description: "Optional. If the freeform text field is used to fill out the tag, these tags are applied as well. The main use case is to flag the object for review. (A prime example is access. A few predefined values are given and the option to fill out something. Here, one can add e.g. fixme=access was filled out by user, value might not be correct" + }), + + createFieldUI( + "Only show if", "condition", tagRendering, + { + deflt: "", + type: "tags", + emptyAllowed: true, + description: "Only show this question/rendering if the object also has the specified tag. This can be useful to ask a follow up question only if the prerequisite is met" + } + ), ...(tagRendering.mappings ?? []).map((mapping) => { - return new MappingGenerator(fullConfig, layerConfig, tagRendering, mapping) + return new MappingGenerator(tagRendering, mapping) }), new Button("Add mapping", () => { if (tagRendering.mappings === undefined) { tagRendering.mappings = [] } tagRendering.mappings.push({if: "", then: ""}); - self.CreateElements(fullConfig, layerConfig, tagRendering, isTitle); + self.CreateElements(fullConfig, layerConfig, tagRendering, options); self.Update(); }) ] - if (!isTitle) { - const b = new Button("Remove this preset", () => { + if (!!options.removable) { + const b = new Button("Remove this tag rendering", () => { for (let i = 0; i < layerConfig.tagRenderings.length; i++) { if (layerConfig.tagRenderings[i] === tagRendering) { layerConfig.tagRenderings.splice(i, 1); @@ -155,10 +198,21 @@ class PresetGenerator extends UIElement { const self = this; this.elements = [ new FixedUiElement("

Preset

"), - createFieldUI("Title", "title", preset0), - createFieldUI("Description", "description", preset0, {deflt: layerConfig.description}), - createFieldUI("icon", "icon", preset0, {deflt: layerConfig.icon}), - createFieldUI("tags", "tags", preset0, {deflt: TagsToString(layerConfig.overpassTags)}), + new FixedUiElement("A preset allows the user to add a new point at a location that was clicked. Note that one layer can have zero, one or multiple presets"), + createFieldUI("Title", "title", preset0, { + description: "The title of this preset, shown in the 'add new {Title} here'-dialog" + }), + createFieldUI("Description", "description", preset0, + { + deflt: layerConfig.description, + type: "string", + description: "A description shown alongside the 'add new'-button" + }), + createFieldUI("tags", "tags", preset0, + { + deflt: TagsToString(layerConfig.overpassTags), type: "tags", + description: "The tags that are added to the newly created point" + }), new Button("Remove this preset", () => { for (let i = 0; i < layerConfig.presets.length; i++) { if (layerConfig.presets[i] === preset0) { @@ -201,12 +255,86 @@ class LayerGenerator extends UIElement { private CreateElements(fullConfig: UIEventSource, layerConfig: LayerConfigJson) { const self = this; this.uielements = [ - createFieldUI("The name of this layer", "id", layerConfig), - createFieldUI("A description of objects for this layer", "description", layerConfig), - createFieldUI("The icon of this layer, either a URL or a base64-encoded svg", "icon", layerConfig), - createFieldUI("The default stroke color", "color", layerConfig), - createFieldUI("The minimal needed zoom to start loading", "minzoom", layerConfig), - createFieldUI("The tags to load from overpass", "overpassTags", layerConfig), + + new FixedUiElement("

A layer is a collection of related objects which have the same or very similar tags renderings. In general, all objects of one layer have the same icon (or at least very similar icons)

"), + + createFieldUI("Name", "id", layerConfig, {description: "The name of this layer"}), + createFieldUI("A description of objects for this layer", "description", layerConfig, {description: "The description of this layer"}), + createFieldUI("Minimum zoom level", "minzoom", layerConfig, { + type: "nat", + deflt: "12", + description: "The minimum zoom level to start loading data. This is mainly limited by the expected number of objects: if there are a lot of objects, then pick something higher. A generous bounding box is put around the map, so some scrolling should be possible" + }), + createFieldUI("The tags to load from overpass", "overpassTags", layerConfig, { + type: "tags", + description: "Tags to load from overpass. The format is key=value&key0=value0&key1=value1, e.g. amenity=public_bookcase or amenity=compressed_air&bicycle=yes. Note that a wildcard is supported, e.g. key=*" + }), + + + new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.title ?? { + key: "", + addExtraTags: "", + mappings: [], + question: "", + render: "Title", + type: "string" + }, { + header: "Title element", + description: "This element is shown in the title of the popup in a header-tag", + removable: false, + hideQuestion: true + }), + + + new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.icon ?? { + key: "*", + addExtraTags: "", + mappings: [], + question: "", + render: "Title", + type: "text" + }, { + header: "Icon", + description: "This decides which icon is used to represent an element on the map. Leave blank if you don't want icons to pop up", + removable: false, + hideQuestion: true + }), + + new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.color ?? { + key: "*", + addExtraTags: "", + mappings: [], + question: "", + render: "Title", + type: "text" + }, { + header: "Colour", + description: "This decides which color is used to represent a way on the map. Note that if an icon is defined as well, the icon will be showed too", + removable: false, + hideQuestion: true + }), + + + ...layerConfig.tagRenderings.map(tr => new TagRenderingGenerator(fullConfig, layerConfig, tr, { + header: "Tag rendering", + description: "A single tag rendering", + removable: true, + hideQuestion: false + })), + new Button("Add a tag rendering", () => { + layerConfig.tagRenderings.push({ + key: undefined, + addExtraTags: undefined, + mappings: [], + question: undefined, + render: undefined, + type: "text" + }); + self.CreateElements(fullConfig, layerConfig); + self.Update(); + }), + + ...layerConfig.presets.map(preset => new PresetGenerator(fullConfig, layerConfig, preset)), new Button("Add a preset", () => { layerConfig.presets.push({ @@ -217,28 +345,7 @@ class LayerGenerator extends UIElement { }); self.CreateElements(fullConfig, layerConfig); self.Update(); - }), - new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.title ?? { - key: "", - addExtraTags: "", - mappings: [], - question: "", - render: "Title", - type: "text" - }, true), - ...layerConfig.tagRenderings.map(tr => new TagRenderingGenerator(fullConfig, layerConfig, tr)), - new Button("Add a tag rendering", () => { - layerConfig.tagRenderings.push({ - key: "", - addExtraTags: "", - mappings: [], - question: "", - render: "", - type: "text" - }); - self.CreateElements(fullConfig, layerConfig); - self.Update(); - }), + }) ] } @@ -274,8 +381,12 @@ class AllLayerComponent extends UIElement { const layerPanes: { header: UIElement | string, content: UIElement | string }[] = []; const config = this.config; for (const layer of this.config.data.layers) { + + const iconUrl = CustomLayoutFromJSON.TagRenderingFromJson(layer?.icon) + .GetContent({id: "node/-1"}); const header = this.config.map(() => { - return `` + + return `` }); layerPanes.push({ header: new VariableUiElement(header), @@ -290,10 +401,17 @@ class AllLayerComponent extends UIElement { config.data.layers.push({ id: "", title: { - render: "Title" + key: "", + render: "title" + }, + icon: { + key: "", + render: "./assets/bug.svg" + }, + color: { + key: "", + render: "#0000ff" }, - icon: "./assets/bug.svg", - color: "", description: "", minzoom: 12, overpassTags: "", @@ -333,38 +451,118 @@ export class ThemeGenerator extends UIElement { if (windowHash !== undefined && windowHash.length > 4) { loadedTheme = JSON.parse(atob(windowHash)); } + this.themeObject = new UIEventSource(loadedTheme ?? defaultTheme); const jsonObjectRoot = this.themeObject.data; + connection.userDetails.addCallback((userDetails) => { + jsonObjectRoot.maintainer = userDetails.name; + }); + jsonObjectRoot.maintainer = connection.userDetails.data.name; + const base64 = this.themeObject.map(JSON.stringify).map(btoa); - this.url = base64.map((data) => `https://pietervdvn.github.io/MapComplete/index.html?test=true&userlayout=true#` + data); + let baseUrl = "https://pietervdvn.github.io/MapComplete"; + if (window.location.hostname === "127.0.0.1") { + baseUrl = "http://127.0.0.1:1234"; + } + this.url = base64.map((data) => baseUrl + `/index.html?test=true&userlayout=true#` + data); const self = this; createFieldUI = (label, key, root, options) => { + options = options ?? {description: "?"}; + options.type = options.type ?? "string"; + const value = new UIEventSource(TagsToString(root[key]) ?? options?.deflt); - value.addCallback((v) => { - root[key] = v; - self.themeObject.ping(); // We assume the root is a part of the themeObject - }) - return new Combine([ - label, - new TextField({ + let textField: UIElement; + if (options.type === "typeSelector") { + const options: { value: string, shown: string | UIElement }[] = []; + for (const possibleType in ValidatedTextField.inputValidation) { + if (possibleType !== "$") { + options.push({value: possibleType, shown: possibleType}); + } + } + + textField = new DropDown("", + options, + value) + } else if (options.type === "tags") { + textField = ValidatedTextField.TagTextField(value.map(CustomLayoutFromJSON.TagsFromJson, [], tags => { + if (tags === undefined) { + return undefined; + } + return tags.map((tag: Tag) => tag.key + "=" + tag.value).join("&"); + }), options?.emptyAllowed ?? false); + } else if (options.type === "img" || options.type === "colour") { + textField = new TextField({ + placeholder: options.type, fromString: (str) => str, toString: (str) => str, - value: value - })]); + value: value, + startValidated: true + }); + } else if (options.type) { + textField = ValidatedTextField.ValidatedTextField(options.type, {value: value}); + } else { + textField = new TextField({ + placeholder: options.type, + fromString: (str) => str, + toString: (str) => str, + value: value, + startValidated: true + }); + } + + value.addCallback((v) => { + if (v === undefined || v === "") { + delete root[key]; + } else { + root[key] = v; + } + self.themeObject.ping(); // We assume the root is a part of the themeObject + }); + return new Combine([ + label, + textField, + "
", + "" + options.description + "" + ]); } this.allQuestionFields = [ - createFieldUI("Name of this theme", "name", jsonObjectRoot), - createFieldUI("Title (shown in the window and in the welcome message)", "title", jsonObjectRoot), - createFieldUI("Description (shown in the welcome message and various other places)", "description", jsonObjectRoot), - createFieldUI("The supported language", "language", jsonObjectRoot), - createFieldUI("startLat", "startLat", jsonObjectRoot), - createFieldUI("startLon", "startLon", jsonObjectRoot), - createFieldUI("startzoom", "startZoom", jsonObjectRoot), - createFieldUI("icon: either a URL to an image file, a relative url to a MapComplete asset ('./asset/help.svg') or a base64-encoded value (including 'data:image/svg+xml;base64,'", "icon", jsonObjectRoot, {deflt: "./assets/bug.svg"}), + createFieldUI("Name of this theme", "name", jsonObjectRoot, {description: "An identifier for this theme"}), + createFieldUI("Title", "title", jsonObjectRoot, { + deflt: "Title", + description: "The title of this theme, as shown in the welcome message and in the title bar of the browser" + }), + createFieldUI("Description", "description", jsonObjectRoot, { + description: "Shown in the welcome message", + deflt: "Description" + }), + createFieldUI("The supported language", "language", jsonObjectRoot, { + description: "The language of this mapcomplete instance. MapComplete can be translated, see here for more information", + deflt: "en" + }), + createFieldUI("startLat", "startLat", jsonObjectRoot, { + type: "float", + deflt: "0", + description: "The latitude where this theme should start. Note that this is only for completely fresh users, as the last location is saved" + }), + createFieldUI("startLon", "startLon", jsonObjectRoot, { + type: "float", + deflt: "0", + description: "The longitude where this theme should start. Note that this is only for completely fresh users, as the last location is saved" + }), + createFieldUI("startzoom", "startZoom", jsonObjectRoot, { + type: "nat", + deflt: "12", + description: "The initial zoom level where the map is located" + }), + createFieldUI("icon", "icon", jsonObjectRoot, { + deflt: "./assets/bug.svg", + type: "img", + description: "The icon representing this MapComplete instance. It is shown in the welcome message and -if adopted as official theme- used as favicon and to browse themes" + }), new AllLayerComponent(this.themeObject) ] @@ -383,8 +581,6 @@ export class ThemeGenerator extends UIElement { return new VerticalCombine([ - // new VariableUiElement(this.themeObject.map(JSON.stringify)), - // new VariableUiElement(this.url.map((url) => `Current URL: Click here to open`)), ...this.allQuestionFields, ]).Render(); } diff --git a/UI/FeatureInfoBox.ts b/UI/FeatureInfoBox.ts index 26b4e2d5b..361b9a279 100644 --- a/UI/FeatureInfoBox.ts +++ b/UI/FeatureInfoBox.ts @@ -11,6 +11,8 @@ import {UserDetails} from "../Logic/Osm/OsmConnection"; import {FixedUiElement} from "./Base/FixedUiElement"; import {State} from "../State"; import {TagRenderingOptions} from "../Customizations/TagRenderingOptions"; +import {UIEventSource} from "../Logic/UIEventSource"; +import Combine from "./Base/Combine"; export class FeatureInfoBox extends UIElement { @@ -36,7 +38,7 @@ export class FeatureInfoBox extends UIElement { constructor( feature: any, tagsES: UIEventSource, - title: TagRenderingOptions | UIElement | string, + title: TagDependantUIElementConstructor | UIElement | string, elementsToShow: TagDependantUIElementConstructor[], ) { super(tagsES); @@ -77,7 +79,7 @@ export class FeatureInfoBox extends UIElement { } else if (title instanceof UIElement) { this._title = title; } else { - this._title = new TagRenderingOptions(title.options).construct(deps); + this._title = title.construct(deps); } this._osmLink = new OsmLink().construct(deps); this._wikipedialink = new WikipediaLink().construct(deps); @@ -124,24 +126,18 @@ export class FeatureInfoBox extends UIElement { questionsHtml = this._someSkipped.Render(); } + const title = new Combine([ + this._title, + this._wikipedialink, + this._osmLink]); + + const infoboxcontents = new Combine( + [ new VerticalCombine(info, "infobox-information "), questionsHtml]); + return "
" + - "
" + - "" + - this._title.Render() + - "" + - this._wikipedialink.Render() + - this._osmLink.Render() + - "
" + - - "
" + - new VerticalCombine(info, "infobox-information ").Render() + - - questionsHtml + - - - "
" + - "" + - "
"; + new Combine([ + "
" + title.Render() + "
", + "
" + infoboxcontents.Render() + "
"]).Render() + ""; } diff --git a/UI/Image/ImageCarouselWithUpload.ts b/UI/Image/ImageCarouselWithUpload.ts index 667206ee1..8590368ab 100644 --- a/UI/Image/ImageCarouselWithUpload.ts +++ b/UI/Image/ImageCarouselWithUpload.ts @@ -24,6 +24,10 @@ export class ImageCarouselWithUploadConstructor implements TagDependantUIElement construct(dependencies): TagDependantUIElement { return new ImageCarouselWithUpload(dependencies); } + + GetContent(tags: any): string { + return undefined; + } } class ImageCarouselWithUpload extends TagDependantUIElement { diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index 140216c8c..af24497c3 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -2,9 +2,97 @@ import {UIElement} from "../UIElement"; import {InputElement} from "./InputElement"; import Translations from "../i18n/Translations"; import {UIEventSource} from "../../Logic/UIEventSource"; +import * as EmailValidator from "email-validator"; +import {parsePhoneNumberFromString} from "libphonenumber-js"; +import {TagRenderingOptions} from "../../Customizations/TagRenderingOptions"; +import {CustomLayoutFromJSON} from "../../Customizations/JSON/CustomLayoutFromJSON"; +import {Tag} from "../../Logic/TagsFilter"; + +export class ValidatedTextField { + public static inputValidation = { + "$": (str) => true, + "string": (str) => true, + "date": (str) => true, // TODO validate and add a date picker + "int": (str) => str.indexOf(".") < 0 && !isNaN(Number(str)), + "nat": (str) => str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) > 0, + "float": (str) => !isNaN(Number(str)), + "pfloat": (str) => !isNaN(Number(str)) && Number(str) > 0, + "email": (str) => EmailValidator.validate(str), + "url": (str) => str, + "phone": (str, country) => { + return parsePhoneNumberFromString(str, country.toUpperCase())?.isValid() ?? false; + } + } + + public static formatting = { + "phone": (str, country) => { + console.log("country formatting", country) + return parsePhoneNumberFromString(str, country.toUpperCase()).formatInternational() + } + } + + public static TagTextField(value: UIEventSource = undefined, allowEmpty: boolean) { + allowEmpty = allowEmpty ?? false; + return new TextField({ + placeholder: "Tags", + fromString: str => { + const tags = CustomLayoutFromJSON.TagsFromJson(str); + if (tags === []) { + if (allowEmpty) { + return [] + } else { + return undefined; + } + } + return tags; + } + , + toString: (tags: Tag[]) => { + if (tags === undefined) { + return undefined; + } + if (tags === []) { + if (allowEmpty) { + return ""; + } else { + return undefined; + } + } + return tags.map(tag => + tag.invertValue ? tag.key + "!=" + tag.value : + tag.key + "=" + tag.value).join("&") + }, + value: value, + startValidated: true + } + ) + } + + public static + + ValidatedTextField(type: string, options: { value?: UIEventSource, country?: string }) + : TextField { + let isValid = ValidatedTextField.inputValidation[type]; + if (isValid === undefined + ) { + throw "Invalid type for textfield: " + type + } + let formatter = ValidatedTextField.formatting[type] ?? ((str) => str); + return new TextField({ + placeholder: type, + toString: str => str, + fromString: str => isValid(str, options?.country) ? formatter(str, options.country) : undefined, + value: options.value, + startValidated: true + }) + + } + +} export class TextField extends InputElement { + private value: UIEventSource; private mappedValue: UIEventSource; /** @@ -14,6 +102,7 @@ export class TextField extends InputElement { private _placeholder: UIElement; private _fromString?: (string: string) => T; private _toString: (t: T) => string; + private startValidated: boolean; constructor(options: { @@ -33,7 +122,8 @@ export class TextField extends InputElement { * @param string */ fromString: (string: string) => T, - value?: UIEventSource + value?: UIEventSource, + startValidated?: boolean, }) { super(undefined); const self = this; @@ -63,7 +153,8 @@ export class TextField extends InputElement { } // @ts-ignore field.value = options.toString(t); - }) + }); + this.startValidated = options.startValidated ?? false; } GetValue(): UIEventSource { @@ -92,6 +183,8 @@ export class TextField extends InputElement { this.mappedValue.addCallback((data) => { field.className = this.mappedValue.data !== undefined ? "valid" : "invalid"; }); + + field.className = this.mappedValue.data !== undefined ? "valid" : "invalid"; const self = this; field.oninput = () => { diff --git a/UI/SearchAndGo.ts b/UI/SearchAndGo.ts index 56ffdaea1..afe1f6937 100644 --- a/UI/SearchAndGo.ts +++ b/UI/SearchAndGo.ts @@ -18,7 +18,8 @@ export class SearchAndGo extends UIElement { this._placeholder.map(uiElement => uiElement.InnerRender(), [Locale.language]) ), fromString: str => str, - toString: str => str + toString: str => str, + value: new UIEventSource("") } ); diff --git a/UI/SimpleAddUI.ts b/UI/SimpleAddUI.ts index 8d1b8985d..e5eaf968a 100644 --- a/UI/SimpleAddUI.ts +++ b/UI/SimpleAddUI.ts @@ -1,5 +1,5 @@ import {UIElement} from "./UIElement"; -import {Tag} from "../Logic/TagsFilter"; +import {Tag, TagUtils} from "../Logic/TagsFilter"; import {FilteredLayer} from "../Logic/FilteredLayer"; import Translations from "./i18n/Translations"; import Combine from "./Base/Combine"; @@ -48,12 +48,18 @@ export class SimpleAddUI extends UIElement { for (const layer of State.state.filteredLayers.data) { for (const preset of layer.layerDef.presets) { + let icon: string = "./assets/bug.svg"; + if (typeof (preset.icon) !== "string") { + console.log("Preset icon is:", preset.icon); + icon = preset.icon.GetContent(TagUtils.KVtoProperties(preset.tags)); + } else { + icon = preset.icon; + } + console.log("Preset icon:", icon) - //