diff --git a/Customizations/HelpText.ts b/Customizations/HelpText.ts new file mode 100644 index 0000000..e0d3ddc --- /dev/null +++ b/Customizations/HelpText.ts @@ -0,0 +1,48 @@ +import {UIElement} from "../UI/UIElement"; +import {SubtleButton} from "../UI/Base/SubtleButton"; +import {VariableUiElement} from "../UI/Base/VariableUIElement"; +import SingleSetting from "../UI/CustomGenerator/SingleSetting"; +import Combine from "../UI/Base/Combine"; +import {UIEventSource} from "../Logic/UIEventSource"; + +export default class HelpText extends UIElement { + + private helpText: UIElement; + private returnButton: UIElement; + + constructor(currentSetting: UIEventSource>) { + super(); + this.returnButton = new SubtleButton("./assets/close.svg", + new VariableUiElement( + currentSetting.map(currentSetting => { + if (currentSetting === undefined) { + return ""; + } + return "Return to general help"; + } + ) + )) + .ListenTo(currentSetting) + .onClick(() => currentSetting.setData(undefined)); + + + this.helpText = new VariableUiElement(currentSetting.map((setting: SingleSetting) => { + if (setting === undefined) { + return "

Welcome to the Custom Theme Builder

" + + "Here, one can make their own custom mapcomplete themes.
" + + "Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it"; + } + + return new Combine(["

", setting._name, "

", setting._description.Render()]).Render(); + })) + + + } + + InnerRender(): string { + return new Combine([this.helpText, + this.returnButton, + ]).Render(); + } + +} \ No newline at end of file diff --git a/Customizations/JSON/FromJSON.ts b/Customizations/JSON/FromJSON.ts index 895cd14..2e9c2f0 100644 --- a/Customizations/JSON/FromJSON.ts +++ b/Customizations/JSON/FromJSON.ts @@ -1,7 +1,7 @@ import {Layout} from "../Layout"; import {LayoutConfigJson} from "./LayoutConfigJson"; import {AndOrTagConfigJson} from "./TagConfigJson"; -import {And, RegexTag, Tag, TagsFilter} from "../../Logic/Tags"; +import {And, Or, RegexTag, Tag, TagsFilter} from "../../Logic/Tags"; import {TagRenderingConfigJson} from "./TagRenderingConfigJson"; import {TagRenderingOptions} from "../TagRenderingOptions"; import Translation from "../../UI/i18n/Translation"; @@ -81,9 +81,14 @@ export class FromJSON { return json; } const tr = {}; + let keyCount = 0; for (let key in json) { + keyCount ++; tr[key] = json[key]; // I'm doing this wrong, I know } + if(keyCount == 0){ + return undefined; + } return new Translation(tr); } @@ -92,14 +97,14 @@ export class FromJSON { } public static TagRenderingWithDefault(json: TagRenderingConfigJson | string, propertyName, defaultValue: string): TagDependantUIElementConstructor { - if (json === undefined) { + if (json === undefined) { if(defaultValue !== undefined){ console.warn(`Using default value ${defaultValue} for ${propertyName}`) return FromJSON.TagRendering(defaultValue); } throw `Tagrendering ${propertyName} is undefined...` } - + if (typeof json === "string") { switch (json) { @@ -133,26 +138,27 @@ export class FromJSON { let template = FromJSON.Translation(json.render); let freeform = undefined; - if (json.freeform) { - - if(json.render === undefined){ - console.error("Freeform is defined, but render is not. This is not allowed.", json) + if (json.freeform?.key) { + // Setup the freeform + if (template === undefined) { + console.error("Freeform.key is defined, but render is not. This is not allowed.", json) throw "Freeform is defined, but render is not. This is not allowed." } - + freeform = { template: `$${json.freeform.type ?? "string"}$`, renderTemplate: template, key: json.freeform.key }; if (json.freeform.addExtraTags) { - freeform["extraTags"] = FromJSON.Tag(json.freeform.addExtraTags); + freeform.extraTags = new And(json.freeform.addExtraTags.map(FromJSON.SimpleTag)) } } else if (json.render) { + // Template (aka rendering) is defined, but freeform.key is not. We allow an input as string freeform = { - template: `$string$`, + template: undefined, // Template to ask is undefined -> we block asking for this key renderTemplate: template, - key: "id" + key: "id" // every object always has an id } } @@ -163,6 +169,10 @@ export class FromJSON { hideInAnswer: mapping.hideInAnswer }) ); + + if(template === undefined && (mappings === undefined || mappings.length === 0)){ + throw "Empty tagrendering detected: no mappings nor template given" + } let rendering = new TagRenderingOptions({ @@ -185,6 +195,9 @@ export class FromJSON { } public static Tag(json: AndOrTagConfigJson | string): TagsFilter { + if(json === undefined){ + throw "Error while parsing a tag: nothing defined. Make sure all the tags are defined and at least one tag is present in a complex expression" + } if (typeof (json) == "string") { const tag = json as string; if (tag.indexOf("!~") >= 0) { @@ -227,7 +240,7 @@ export class FromJSON { return new And(json.and.map(FromJSON.Tag)); } if (json.or !== undefined) { - return new And(json.or.map(FromJSON.Tag)); + return new Or(json.or.map(FromJSON.Tag)); } } @@ -270,7 +283,8 @@ export class FromJSON { }) ?? []; function style(tags) { - const iconSizeStr = iconSize.GetContent(tags).txt.split(","); + const iconSizeStr = + iconSize.GetContent(tags).txt.split(","); const iconwidth = Number(iconSizeStr[0]); const iconheight = Number(iconSizeStr[1]); const iconmode = iconSizeStr[2]; diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index c69901c..566f3ac 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -66,7 +66,7 @@ export interface LayerConfigJson { * Wayhandling: should a way/area be displayed as: * 0) The way itself * 1) The centerpoint and the way - * 2) Only the centerpoint? + * 2) Only the centerpoint */ wayHandling?: number; diff --git a/Customizations/JSON/TagConfigJson.ts b/Customizations/JSON/TagConfigJson.ts index e32cd4f..d167ba0 100644 --- a/Customizations/JSON/TagConfigJson.ts +++ b/Customizations/JSON/TagConfigJson.ts @@ -1,8 +1,5 @@ export interface AndOrTagConfigJson { - and?: (string | AndOrTagConfigJson)[] or?: (string | AndOrTagConfigJson)[] - - -} \ No newline at end of file +} diff --git a/Customizations/JSON/TagRenderingConfigJson.ts b/Customizations/JSON/TagRenderingConfigJson.ts index ffcb925..c432cc8 100644 --- a/Customizations/JSON/TagRenderingConfigJson.ts +++ b/Customizations/JSON/TagRenderingConfigJson.ts @@ -37,7 +37,7 @@ export interface TagRenderingConfigJson { * If a value is added with the textfield, these extra tag is addded. * Usefull to add a 'fixme=freeform textfield used - to be checked' **/ - addExtraTags?: AndOrTagConfigJson | string; + addExtraTags?: string[]; } /** diff --git a/Customizations/TagRendering.ts b/Customizations/TagRendering.ts index 4993337..57b4b58 100644 --- a/Customizations/TagRendering.ts +++ b/Customizations/TagRendering.ts @@ -127,12 +127,13 @@ TagRendering extends UIElement implements TagDependantUIElement { // Prepare the actual input element -> pick an appropriate implementation - this._questionElement = this.InputElementFor(options); + this._questionElement = this.InputElementFor(options) ?? + new FixedInputElement("No input possible", new Tag("a","b")); const save = () => { const selection = self._questionElement.GetValue().data; console.log("Tagrendering: saving tags ", selection); if (selection) { - State.state.changes.addTag(tags.data.id, selection); + State.state?.changes?.addTag(tags.data.id, selection); } self._editMode.setData(false); } @@ -143,7 +144,7 @@ TagRendering extends UIElement implements TagDependantUIElement { if (tags === undefined) { return Translations.t.general.noTagsSelected.SetClass("subtle").Render(); } - const csCount = State.state.osmConnection.userDetails.data.csCount; + const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000; if (csCount < State.userJourney.tagsVisibleAt) { return ""; } @@ -154,7 +155,7 @@ TagRendering extends UIElement implements TagDependantUIElement { return tags.asHumanString(true, true); } ) - ); + ).ListenTo(self._questionElement); const cancel = () => { self._questionSkipped.setData(true); @@ -246,7 +247,7 @@ TagRendering extends UIElement implements TagDependantUIElement { private InputForFreeForm(freeform): InputElement { - if (freeform === undefined) { + if (freeform?.template === undefined) { return undefined; } @@ -269,8 +270,14 @@ TagRendering extends UIElement implements TagDependantUIElement { if (!isValid(string, this._source.data._country)) { return undefined; } + + const tag = new Tag(freeform.key, formatter(string, this._source.data._country)); - + + if (tag.value.length > 255) { + return undefined; // Toolong + } + if (freeform.extraTags === undefined) { return tag; } @@ -340,7 +347,8 @@ TagRendering extends UIElement implements TagDependantUIElement { if (this.IsKnown()) { return false; } - if (this._question === undefined) { + if (this._question === undefined || + (this._freeform?.template === undefined && (this._mapping?.length ?? 0) == 0)) { // We don't ask this question in the first place return false; } @@ -390,15 +398,20 @@ TagRendering extends UIElement implements TagDependantUIElement { InnerRender(): string { - if (this.IsQuestioning() && !State.state?.osmConnection?.userDetails?.data?.loggedIn) { + if (this.IsQuestioning() + && (State.state !== undefined) // If State.state is undefined, we are testing/custom theme building -> show regular save + && !State.state.osmConnection.userDetails.data.loggedIn) { + const question = this.ApplyTemplate(this._question).SetClass('question-text'); return "
" + new Combine([ - question, + question.Render(), "
", this._questionElement.Render(), - "", + "", ]).Render() + "
"; } @@ -428,7 +441,8 @@ TagRendering extends UIElement implements TagDependantUIElement { let editButton = ""; - if (State.state?.osmConnection?.userDetails?.data?.loggedIn && this._question !== undefined) { + if (State.state === undefined || // state undefined -> we are custom testing + State.state?.osmConnection?.userDetails?.data?.loggedIn && this._question !== undefined) { editButton = this._editButton.Render(); } @@ -438,6 +452,8 @@ TagRendering extends UIElement implements TagDependantUIElement { ""; } + console.log("No rendering for",this) + return ""; } diff --git a/Logic/FilteredLayer.ts b/Logic/FilteredLayer.ts index 08f753b..ebb1db1 100644 --- a/Logic/FilteredLayer.ts +++ b/Logic/FilteredLayer.ts @@ -184,16 +184,18 @@ export class FilteredLayer { idsFromOverpass.add(feature.properties.id); fusedFeatures.push(feature); } + this._dataFromOverpass = fusedFeatures; + console.log("New elements are ", this._newElements) for (const feature of this._newElements) { - if (idsFromOverpass.has(feature.properties.id)) { + if (!idsFromOverpass.has(feature.properties.id)) { // This element is not yet uploaded or not yet visible in overpass // We include it in the layer fusedFeatures.push(feature); + console.log("Adding ", feature," to fusedFeatures") } } - this._dataFromOverpass = fusedFeatures; // We use a new, fused dataset data = { diff --git a/Logic/LayerUpdater.ts b/Logic/LayerUpdater.ts index 00ee7c3..f7537fd 100644 --- a/Logic/LayerUpdater.ts +++ b/Logic/LayerUpdater.ts @@ -4,6 +4,7 @@ import {FilteredLayer} from "./FilteredLayer"; import {Bounds} from "./Bounds"; import {Overpass} from "./Osm/Overpass"; import {State} from "../State"; +import {LayerDefinition} from "../Customizations/LayerDefinition"; export class LayerUpdater { @@ -27,7 +28,7 @@ export class LayerUpdater { const self = this; this.sufficentlyZoomed = State.state.locationControl.map(location => { - let minzoom = Math.min(...state.layoutToUse.data.layers.map(layer => layer.minzoom ?? 18)); + let minzoom = Math.min(...state.layoutToUse.data.layers.map(layer => (layer as LayerDefinition).minzoom ?? 18)); return location.zoom >= minzoom; }, [state.layoutToUse] ); @@ -49,6 +50,9 @@ export class LayerUpdater { const filters: TagsFilter[] = []; state = state ?? State.state; for (const layer of state.layoutToUse.data.layers) { + if(typeof(layer) === "string"){ + continue; + } if (state.locationControl.data.zoom < layer.minzoom) { console.log("Not loading layer ", layer.id, " as it needs at least ", layer.minzoom, "zoom") continue; diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 4282a28..042090a 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -36,9 +36,8 @@ export class UIEventSource{ }); for (const possibleSource of possibleSources) { - possibleSource.addCallback(() => { + possibleSource?.addCallback(() => { sink.setData(source.data?.data); - }) } @@ -86,5 +85,25 @@ export class UIEventSource{ } return this; } + + public stabilized(millisToStabilize) : UIEventSource{ + + const newSource = new UIEventSource(this.data); + + let currentCallback = 0; + this.addCallback(latestData => { + currentCallback++; + const thisCallback = currentCallback; + window.setTimeout(() => { + if(thisCallback === currentCallback){ + newSource.setData(latestData); + } + }, millisToStabilize) + }); + + return newSource; + + + } } \ No newline at end of file diff --git a/UI/Base/Combine.ts b/UI/Base/Combine.ts index 1dac940..d8cf3fb 100644 --- a/UI/Base/Combine.ts +++ b/UI/Base/Combine.ts @@ -2,14 +2,17 @@ import {UIElement} from "../UIElement"; import Translations from "../i18n/Translations"; export default class Combine extends UIElement { - private uiElements: (string | UIElement)[]; - private className: string = undefined; - private clas: string = undefined; + private readonly uiElements: (string | UIElement)[]; + private readonly className: string = undefined; constructor(uiElements: (string | UIElement)[], className: string = undefined) { super(undefined); + this.dumbMode = false; this.className = className; this.uiElements = uiElements; + if (className) { + console.error("Deprecated used of className") + } } InnerRender(): string { diff --git a/UI/Base/PageSplit.ts b/UI/Base/PageSplit.ts new file mode 100644 index 0000000..96791c4 --- /dev/null +++ b/UI/Base/PageSplit.ts @@ -0,0 +1,20 @@ +import {UIElement} from "../UIElement"; + +export default class PageSplit extends UIElement{ + private _left: UIElement; + private _right: UIElement; + private _leftPercentage: number; + + constructor(left: UIElement, right:UIElement, + leftPercentage: number = 50) { + super(); + this._left = left; + this._right = right; + this._leftPercentage = leftPercentage; + } + + InnerRender(): string { + return `${this._left.Render()}${this._right.Render()}`; + } + +} \ No newline at end of file diff --git a/UI/Base/SubtleButton.ts b/UI/Base/SubtleButton.ts index ec0f09a..d99aa83 100644 --- a/UI/Base/SubtleButton.ts +++ b/UI/Base/SubtleButton.ts @@ -4,9 +4,9 @@ import Combine from "./Combine"; export class SubtleButton extends UIElement{ - private imageUrl: string; - private message: UIElement; - private linkTo: { url: string, newTab?: boolean } = undefined; + private readonly imageUrl: string; + private readonly message: UIElement; + private readonly linkTo: { url: string, newTab?: boolean } = undefined; constructor(imageUrl: string, message: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined) { super(undefined); @@ -18,7 +18,7 @@ export class SubtleButton extends UIElement{ InnerRender(): string { - if(this.message.IsEmpty()){ + if(this.message !== null && this.message.IsEmpty()){ return ""; } @@ -26,7 +26,7 @@ export class SubtleButton extends UIElement{ return new Combine([ ``, this.imageUrl !== undefined ? `` : "", - this.message, + this.message ?? "", '' ]).Render(); } @@ -34,7 +34,7 @@ export class SubtleButton extends UIElement{ return new Combine([ '', this.imageUrl !== undefined ? `` : "", - this.message, + this.message ?? "", '' ]).Render(); } diff --git a/UI/Base/TabbedComponent.ts b/UI/Base/TabbedComponent.ts index 2c45e85..b7c5d47 100644 --- a/UI/Base/TabbedComponent.ts +++ b/UI/Base/TabbedComponent.ts @@ -7,8 +7,8 @@ export class TabbedComponent extends UIElement { private headers: UIElement[] = []; private content: UIElement[] = []; - constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab : UIEventSource = new UIEventSource(0)) { - super(openedTab); + constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource | number) = 0) { + super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource(0))); const self = this; for (let i = 0; i < elements.length; i++) { let element = elements[i]; diff --git a/UI/CustomGenerator/AllLayersPanel.ts b/UI/CustomGenerator/AllLayersPanel.ts index d83e59c..89f6337 100644 --- a/UI/CustomGenerator/AllLayersPanel.ts +++ b/UI/CustomGenerator/AllLayersPanel.ts @@ -3,34 +3,30 @@ import {TabbedComponent} from "../Base/TabbedComponent"; import {SubtleButton} from "../Base/SubtleButton"; import {UIEventSource} from "../../Logic/UIEventSource"; import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; -import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson"; import LayerPanel from "./LayerPanel"; import SingleSetting from "./SingleSetting"; +import Combine from "../Base/Combine"; +import {GenerateEmpty} from "./GenerateEmpty"; +import PageSplit from "../Base/PageSplit"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import HelpText from "../../Customizations/HelpText"; +import {MultiTagInput} from "../Input/MultiTagInput"; +import {FromJSON} from "../../Customizations/JSON/FromJSON"; +import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import TagRenderingPanel from "./TagRenderingPanel"; export default class AllLayersPanel extends UIElement { private panel: UIElement; - private _config: UIEventSource; - private _currentlySelected: UIEventSource>; - private languages: UIEventSource; + private readonly _config: UIEventSource; + private readonly languages: UIEventSource; - private static createEmptyLayer(): LayerConfigJson { - return { - id: undefined, - name: undefined, - minzoom: 0, - overpassTags: undefined, - title: undefined, - description: {} - } - } - - constructor(config: UIEventSource, currentlySelected: UIEventSource>, + constructor(config: UIEventSource, languages: UIEventSource) { super(undefined); this._config = config; - this._currentlySelected = currentlySelected; this.languages = languages; this.createPanels(); @@ -46,23 +42,85 @@ export default class AllLayersPanel extends UIElement { const layers = this._config.data.layers; for (let i = 0; i < layers.length; i++) { + const currentlySelected = new UIEventSource<(SingleSetting)>(undefined); + const layer = new LayerPanel(this._config, this.languages, i, currentlySelected); + const helpText = new HelpText(currentlySelected); + + const previewTagInput = new MultiTagInput(); + previewTagInput.GetValue().setData(["id=123456"]); + const previewTagValue = previewTagInput.GetValue().map(tags => { + const properties = {}; + for (const str of tags) { + const tag = FromJSON.SimpleTag(str); + if (tag !== undefined) { + properties[tag.key] = tag.value; + } + } + return properties; + }); + + const preview = new VariableUiElement(layer.selectedTagRendering.map( + (tagRenderingPanel: TagRenderingPanel) => { + if (tagRenderingPanel === undefined) { + return "No tag rendering selected at the moment"; + } + + let es = tagRenderingPanel.GetValue(); + let tagRenderingConfig: TagRenderingConfigJson = es.data; + + let rendering: UIElement; + try { + rendering = FromJSON.TagRendering(tagRenderingConfig) + .construct({tags: previewTagValue}) + } catch (e) { + console.error("User defined tag rendering incorrect:", e); + rendering = new FixedUiElement(e).SetClass("alert"); + } + + return new Combine([ + "

", + tagRenderingPanel.options.title ?? "Extra tag rendering", + "

", + tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup", + "
", + rendering]).Render(); + + }, + [this._config] + )).ListenTo(layer.selectedTagRendering); + tabs.push({ header: "", - content: new LayerPanel(this._config, this.languages, i, this._currentlySelected) + content: + new PageSplit( + layer.SetClass("scrollable"), + new Combine([ + helpText, + "
", + "

Testing tags

", + previewTagInput, + "

Tag Rendering preview

", + preview + + ]), 60 + ) }); } tabs.push({ header: "", - content: new SubtleButton( - "./assets/add.svg", - "Add a new layer" - ).onClick(() => { - self._config.data.layers.push(AllLayersPanel.createEmptyLayer()) - self._config.ping(); - }) + content: new Combine([ + "

Layer editor

", + "In this tab page, you can add and edit the layers of the theme. Click the layers above or add a new layer to get started.", + new SubtleButton( + "./assets/add.svg", + "Add a new layer" + ).onClick(() => { + self._config.data.layers.push(GenerateEmpty.createEmptyLayer()) + self._config.ping(); + })]) }) - - this.panel = new TabbedComponent(tabs, new UIEventSource(Math.max(0, layers.length-1))); + + this.panel = new TabbedComponent(tabs, new UIEventSource(Math.max(0, layers.length - 1))); this.Update(); } diff --git a/UI/CustomGenerator/GenerateEmpty.ts b/UI/CustomGenerator/GenerateEmpty.ts new file mode 100644 index 0000000..5a9c67c --- /dev/null +++ b/UI/CustomGenerator/GenerateEmpty.ts @@ -0,0 +1,67 @@ +import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson"; +import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; +import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; + +export class GenerateEmpty { + public static createEmptyLayer(): LayerConfigJson { + return { + id: undefined, + name: undefined, + minzoom: 0, + overpassTags: {and: [""]}, + title: undefined, + description: {}, + } + } + + public static createEmptyLayout(): LayoutConfigJson { + return { + id: "", + title: {}, + description: {}, + language: [], + maintainer: "", + icon: "./assets/bug.svg", + version: "0", + startLat: 0, + startLon: 0, + startZoom: 1, + socialImage: "", + layers: [] + } + } + + public static createTestLayout(): LayoutConfigJson { + return { + id: "test", + title: {"en": "Test layout"}, + description: {"en": "A layout for testing"}, + language: ["en"], + maintainer: "Pieter Vander Vennet", + icon: "./assets/bug.svg", + version: "0", + startLat: 0, + startLon: 0, + startZoom: 1, + widenFactor: 0.05, + socialImage: "", + layers: [{ + id: "testlayer", + name: "Testing layer", + minzoom: 15, + overpassTags: {and: ["highway=residential"]}, + title: "Some Title", + description: {"en": "Some Description"}, + icon: {render: {en: "./assets/pencil.svg"}}, + width: {render: {en: "5"}}, + tagRenderings: [{ + render: {"en":"Test Rendering"} + }] + }] + } + } + + public static createEmptyTagRendering(): TagRenderingConfigJson { + return {}; + } +} \ No newline at end of file diff --git a/UI/CustomGenerator/LayerPanel.ts b/UI/CustomGenerator/LayerPanel.ts index 0db2305..fe2817e 100644 --- a/UI/CustomGenerator/LayerPanel.ts +++ b/UI/CustomGenerator/LayerPanel.ts @@ -9,24 +9,37 @@ import {TextField} from "../Input/TextField"; import {InputElement} from "../Input/InputElement"; import MultiLingualTextFields from "../Input/MultiLingualTextFields"; import {CheckBox} from "../Input/CheckBox"; -import {MultiTagInput} from "../Input/MultiTagInput"; +import {AndOrTagInput} from "../Input/AndOrTagInput"; +import TagRenderingPanel from "./TagRenderingPanel"; +import {GenerateEmpty} from "./GenerateEmpty"; +import {DropDown} from "../Input/DropDown"; +import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; +import {MultiInput} from "../Input/MultiInput"; +import {Tag} from "../../Logic/Tags"; +import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson"; /** * Shows the configuration for a single layer */ export default class LayerPanel extends UIElement { - private _config: UIEventSource; + private readonly _config: UIEventSource; - private settingsTable: UIElement; + private readonly settingsTable: UIElement; + private readonly renderingOptions: UIElement; - private deleteButton: UIElement; + private readonly deleteButton: UIElement; + + public readonly selectedTagRendering: UIEventSource + = new UIEventSource(undefined); + private tagRenderings: UIElement; constructor(config: UIEventSource, languages: UIEventSource, index: number, currentlySelected: UIEventSource>) { - super(undefined); + super(); this._config = config; + this.renderingOptions = this.setupRenderOptions(config, languages, index, currentlySelected); const actualDeleteButton = new SubtleButton( "./assets/delete.svg", @@ -70,17 +83,120 @@ export default class LayerPanel extends UIElement { setting(TextField.StringInput(), "id", "Id", "An identifier for this layer
This should be a simple, lowercase, human readable string that is used to identify the layer."), setting(new MultiLingualTextFields(languages), "title", "Title", "The human-readable name of this layer
Used in the layer control panel and the 'Personal theme'"), setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.
Shown in the layer selections and in the personal theme"), - setting(new MultiTagInput(), "overpassTags","Overpass query", - new Combine(["The tags to load from overpass. ", MultiTagInput.tagExplanation])) + setting(TextField.NumberInput("nat", n => n < 23), "minzoom", "Minimal zoom", + "The minimum zoomlevel needed to load and show this layer."), + setting(new DropDown("", [ + {value: 0, shown: "Show ways and areas as ways and lines"}, + {value: 1, shown: "Show both the ways/areas and the centerpoints"}, + {value: 2, shown: "Show everything as centerpoint"}]), "wayHandling", "Way handling", + "Describes how ways and areas are represented on the map: areas can be represented as the area itself, or it can be converted into the centerpoint"), + + setting(new AndOrTagInput(), "overpassTags", "Overpass query", + "The tags of the objects to load from overpass"), + ], - currentlySelected + currentlySelected); + const self = this; + + const tagRenderings = new MultiInput("Add a tag rendering/question", + () => ({}), + () => { + const tagPanel = new TagRenderingPanel(languages, currentlySelected) + self.registerTagRendering(tagPanel); + return tagPanel; + }); + tagRenderings.GetValue().addCallback( + tagRenderings => { + (config.data.layers[index] as LayerConfigJson).tagRenderings = tagRenderings; + config.ping(); + } ) - ; + + function loadTagRenderings() { + const values = (config.data.layers[index] as LayerConfigJson).tagRenderings; + const renderings: TagRenderingConfigJson[] = []; + for (const value of values) { + if (typeof (value) !== "string") { + renderings.push(value); + } + + } + tagRenderings.GetValue().setData(renderings); + } + + loadTagRenderings(); + + this.tagRenderings = tagRenderings; + + + } + + private setupRenderOptions(config: UIEventSource, + languages: UIEventSource, + index: number, + currentlySelected: UIEventSource>): UIElement { + const iconSelect = new TagRenderingPanel( + languages, currentlySelected, + { + title: "Icon", + description: "A visual representation for this layer and for the points on the map.", + disableQuestions: true + }); + const size = new TagRenderingPanel(languages, currentlySelected, + { + title: "Icon Size", + description: "The size of the icons on the map in pixels. Can vary based on the tagging", + disableQuestions: true + }); + const color = new TagRenderingPanel(languages, currentlySelected, + { + title: "Way and area color", + description: "The color or a shown way or area. Can vary based on the tagging", + disableQuestions: true + }); + const stroke = new TagRenderingPanel(languages, currentlySelected, + { + title: "Stroke width", + description: "The width of lines representing ways and the outline of areas. Can vary based on the tags", + disableQuestions: true + }); + this.registerTagRendering(iconSelect); + this.registerTagRendering(size); + this.registerTagRendering(color); + this.registerTagRendering(stroke); + + function setting(input: InputElement, path, isIcon: boolean = false): SingleSetting { + return new SingleSetting(config, input, ["layers", index, path], undefined, undefined) + } + + return new SettingsTable([ + setting(iconSelect, "icon"), + setting(size, "size"), + setting(color, "color"), + setting(stroke, "stroke") + ], currentlySelected); + } + + private registerTagRendering( + tagRenderingPanel: TagRenderingPanel) { + + tagRenderingPanel.IsHovered().addCallback(isHovering => { + if (!isHovering) { + return; + } + this.selectedTagRendering.setData(tagRenderingPanel); + }) } InnerRender(): string { return new Combine([ + "

General layer settings

", this.settingsTable, + "

Map rendering options

", + this.renderingOptions, + "

Tag rendering and questions

", + this.tagRenderings, + "

Layer delete

", this.deleteButton ]).Render(); } diff --git a/UI/CustomGenerator/MappingInput.ts b/UI/CustomGenerator/MappingInput.ts new file mode 100644 index 0000000..9431db0 --- /dev/null +++ b/UI/CustomGenerator/MappingInput.ts @@ -0,0 +1,64 @@ +import {InputElement} from "../Input/InputElement"; +import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {UIElement} from "../UIElement"; +import SettingsTable from "./SettingsTable"; +import SingleSetting from "./SingleSetting"; +import {AndOrTagInput} from "../Input/AndOrTagInput"; +import MultiLingualTextFields from "../Input/MultiLingualTextFields"; +import {DropDown} from "../Input/DropDown"; + +export default class MappingInput extends InputElement<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }> { + + private readonly _value: UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }>; + private readonly _panel: UIElement; + + constructor(languages: UIEventSource, disableQuestions: boolean = false) { + super(); + const currentSelected = new UIEventSource>(undefined); + this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({ + if: undefined, + then: undefined + }); + const self = this; + + function setting(inputElement: InputElement, path: string, name: string, description: string | UIElement) { + return new SingleSetting(self._value, inputElement, path, name, description); + } + + const withQuestions = [setting(new DropDown("", + [{value: false, shown: "Can be used as answer"}, {value: true, shown: "Not an answer option"}]), + "hideInAnswer", "Answer option", + "Sometimes, multiple tags for the same meaning are used (e.g. access=yes and access=public)." + + "Use this toggle to disable an anwer. Alternatively an implied/assumed rendering can be used. In order to do this:" + + "use a single tag in the 'if' with no value defined, e.g. indoor=. The mapping will then be shown as default until explicitly changed" + )]; + + this._panel = new SettingsTable([ + setting(new AndOrTagInput(), "if", "If matches", "If this condition matches, the template then below will be used"), + setting(new MultiLingualTextFields(languages), + "then", "Then show", "If the condition above matches, this template then below will be shown to the user."), + ...(disableQuestions ? [] : withQuestions) + + ], currentSelected).SetClass("bordered tag-mapping"); + + } + + + InnerRender(): string { + return this._panel.Render(); + } + + + GetValue(): UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }> { + return this._value; + } + + + IsSelected: UIEventSource = new UIEventSource(false); + + IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean { + return false; + } + +} \ No newline at end of file diff --git a/UI/CustomGenerator/SettingsTable.ts b/UI/CustomGenerator/SettingsTable.ts index 1d0b4eb..f16fbce 100644 --- a/UI/CustomGenerator/SettingsTable.ts +++ b/UI/CustomGenerator/SettingsTable.ts @@ -1,27 +1,33 @@ import SingleSetting from "./SingleSetting"; import {UIElement} from "../UIElement"; import {FixedUiElement} from "../Base/FixedUiElement"; -import {InputElement} from "../Input/InputElement"; import {UIEventSource} from "../../Logic/UIEventSource"; +import PageSplit from "../Base/PageSplit"; import Combine from "../Base/Combine"; -import {VariableUiElement} from "../Base/VariableUIElement"; export default class SettingsTable extends UIElement { private _col1: UIElement[] = []; - private _col2: InputElement[] = []; + private _col2: UIElement[] = []; public selectedSetting: UIEventSource>; - constructor(elements: SingleSetting[], + constructor(elements: (SingleSetting | string)[], currentSelectedSetting: UIEventSource>) { super(undefined); const self = this; this.selectedSetting = currentSelectedSetting ?? new UIEventSource>(undefined); for (const element of elements) { - let title: UIElement = new FixedUiElement(element._name); + if(typeof element === "string"){ + this._col1.push(new FixedUiElement(element)); + this._col2.push(null); + continue; + } + + let title: UIElement = element._name === undefined ? null : new FixedUiElement(element._name); this._col1.push(title); this._col2.push(element._value); + element._value.SetStyle("display:block"); element._value.IsSelected.addCallback(isSelected => { if (isSelected) { self.selectedSetting.setData(element); @@ -34,13 +40,19 @@ export default class SettingsTable extends UIElement { } InnerRender(): string { - let html = ""; + let elements = []; for (let i = 0; i < this._col1.length; i++) { - html += `${this._col1[i].Render()}${this._col2[i].Render()}` + if(this._col1[i] !== null && this._col2[i] !== null){ + elements.push(new PageSplit(this._col1[i], this._col2[i], 25)); + }else if(this._col1[i] !== null){ + elements.push(this._col1[i]) + }else{ + elements.push(this._col2[i]) + } } - return `${html}
`; + return new Combine(elements).Render(); } - + } \ No newline at end of file diff --git a/UI/CustomGenerator/SingleSetting.ts b/UI/CustomGenerator/SingleSetting.ts index 3c4b684..fdf3c16 100644 --- a/UI/CustomGenerator/SingleSetting.ts +++ b/UI/CustomGenerator/SingleSetting.ts @@ -1,4 +1,3 @@ -import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; import {UIEventSource} from "../../Logic/UIEventSource"; import {InputElement} from "../Input/InputElement"; import {UIElement} from "../UIElement"; @@ -12,7 +11,7 @@ export default class SingleSetting { public _description: UIElement; public _options: { showIconPreview?: boolean }; - constructor(config: UIEventSource, + constructor(config: UIEventSource, value: InputElement, path: string | (string | number)[], name: string, @@ -47,11 +46,17 @@ export default class SingleSetting { // We have to rewalk every time as parts might be new let configPart = config.data; for (const pathPart of path) { - configPart = configPart[pathPart]; - if (configPart === undefined) { - console.warn("Lost the way for path ", path) - return; + let newConfigPart = configPart[pathPart]; + if (newConfigPart === undefined) { + console.warn("Lost the way for path ", path, " - creating entry") + if (typeof (pathPart) === "string") { + configPart[pathPart] = {}; + } else { + configPart[pathPart] = []; + } + newConfigPart = configPart[pathPart]; } + configPart = newConfigPart; } configPart[lastPart] = value; config.ping(); @@ -66,7 +71,6 @@ export default class SingleSetting { } } const loadedValue = configPart[lastPart]; - if (loadedValue !== undefined) { value.GetValue().setData(loadedValue); } @@ -79,6 +83,8 @@ export default class SingleSetting { } + + } \ No newline at end of file diff --git a/UI/CustomGenerator/TagRenderingPanel.ts b/UI/CustomGenerator/TagRenderingPanel.ts new file mode 100644 index 0000000..2f2b9bb --- /dev/null +++ b/UI/CustomGenerator/TagRenderingPanel.ts @@ -0,0 +1,103 @@ +import {UIElement} from "../UIElement"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {InputElement} from "../Input/InputElement"; +import SingleSetting from "./SingleSetting"; +import SettingsTable from "./SettingsTable"; +import {TextField, ValidatedTextField} from "../Input/TextField"; +import Combine from "../Base/Combine"; +import MultiLingualTextFields from "../Input/MultiLingualTextFields"; +import {AndOrTagInput} from "../Input/AndOrTagInput"; +import {MultiTagInput} from "../Input/MultiTagInput"; +import {MultiInput} from "../Input/MultiInput"; +import MappingInput from "./MappingInput"; +import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; +import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; + +export default class TagRenderingPanel extends InputElement { + + private intro: UIElement; + private settingsTable: UIElement; + + public IsImage = false; + private readonly _value: UIEventSource; + public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; }; + + constructor(languages: UIEventSource, + currentlySelected: UIEventSource>, + options?: { + title?: string, + description?: string, + disableQuestions?: boolean, + isImage?: boolean + }) { + super(); + + this.SetClass("bordered"); + this.SetClass("min-height"); + + this.options = options ?? {}; + + this.intro = new Combine(["

", options?.title ?? "TagRendering", "

", options?.description ?? ""]) + this.IsImage = options?.isImage ?? false; + + const value = new UIEventSource({}); + this._value = value; + + function setting(input: InputElement, id: string | string[], name: string, description: string | UIElement): SingleSetting { + return new SingleSetting(value, input, id, name, description); + } + + + const questionSettings = [ + + setting(new MultiLingualTextFields(languages), "question", "Question", "If the key or mapping doesn't match, this question is asked"), + + setting(new AndOrTagInput(), "condition", "Condition", + "Only show this tag rendering if these tags matches. Optional field.
Note that the Overpass-tags are already always included in this object"), + + "

Freeform key

", + setting(TextField.KeyInput(), ["freeform", "key"], "Freeform key
", + "If specified, the rendering will search if this key is present." + + "If it is, the rendering above will be used to display the element.
" + + "The rendering will go into question mode if
  • this key is not present
  • No single mapping matches
  • A question is given
  • "), + + setting(ValidatedTextField.TypeDropdown(), ["freeform", "type"], "Freeform type", + "The type of this freeform text field, in order to validate"), + setting(new MultiTagInput(), ["freeform", "addExtraTags"], "Extra tags on freeform", + "When the freeform text field is used, the user might mean a predefined key. This field allows to add extra tags, e.g. fixme=User used a freeform field - to check"), + + ]; + + const settings: (string | SingleSetting)[] = [ + setting(new MultiLingualTextFields(languages), "render", "Value to show", " Renders this value. Note that {key}-parts are substituted by the corresponding values of the element. If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value."), + ...(options?.disableQuestions ? [] : questionSettings), + + "

    Mappings

    ", + setting(new MultiInput<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }>("Add a mapping", + () => ({if: undefined, then: undefined}), + () => new MappingInput(languages, options?.disableQuestions ?? false)), "mappings", + "Mappings", "") + + ]; + + this.settingsTable = new SettingsTable(settings, currentlySelected); + } + + InnerRender(): string { + return new Combine([ + this.intro, + this.settingsTable]).Render(); + } + + GetValue(): UIEventSource { + return this._value; + } + + IsSelected: UIEventSource = new UIEventSource(false); + + IsValid(t: TagRenderingConfigJson): boolean { + return false; + } + + +} \ No newline at end of file diff --git a/UI/CustomGenerator/TagRenderingPreview.ts b/UI/CustomGenerator/TagRenderingPreview.ts new file mode 100644 index 0000000..61c7163 --- /dev/null +++ b/UI/CustomGenerator/TagRenderingPreview.ts @@ -0,0 +1,15 @@ +import {UIElement} from "../UIElement"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import TagRenderingPanel from "./TagRenderingPanel"; + +export default class TagRenderingPreview extends UIElement{ + + constructor(selectedTagRendering: UIEventSource) { + super(selectedTagRendering); + } + + InnerRender(): string { + return ""; + } + +} \ No newline at end of file diff --git a/UI/Input/AndOrTagInput.ts b/UI/Input/AndOrTagInput.ts index cd5f68e..5935814 100644 --- a/UI/Input/AndOrTagInput.ts +++ b/UI/Input/AndOrTagInput.ts @@ -3,88 +3,162 @@ import {UIEventSource} from "../../Logic/UIEventSource"; import {UIElement} from "../UIElement"; import Combine from "../Base/Combine"; import {SubtleButton} from "../Base/SubtleButton"; -import TagInput from "./TagInput"; -import {FixedUiElement} from "../Base/FixedUiElement"; +import {CheckBox} from "./CheckBox"; +import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; +import {MultiTagInput} from "./MultiTagInput"; +import {FormatNumberOptions} from "libphonenumber-js"; -export class AndOrTagInput extends InputElement<(string | AndOrTagInput)[]> { +class AndOrConfig implements AndOrTagConfigJson { + public and: (string | AndOrTagConfigJson)[] = undefined; + public or: (string | AndOrTagConfigJson)[] = undefined; +} - private readonly _value: UIEventSource; +export class AndOrTagInput extends InputElement { + + private readonly _rawTags = new MultiTagInput(); + private readonly _subAndOrs: AndOrTagInput[] = []; + private readonly _isAnd: UIEventSource = new UIEventSource(true); + private readonly _isAndButton; + private readonly _addBlock: UIElement; + private readonly _value: UIEventSource = new UIEventSource(undefined); + + public bottomLeftButton: UIElement; + IsSelected: UIEventSource; - private elements: UIElement[] = []; - private inputELements: (InputElement | InputElement)[] = []; - private addTag: UIElement; - constructor(value: UIEventSource = new UIEventSource([])) { - super(undefined); - this._value = value; - - this.addTag = new SubtleButton("./assets/addSmall.svg", "Add a tag") - .SetClass("small-button") - .onClick(() => { - this.IsSelected.setData(true); - value.data.push(""); - value.ping(); - }); + constructor() { + super(); const self = this; - value.map((tags: string[]) => tags.length).addCallback(() => self.createElements()); - this.createElements(); + this._isAndButton = new CheckBox( + new SubtleButton("./assets/ampersand.svg", null).SetClass("small-button"), + new SubtleButton("./assets/or.svg", null).SetClass("small-button"), + this._isAnd); - this._value.addCallback(tags => self.load(tags)); - this.IsSelected = new UIEventSource(false); - } + this._addBlock = + new SubtleButton("./assets/addSmall.svg", "Add an and/or-expression") + .SetClass("small-button") + .onClick(() => {self.createNewBlock()}); + + + this._isAnd.addCallback(() => self.UpdateValue()); + this._rawTags.GetValue().addCallback(() => { + self.UpdateValue() + }); + + this.IsSelected = this._rawTags.IsSelected; + + this._value.addCallback(tags => self.loadFromValue(tags)); - private load(tags: string[]) { - if (tags === undefined) { - return; - } - for (let i = 0; i < tags.length; i++) { - console.log("Setting tag ", i) - this.inputELements[i].GetValue().setData(tags[i]); - } } - private UpdateIsSelected(){ - this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b)) - } - - private createElements() { - this.inputELements = []; - this.elements = []; - for (let i = 0; i < this._value.data.length; i++) { - let tag = this._value.data[i]; - const input = new TagInput(new UIEventSource(tag)); - input.GetValue().addCallback(tag => { - console.log("Writing ", tag) - this._value.data[i] = tag; - this._value.ping(); - } - ); - this.inputELements.push(input); - input.IsSelected.addCallback(() => this.UpdateIsSelected()); - const deleteBtn = new FixedUiElement("") - .onClick(() => { - this._value.data.splice(i, 1); - this._value.ping(); - }); - this.elements.push(new Combine([input, deleteBtn, "
    "]).SetClass("tag-input-row")) - } - + private createNewBlock(){ + const inputEl = new AndOrTagInput(); + inputEl.GetValue().addCallback(() => this.UpdateValue()); + const deleteButton = this.createDeleteButton(inputEl.id); + inputEl.bottomLeftButton = deleteButton; + this._subAndOrs.push(inputEl); this.Update(); } - InnerRender(): string { - return new Combine([...this.elements, this.addTag]).SetClass("bordered").Render(); + private createDeleteButton(elementId: string): UIElement { + const self = this; + return new SubtleButton("./assets/delete.svg", null).SetClass("small-button") + .onClick(() => { + for (let i = 0; i < self._subAndOrs.length; i++) { + if (self._subAndOrs[i].id === elementId) { + self._subAndOrs.splice(i, 1); + self.Update(); + self.UpdateValue(); + return; + } + } + }); + } + private loadFromValue(value: AndOrTagConfigJson) { + this._isAnd.setData(value.and !== undefined); + const tags = value.and ?? value.or; + const rawTags: string[] = []; + const subTags: AndOrTagConfigJson[] = []; + for (const tag of tags) { + + if (typeof (tag) === "string") { + rawTags.push(tag); + } else { + subTags.push(tag); + } + } + + for (let i = 0; i < rawTags.length; i++) { + if (this._rawTags.GetValue().data[i] !== rawTags[i]) { + // For some reason, 'setData' isn't stable as the comparison between the lists fails + // Probably because we generate a new list object every timee + // So we compare again here and update only if we find a difference + this._rawTags.GetValue().setData(rawTags); + break; + } + } + + while(this._subAndOrs.length < subTags.length){ + this.createNewBlock(); + } + + for (let i = 0; i < subTags.length; i++){ + let subTag = subTags[i]; + this._subAndOrs[i].GetValue().setData(subTag); + + } - IsValid(t: string[]): boolean { - return false; } - GetValue(): UIEventSource { + private UpdateValue() { + const tags: (string | AndOrTagConfigJson)[] = []; + tags.push(...this._rawTags.GetValue().data); + + for (const subAndOr of this._subAndOrs) { + const subAndOrData = subAndOr._value.data; + if (subAndOrData === undefined) { + continue; + } + console.log(subAndOrData); + tags.push(subAndOrData); + } + + const tagConfig = new AndOrConfig(); + + if (this._isAnd.data) { + tagConfig.and = tags; + } else { + tagConfig.or = tags; + } + this._value.setData(tagConfig); + } + + GetValue(): UIEventSource { return this._value; } + InnerRender(): string { + const leftColumn = new Combine([ + this._isAndButton, + "
    ", + this.bottomLeftButton ?? "" + ]); + const tags = new Combine([ + this._rawTags, + ...this._subAndOrs, + this._addBlock + ]).Render(); + return `
    ${leftColumn.Render()}${tags}
    `; + } + + + IsValid(t: AndOrTagConfigJson): boolean { + return true; + } + + } \ No newline at end of file diff --git a/UI/Input/MultiInput.ts b/UI/Input/MultiInput.ts new file mode 100644 index 0000000..ef3473d --- /dev/null +++ b/UI/Input/MultiInput.ts @@ -0,0 +1,89 @@ +import {InputElement} from "./InputElement"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {UIElement} from "../UIElement"; +import Combine from "../Base/Combine"; +import {SubtleButton} from "../Base/SubtleButton"; +import {FixedUiElement} from "../Base/FixedUiElement"; + +export class MultiInput extends InputElement { + + private readonly _value: UIEventSource; + IsSelected: UIEventSource; + private elements: UIElement[] = []; + private inputELements: InputElement[] = []; + private addTag: UIElement; + + constructor( + addAElement: string, + newElement: (() => T), + createInput: (() => InputElement), + value: UIEventSource = new UIEventSource([])) { + super(undefined); + this._value = value; + + this.addTag = new SubtleButton("./assets/addSmall.svg", addAElement) + .SetClass("small-button") + .onClick(() => { + this.IsSelected.setData(true); + value.data.push(newElement()); + value.ping(); + }); + const self = this; + value.map((tags: string[]) => tags.length).addCallback(() => self.createElements(createInput)); + this.createElements(createInput); + + this._value.addCallback(tags => self.load(tags)); + this.IsSelected = new UIEventSource(false); + } + + private load(tags: T[]) { + if (tags === undefined) { + return; + } + for (let i = 0; i < tags.length; i++) { + this.inputELements[i].GetValue().setData(tags[i]); + } + } + + private UpdateIsSelected(){ + this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b)) + } + + private createElements(createInput: (() => InputElement)) { + this.inputELements.splice(0, this.inputELements.length); + this.elements = []; + const self = this; + for (let i = 0; i < this._value.data.length; i++) { + let tag = this._value.data[i]; + const input = createInput(); + input.GetValue().addCallback(tag => { + self._value.data[i] = tag; + self._value.ping(); + } + ); + this.inputELements.push(input); + input.IsSelected.addCallback(() => this.UpdateIsSelected()); + const deleteBtn = new FixedUiElement("") + .onClick(() => { + self._value.data.splice(i, 1); + self._value.ping(); + }); + this.elements.push(new Combine([input, deleteBtn, "
    "]).SetClass("tag-input-row")) + } + + this.Update(); + } + + InnerRender(): string { + return new Combine([...this.elements, this.addTag]).Render(); + } + + IsValid(t: T[]): boolean { + return false; + } + + GetValue(): UIEventSource { + return this._value; + } + +} \ No newline at end of file diff --git a/UI/Input/MultiTagInput.ts b/UI/Input/MultiTagInput.ts index af9095c..659534b 100644 --- a/UI/Input/MultiTagInput.ts +++ b/UI/Input/MultiTagInput.ts @@ -5,88 +5,17 @@ import Combine from "../Base/Combine"; import {SubtleButton} from "../Base/SubtleButton"; import TagInput from "./TagInput"; import {FixedUiElement} from "../Base/FixedUiElement"; +import {MultiInput} from "./MultiInput"; -export class MultiTagInput extends InputElement { - - public static tagExplanation: UIElement = - new FixedUiElement("

    How to use the tag-element

    ") - - private readonly _value: UIEventSource; - IsSelected: UIEventSource; - private elements: UIElement[] = []; - private inputELements: InputElement[] = []; - private addTag: UIElement; +export class MultiTagInput extends MultiInput { + constructor(value: UIEventSource = new UIEventSource([])) { - super(undefined); - this._value = value; - - this.addTag = new SubtleButton("./assets/addSmall.svg", "Add a tag") - .SetClass("small-button") - .onClick(() => { - this.IsSelected.setData(true); - value.data.push(""); - value.ping(); - }); - const self = this; - value.map((tags: string[]) => tags.length).addCallback(() => self.createElements()); - this.createElements(); - - - this._value.addCallback(tags => self.load(tags)); - this.IsSelected = new UIEventSource(false); - } - - private load(tags: string[]) { - if (tags === undefined) { - return; - } - for (let i = 0; i < tags.length; i++) { - console.log("Setting tag ", i) - this.inputELements[i].GetValue().setData(tags[i]); - } - } - - private UpdateIsSelected(){ - this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b)) - } - - private createElements() { - this.inputELements = []; - this.elements = []; - for (let i = 0; i < this._value.data.length; i++) { - let tag = this._value.data[i]; - const input = new TagInput(new UIEventSource(tag)); - input.GetValue().addCallback(tag => { - console.log("Writing ", tag) - this._value.data[i] = tag; - this._value.ping(); - } - ); - this.inputELements.push(input); - input.IsSelected.addCallback(() => this.UpdateIsSelected()); - const deleteBtn = new FixedUiElement("") - .onClick(() => { - this._value.data.splice(i, 1); - this._value.ping(); - }); - this.elements.push(new Combine([input, deleteBtn, "
    "]).SetClass("tag-input-row")) - } - - this.Update(); - } - - InnerRender(): string { - return new Combine([...this.elements, this.addTag]).SetClass("bordered").Render(); - } - - - IsValid(t: string[]): boolean { - return false; - } - - GetValue(): UIEventSource { - return this._value; + super("Add a new tag", + () => "", + () => new TagInput(), + value + ); } } \ No newline at end of file diff --git a/UI/Input/RadioButton.ts b/UI/Input/RadioButton.ts index 1338af5..5d23dc3 100644 --- a/UI/Input/RadioButton.ts +++ b/UI/Input/RadioButton.ts @@ -2,6 +2,7 @@ import {InputElement} from "./InputElement"; import {UIEventSource} from "../../Logic/UIEventSource"; export class RadioButton extends InputElement { + IsSelected: UIEventSource = new UIEventSource(false); private readonly _selectedElementIndex: UIEventSource = new UIEventSource(null); @@ -26,16 +27,16 @@ export class RadioButton extends InputElement { return elements[selectedIndex].GetValue() } } - ), elements.map(e => e.GetValue())); + ), elements.map(e => e?.GetValue())); this.value.addCallback((t) => { - self.ShowValue(t); + self?.ShowValue(t); }) for (let i = 0; i < elements.length; i++) { // If an element is clicked, the radio button corresponding with it should be selected as well - elements[i].onClick(() => { + elements[i]?.onClick(() => { self._selectedElementIndex.setData(i); }); } diff --git a/UI/Input/TagInput.ts b/UI/Input/TagInput.ts index f70b97e..8e1a127 100644 --- a/UI/Input/TagInput.ts +++ b/UI/Input/TagInput.ts @@ -18,16 +18,7 @@ export default class SingleTagInput extends InputElement { super(undefined); this._value = value ?? new UIEventSource(undefined); - this.key = new TextField({ - placeholder: "key", - fromString: str => { - if (str?.match(/^[a-zA-Z][a-zA-Z0-9:]*$/)) { - return str; - } - return undefined - }, - toString: str => str - }); + this.key = TextField.KeyInput(); this.value = new TextField({ placeholder: "value - if blank, matches if key is NOT present", @@ -95,7 +86,8 @@ export default class SingleTagInput extends InputElement { InnerRender(): string { return new Combine([ this.key, this.operator, this.value - ]).Render(); + ]).SetStyle("display:flex") + .Render(); } diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index 6cc6eec..63a9383 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -4,8 +4,33 @@ import Translations from "../i18n/Translations"; import {UIEventSource} from "../../Logic/UIEventSource"; import * as EmailValidator from "email-validator"; import {parsePhoneNumberFromString} from "libphonenumber-js"; +import {DropDown} from "./DropDown"; export class ValidatedTextField { + + public static explanations = { + "string": "A basic, 255-char string", + "date": "A date", + "wikidata": "A wikidata identifier, e.g. Q42", + "int": "A number", + "nat": "A positive number", + "float": "A decimal", + "pfloat": "A positive decimal", + "email": "An email adress", + "url": "A url", + "phone": "A phone number" + } + + public static TypeDropdown() : DropDown{ + const values : {value: string, shown: string}[] = []; + const expl = ValidatedTextField.explanations; + for(const key in expl){ + values.push({value: key, shown: `${key} - ${expl[key]}`}) + } + return new DropDown("", values) + } + + public static inputValidation = { "$": () => true, "string": () => true, @@ -40,6 +65,19 @@ export class TextField extends InputElement { }); } + public static KeyInput(): TextField{ + return new TextField({ + placeholder: "key", + fromString: str => { + if (str?.match(/^[a-zA-Z][a-zA-Z0-9:_-]*$/)) { + return str; + } + return undefined + }, + toString: str => str + }); + } + public static NumberInput(type: string = "int", extraValidation: (number: Number) => boolean = undefined) : TextField{ const isValid = ValidatedTextField.inputValidation[type]; extraValidation = extraValidation ?? (() => true) diff --git a/UI/ShareScreen.ts b/UI/ShareScreen.ts index f78b731..a591b71 100644 --- a/UI/ShareScreen.ts +++ b/UI/ShareScreen.ts @@ -162,6 +162,7 @@ export class ShareScreen extends UIElement { this._iframeCode = new VariableUiElement( url.map((url) => { return ` + <iframe src="${url}" width="100%" height="100%" title="${layout.title.InnerRender()} with MapComplete"></iframe> ` }) ); diff --git a/UI/SimpleAddUI.ts b/UI/SimpleAddUI.ts index 3d7cad1..bb54bbf 100644 --- a/UI/SimpleAddUI.ts +++ b/UI/SimpleAddUI.ts @@ -53,7 +53,7 @@ export class SimpleAddUI extends UIElement { if (typeof (preset.icon) !== "string") { const tags = Utils.MergeTags(TagUtils.KVtoProperties(preset.tags), {id:"node/-1"}); - icon = preset.icon.GetContent(tags); + icon = preset.icon.GetContent(tags).txt; } else { icon = preset.icon; } @@ -193,7 +193,7 @@ export class SimpleAddUI extends UIElement { return new Combine([header, Translations.t.general.add.stillLoading]).Render() } - return header.Render() + new Combine(this._addButtons, "add-popup-all-buttons").Render(); + return header.Render() + new Combine(this._addButtons).SetClass("add-popup-all-buttons").Render(); } diff --git a/UI/UIElement.ts b/UI/UIElement.ts index 88037cb..dea2f53 100644 --- a/UI/UIElement.ts +++ b/UI/UIElement.ts @@ -1,15 +1,19 @@ import {UIEventSource} from "../Logic/UIEventSource"; -export abstract class UIElement extends UIEventSource{ - +export abstract class UIElement extends UIEventSource { + private static nextId: number = 0; public readonly id: string; public readonly _source: UIEventSource; private clss: string[] = [] - + + private style: string; + private _hideIfEmpty = false; - + + public dumbMode = false; + /** * In the 'deploy'-step, some code needs to be run by ts-node. * However, ts-node crashes when it sees 'document'. When running from console, we flag this and disable all code where document is needed. @@ -30,6 +34,7 @@ export abstract class UIElement extends UIEventSource{ if (source === undefined) { return this; } + this.dumbMode = false; const self = this; source.addCallback(() => { self.Update(); @@ -40,24 +45,56 @@ export abstract class UIElement extends UIEventSource{ private _onClick: () => void; public onClick(f: (() => void)) { + this.dumbMode = false; this._onClick = f; this.SetClass("clickable") this.Update(); return this; } - + + private _onHover: UIEventSource; + + public IsHovered(): UIEventSource { + this.dumbMode = false; + if (this._onHover !== undefined) { + return this._onHover; + } + // Note: we just save it. 'Update' will register that an eventsource exist and install the necessary hooks + this._onHover = new UIEventSource(false); + return this._onHover; + } + Update(): void { - if(UIElement.runningFromConsole){ + if (UIElement.runningFromConsole) { return; } - + let element = document.getElementById(this.id); if (element === undefined || element === null) { // The element is not painted + + if (this.dumbMode) { + // We update all the children anyway + for (const i in this) { + const child = this[i]; + if (child instanceof UIElement) { + child.Update(); + } else if (child instanceof Array) { + for (const ch of child) { + if (ch instanceof UIElement) { + ch.Update(); + } + } + } + } + } + + return; } this.setData(this.InnerRender()); element.innerHTML = this.data; + if (this._hideIfEmpty) { if (element.innerHTML === "") { element.parentElement.style.display = "none"; @@ -70,7 +107,7 @@ export abstract class UIElement extends UIEventSource{ const self = this; element.onclick = (e) => { // @ts-ignore - if(e.consumed){ + if (e.consumed) { return; } self._onClick(); @@ -81,6 +118,12 @@ export abstract class UIElement extends UIEventSource{ element.style.cursor = "pointer"; } + if (this._onHover !== undefined) { + const self = this; + element.addEventListener('mouseover', () => self._onHover.setData(true)); + element.addEventListener('mouseout', () => self._onHover.setData(false)); + } + this.InnerUpdate(element); for (const i in this) { @@ -108,10 +151,18 @@ export abstract class UIElement extends UIEventSource{ } Render(): string { - return `${this.InnerRender()}` + if (this.dumbMode) { + return this.InnerRender(); + } + let style = ""; + if (this.style !== undefined && this.style !== "") { + style = `style="${this.style}"`; + } + return `${this.InnerRender()}` } AttachTo(divId: string) { + this.dumbMode = false; let element = document.getElementById(divId); if (element === null) { throw "SEVERE: could not attach UIElement to " + divId; @@ -143,6 +194,7 @@ export abstract class UIElement extends UIEventSource{ } public SetClass(clss: string): UIElement { + this.dumbMode = false; if (this.clss.indexOf(clss) < 0) { this.clss.push(clss); } @@ -150,14 +202,13 @@ export abstract class UIElement extends UIEventSource{ return this; } - public RemoveClass(clss: string): UIElement { - if (this.clss.indexOf(clss) >= 0) { - this.clss = this.clss.splice(this.clss.indexOf(clss), 1); - } + + public SetStyle(style: string): UIElement { + this.dumbMode = false; + this.style = style; this.Update(); return this; } - } diff --git a/UI/i18n/Translation.ts b/UI/i18n/Translation.ts index 15e2b65..1e0f610 100644 --- a/UI/i18n/Translation.ts +++ b/UI/i18n/Translation.ts @@ -52,8 +52,8 @@ export default class Translation extends UIElement { for (const i in this.translations) { return this.translations[i]; // Return a random language } - console.log("Missing language ",Locale.language.data,"for",this.translations) - return "Missing translation" + console.error("Missing language ",Locale.language.data,"for",this.translations) + return undefined; } InnerRender(): string { diff --git a/assets/ampersand.svg b/assets/ampersand.svg new file mode 100644 index 0000000..525a1ef --- /dev/null +++ b/assets/ampersand.svg @@ -0,0 +1,53 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/assets/or.svg b/assets/or.svg new file mode 100644 index 0000000..12e9510 --- /dev/null +++ b/assets/or.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/assets/themes/aed/aed.json b/assets/themes/aed/aed.json index b170cb2..91f3040 100644 --- a/assets/themes/aed/aed.json +++ b/assets/themes/aed/aed.json @@ -93,7 +93,7 @@ "condition": "indoor=yes", "freeform": { "key": "access", - "addExtraTags": "fixme=Freeform field used for access - doublecheck the value" + "addExtraTags": ["fixme=Freeform field used for access - doublecheck the value"] }, "mappings": [ { diff --git a/assets/themes/artwork/artwork.json b/assets/themes/artwork/artwork.json index d6eb1a1..18daa4f 100644 --- a/assets/themes/artwork/artwork.json +++ b/assets/themes/artwork/artwork.json @@ -76,7 +76,7 @@ }, "freeform": { "key": "artwork_type", - "addExtraTags": "fixme=Artowrk type was added with the freeform, might need another check" + "addExtraTags": ["fixme=Artowrk type was added with the freeform, might need another check"] }, "mappings": [ { diff --git a/assets/themes/toilets/toilets.json b/assets/themes/toilets/toilets.json index bea6bed..066de4b 100644 --- a/assets/themes/toilets/toilets.json +++ b/assets/themes/toilets/toilets.json @@ -56,7 +56,9 @@ "render": "Access is {access}", "freeform": { "key": "access", - "addExtraTags": "fixme=the tag access was filled out by the user and might need refinement" + "addExtraTags": [ + "fixme=the tag access was filled out by the user and might need refinement" + ] }, "mappings": [ { diff --git a/customGenerator.html b/customGenerator.html index d7fe549..a47a77a 100644 --- a/customGenerator.html +++ b/customGenerator.html @@ -5,33 +5,7 @@ Custom Theme Generator for Mapcomplete -
    - 'left' not attached +
    + 'maindiv' not attached
    - -
    'bottomright' not attached
    \ No newline at end of file diff --git a/customGenerator.ts b/customGenerator.ts index a6283ee..dcc24d0 100644 --- a/customGenerator.ts +++ b/customGenerator.ts @@ -1,68 +1,51 @@ -import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson"; import {UIEventSource} from "./Logic/UIEventSource"; import SingleSetting from "./UI/CustomGenerator/SingleSetting"; import Combine from "./UI/Base/Combine"; import {VariableUiElement} from "./UI/Base/VariableUIElement"; import GeneralSettings from "./UI/CustomGenerator/GeneralSettings"; -import {SubtleButton} from "./UI/Base/SubtleButton"; import {TabbedComponent} from "./UI/Base/TabbedComponent"; import AllLayersPanel from "./UI/CustomGenerator/AllLayersPanel"; -import {ShareScreen} from "./UI/ShareScreen"; -import {FromJSON} from "./Customizations/JSON/FromJSON"; import SharePanel from "./UI/CustomGenerator/SharePanel"; +import {GenerateEmpty} from "./UI/CustomGenerator/GenerateEmpty"; +import PageSplit from "./UI/Base/PageSplit"; +import HelpText from "./Customizations/HelpText"; +import {TagRendering} from "./Customizations/TagRendering"; -const empty: LayoutConfigJson = { - id: "", - title: {}, - description: {}, - language: [], - maintainer: "", - icon: "./assets/bug.svg", - version: "0", - startLat: 0, - startLon: 0, - startZoom: 1, - socialImage: "", - layers: [], -} - -const test: LayoutConfigJson = { - id: "test", - title: {"en": "Test layout"}, - description: {"en": "A layout for testing"}, - language: ["en"], - maintainer: "Pieter Vander Vennet", - icon: "./assets/bug.svg", - version: "0", - startLat: 0, - startLon: 0, - startZoom: 1, - widenFactor: 0.05, - socialImage: "", - layers: [], -} - - -const es = new UIEventSource(test); +const es = new UIEventSource(GenerateEmpty.createTestLayout()); const encoded = es.map(config => btoa(JSON.stringify(config))); -const testUrl = encoded.map(encoded => `./index.html?userlayout=${es.data.id}&test=true#${encoded}`) const liveUrl = encoded.map(encoded => `./index.html?userlayout=${es.data.id}#${encoded}`) const iframe = liveUrl.map(url => ``); +TagRendering.injectFunction(); const currentSetting = new UIEventSource>(undefined) const generalSettings = new GeneralSettings(es, currentSetting); const languages = generalSettings.languages; + + +// The preview +const preview = new Combine([ + new VariableUiElement(iframe.stabilized(2500)) +]).SetClass("preview") + + new TabbedComponent([ { header: "", - content: generalSettings + content: + new PageSplit( + generalSettings.SetStyle("width: 50vw;"), + new Combine([ + new HelpText(currentSetting).SetStyle("height:calc(100% - 65vh); width: 100%; display:block; overflow-y: auto"), + preview.SetStyle("height:65vh; width:100%; display:block") + ]).SetStyle("position:relative; width: 50%;") + ) }, { header: "", - content: new AllLayersPanel(es, currentSetting, languages) + content: new AllLayersPanel(es, languages) }, { header: "", @@ -77,44 +60,6 @@ new TabbedComponent([ header: "", content: new SharePanel(es, liveUrl) } -]).AttachTo("left"); - - -const returnButton = new SubtleButton("./assets/close.svg", - new VariableUiElement( - currentSetting.map(currentSetting => { - if (currentSetting === undefined) { - return ""; - } - return "Return to general help"; - } - ) - )) - .ListenTo(currentSetting) - .onClick(() => currentSetting.setData(undefined)); - - -const helpText = new VariableUiElement(currentSetting.map((setting: SingleSetting) => { - if (setting === undefined) { - return "

    Welcome to the Custom Theme Builder

    " + - "Here, one can make their own custom mapcomplete themes.
    " + - "Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it"; - } - - return new Combine(["

    ", setting._name, "

    ", setting._description.Render()]).Render(); -})) - - -new Combine([helpText, - returnButton, -]).AttachTo("right"); - -// The preview -new Combine([ - new VariableUiElement(iframe) -]).AttachTo("bottomright"); - - - - +], 1).SetClass("main-tabs") + .AttachTo("maindiv"); diff --git a/index.css b/index.css index 855f671..57ebc2c 100644 --- a/index.css +++ b/index.css @@ -109,16 +109,21 @@ padding-bottom: 0.15em; } -.clickable { - pointer-events: all; -} + .clickable { + pointer-events: all; + } + + .page-split { + display: flex; + height: 100%; + } -.activate-osm-authentication { - cursor: pointer; - color: blue; - text-decoration: underline; -} + .activate-osm-authentication { + cursor: pointer; + color: blue; + text-decoration: underline; + } /**************** USER BADGE ****************/ @@ -1224,11 +1229,10 @@ .tab-content { - padding: 1em; z-index: 5002; background-color: white; position: relative; - + padding: 1em; } .tab-single-header { diff --git a/test.html b/test.html index b5f19fb..3be38f5 100644 --- a/test.html +++ b/test.html @@ -1,8 +1,24 @@ + Small tests - + +
    'maindiv' not attached
    diff --git a/test.ts b/test.ts index e82bca7..79e52ed 100644 --- a/test.ts +++ b/test.ts @@ -1,9 +1,19 @@ -import TagInput from "./UI/Input/TagInput"; +import TagRenderingPanel from "./UI/CustomGenerator/TagRenderingPanel"; import {UIEventSource} from "./Logic/UIEventSource"; +import {TextField} from "./UI/Input/TextField"; import {VariableUiElement} from "./UI/Base/VariableUIElement"; -import {MultiTagInput} from "./UI/Input/MultiTagInput"; +import SettingsTable from "./UI/CustomGenerator/SettingsTable"; +import SingleSetting from "./UI/CustomGenerator/SingleSetting"; +import {MultiInput} from "./UI/Input/MultiInput"; -const input = new MultiTagInput(new UIEventSource(["key~value|0"])); -input.GetValue().addCallback(console.log); -input.AttachTo("maindiv"); -new VariableUiElement(input.GetValue().map(tags => tags.join(" & "))).AttachTo("extradiv") \ No newline at end of file + +const config = new UIEventSource({}) +const languages = new UIEventSource(["en","nl"]); +new MultiInput( + () => "Add a tag rendering", + () => new TagRenderingPanel( + + ) + + +) \ No newline at end of file