Butchering the UI framework

This commit is contained in:
pietervdvn 2021-06-10 01:36:20 +02:00
parent 8d404b1ba9
commit 6415e195d1
90 changed files with 1012 additions and 3101 deletions

View file

@ -12,12 +12,12 @@ import Combine from "../../UI/Base/Combine";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {FixedUiElement} from "../../UI/Base/FixedUiElement";
import {UIElement} from "../../UI/UIElement";
import {SubstitutedTranslation} from "../../UI/SubstitutedTranslation";
import SourceConfig from "./SourceConfig";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {Tag} from "../../Logic/Tags/Tag";
import SubstitutingTag from "../../Logic/Tags/SubstitutingTag";
import BaseUIElement from "../../UI/BaseUIElement";
export default class LayerConfig {
@ -294,7 +294,7 @@ export default class LayerConfig {
{
icon:
{
html: UIElement,
html: BaseUIElement,
iconSize: [number, number],
iconAnchor: [number, number],
popupAnchor: [number, number],
@ -361,7 +361,7 @@ export default class LayerConfig {
const iconUrlStatic = render(this.icon);
const self = this;
const mappedHtml = tags.map(tgs => {
function genHtmlFromString(sourcePart: string): UIElement {
function genHtmlFromString(sourcePart: string): BaseUIElement {
if (sourcePart.indexOf("html:") == 0) {
// We use § as a replacement for ;
const html = sourcePart.substring("html:".length)
@ -370,7 +370,7 @@ export default class LayerConfig {
}
const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`;
let html: UIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`);
let html: BaseUIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`);
const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/)
if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) {
html = new Combine([
@ -387,7 +387,7 @@ export default class LayerConfig {
const iconUrl = render(self.icon);
const rotation = render(self.rotation, "0deg");
let htmlParts: UIElement[] = [];
let htmlParts: BaseUIElement[] = [];
let sourceParts = Utils.NoNull(iconUrl.split(";").filter(prt => prt != ""));
for (const sourcePart of sourceParts) {
htmlParts.push(genHtmlFromString(sourcePart))
@ -399,7 +399,7 @@ export default class LayerConfig {
continue;
}
if (iconOverlay.badge) {
const badgeParts: UIElement[] = [];
const badgeParts: BaseUIElement[] = [];
const partDefs = iconOverlay.then.GetRenderValue(tgs).txt.split(";").filter(prt => prt != "");
for (const badgePartStr of partDefs) {
@ -437,7 +437,7 @@ export default class LayerConfig {
} catch (e) {
console.error(e, tgs)
}
return new Combine(htmlParts).Render();
return new Combine(htmlParts);
})

View file

@ -1,5 +1,5 @@
import {FixedUiElement} from "./UI/Base/FixedUiElement";
import CheckBox from "./UI/Input/CheckBox";
import Toggle from "./UI/Input/Toggle";
import {Basemap} from "./UI/BigComponents/Basemap";
import State from "./State";
import LoadFromOverpass from "./Logic/Actors/OverpassFeatureSource";
@ -272,7 +272,7 @@ export class InitUiElements {
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg());
new CheckBox(
new Toggle(
fullOptions
.SetClass("welcomeMessage")
.onClick(() => {/*Catch the click*/
@ -307,7 +307,7 @@ export class InitUiElements {
)
;
const copyrightButton = new CheckBox(
const copyrightButton = new Toggle(
copyrightNotice,
new MapControlButton(Svg.osm_copyright_svg()),
copyrightNotice.isShown
@ -316,13 +316,13 @@ export class InitUiElements {
const layerControlPanel = new LayerControlPanel(
State.state.layerControlIsOpened)
.SetClass("block p-1 rounded-full");
const layerControlButton = new CheckBox(
const layerControlButton = new Toggle(
layerControlPanel,
new MapControlButton(Svg.layers_svg()),
State.state.layerControlIsOpened
)
const layerControl = new CheckBox(
const layerControl = new Toggle(
layerControlButton,
"",
State.state.featureSwitchLayers

View file

@ -183,7 +183,6 @@ export default class GeoLocationHandler extends UIElement {
self.StartGeolocating(false);
}
this.HideOnEmpty(true);
}
private locate() {

View file

@ -21,7 +21,6 @@ class TitleElement extends UIElement {
this._allElementsStorage = allElementsStorage;
this.ListenTo(Locale.language);
this.ListenTo(this._selectedFeature)
this.dumbMode = false;
}
InnerRender(): string {
@ -63,7 +62,7 @@ export default class TitleHandler {
selectedFeature.addCallbackAndRun(_ => {
const title = new TitleElement(layoutToUse, selectedFeature, allElementsStorage)
const d = document.createElement('div');
d.innerHTML = title.InnerRender();
d.innerHTML = title.InnerRenderAsString();
// We pass everything into a div to strip out images etc...
document.title = (d.textContent || d.innerText);
})

View file

@ -1,11 +1,11 @@
import {UIElement} from "../UIElement";
import {FixedUiElement} from "./FixedUiElement";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class Combine extends UIElement {
private readonly uiElements: UIElement[];
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[];
constructor(uiElements: (string | UIElement)[]) {
constructor(uiElements: (string | BaseUIElement)[]) {
super();
this.uiElements = Utils.NoNull(uiElements)
.map(el => {
@ -15,18 +15,21 @@ export default class Combine extends UIElement {
return el;
});
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
InnerRender(): string {
return this.uiElements.map(ui => {
if(ui === undefined || ui === null){
return "";
for (const subEl of this.uiElements) {
if(subEl === undefined || subEl === null){
continue;
}
if(ui.Render === undefined){
console.error("Not a UI-element", ui);
return "";
const subHtml = subEl.ConstructElement()
if(subHtml !== undefined){
el.appendChild(subHtml)
}
return ui.Render();
}).join("");
}
return el;
}
}

View file

@ -12,11 +12,11 @@ export default class FeatureSwitched extends UIElement{
this._swtch = swtch;
}
InnerRender(): string {
InnerRender(): UIElement | string {
if(this._swtch.data){
return this._upstream.Render();
}
return "";
return undefined;
}
}

View file

@ -7,7 +7,7 @@ export class FixedUiElement extends UIElement {
super(undefined);
this._html = html ?? "";
}
InnerRender(): string {
return this._html;
}

View file

@ -1,19 +1,29 @@
import Constants from "../../Models/Constants";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class Img {
export default class Img extends BaseUIElement {
private _src: string;
public static runningFromConsole = false;
constructor(src: string) {
super();
this._src = src;
}
static AsData(source:string){
if(Utils.runningFromConsole){
return source;
}
return `data:image/svg+xml;base64,${(btoa(source))}`;
}
static AsData(source: string) {
if (Utils.runningFromConsole) {
return source;
}
return `data:image/svg+xml;base64,${(btoa(source))}`;
}
static AsImageElement(source: string, css_class: string = "", style=""): string{
static AsImageElement(source: string, css_class: string = "", style = ""): string {
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`;
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("img")
el.src = this._src;
return el;
}
}

View file

@ -1,24 +1,35 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class Link extends UIElement {
private readonly _embeddedShow: UIElement;
private readonly _target: string;
private readonly _newTab: string;
export default class Link extends BaseUIElement {
private readonly _element: HTMLElement;
constructor(embeddedShow: UIElement | string, target: string, newTab: boolean = false) {
constructor(embeddedShow: BaseUIElement | string, target: string | UIEventSource<string>, newTab: boolean = false) {
super();
this._embeddedShow = Translations.W(embeddedShow);
this._target = target;
this._newTab = "";
if (newTab) {
this._newTab = "target='_blank'"
const _embeddedShow = Translations.W(embeddedShow);
const el = document.createElement("a")
if(typeof target === "string"){
el.href = target
}else{
target.addCallbackAndRun(target => {
el.target = target;
})
}
if (newTab) {
el.target = "_blank"
}
el.appendChild(_embeddedShow.ConstructElement())
this._element = el
}
InnerRender(): string {
return `<a href="${this._target}" ${this._newTab}>${this._embeddedShow.Render()}</a>`;
protected InnerConstructElement(): HTMLElement {
return this._element;
}
}

View file

@ -7,7 +7,13 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Hash from "../../Logic/Web/Hash";
/**
* Wraps some contents into a panel that scrolls the content _under_ the title
*
* The scrollableFullScreen is a bit of a peculiar component:
* - It shows a title and some contents, constructed from the respective functions passed into the constructor
* - When the element is 'activated', one clone of title+contents is attached to the fullscreen
* - The element itself will - upon rendering - also show the title and contents (allthough it'll be a different clone)
*
*
*/
export default class ScrollableFullScreen extends UIElement {
private static readonly empty = new FixedUiElement("");
@ -40,8 +46,8 @@ export default class ScrollableFullScreen extends UIElement {
})
}
InnerRender(): string {
return this._component.Render();
InnerRender(): UIElement {
return this._component;
}
Activate(): void {

View file

@ -1,55 +1,51 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import Combine from "./Combine";
import {FixedUiElement} from "./FixedUiElement";
import BaseUIElement from "../BaseUIElement";
import Link from "./Link";
import Img from "./Img";
import {UIEventSource} from "../../Logic/UIEventSource";
export class SubtleButton extends Combine {
constructor(imageUrl: string | UIElement, message: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined) {
constructor(imageUrl: string | BaseUIElement, message: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined) {
super(SubtleButton.generateContent(imageUrl, message, linkTo));
this.SetClass("block flex p-3 my-2 bg-blue-100 rounded-lg hover:shadow-xl hover:bg-blue-200 link-no-underline")
}
private static generateContent(imageUrl: string | UIElement, messageT: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined): (UIElement | string)[] {
private static generateContent(imageUrl: string | BaseUIElement, messageT: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined): (BaseUIElement )[] {
const message = Translations.W(messageT);
if (message !== null) {
message.dumbMode = false;
}
let img;
if ((imageUrl ?? "") === "") {
img = new FixedUiElement("");
img = undefined;
} else if (typeof (imageUrl) === "string") {
img = new FixedUiElement(`<img style="width: 100%;" src="${imageUrl}" alt="">`);
img = new Img(imageUrl).SetClass("w-full")
} else {
img = imageUrl;
}
img.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0")
img?.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0")
const image = new Combine([img])
.SetClass("flex-shrink-0");
if (message !== null && message.IsEmpty()) {
// Message == null: special case to force empty text
return [];
}
if (linkTo != undefined) {
if (linkTo == undefined) {
return [
`<a class='flex group' href="${linkTo.url}" ${linkTo.newTab ? 'target="_blank"' : ""}>`,
image,
`<div class='ml-4 overflow-ellipsis'>`,
message,
`</div>`,
`</a>`
];
}
return [
image,
message,
new Link(
new Combine([
image,
message?.SetClass("block ml-4 overflow-ellipsis")
]).SetClass("flex group"),
linkTo.url,
linkTo.newTab ?? false
)
];
}

View file

@ -1,39 +1,41 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "./Combine";
export class TabbedComponent extends UIElement {
private headers: UIElement[] = [];
private readonly header: UIElement;
private content: UIElement[] = [];
constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) {
super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0)));
const self = this;
const tabs: UIElement[] = []
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
this.headers.push(Translations.W(element.header).onClick(() => self._source.setData(i)));
const header = Translations.W(element.header).onClick(() => self._source.setData(i))
const content = Translations.W(element.content)
this.content.push(content);
}
}
InnerRender(): string {
let headerBar = "";
for (let i = 0; i < this.headers.length; i++) {
let header = this.headers[i];
if (!this.content[i].IsEmpty()) {
headerBar += `<div class=\'tab-single-header ${i == this._source.data ? 'tab-active' : 'tab-non-active'}\'>` +
header.Render() + "</div>"
const tab = header.SetClass("block tab-single-header")
tabs.push(tab)
}
}
this.header = new Combine(tabs).SetClass("block tabs-header-bar")
headerBar = "<div class='tabs-header-bar'>" + headerBar + "</div>"
}
InnerRender(): UIElement {
const content = this.content[this._source.data];
return headerBar + "<div class='tab-content'>" + (content?.Render() ?? "") + "</div>";
return new Combine([
this.header,
content.SetClass("tab-content"),
])
}
}

View file

@ -1,16 +1,35 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export class VariableUiElement extends UIElement {
private _html: UIEventSource<string>;
export class VariableUiElement extends BaseUIElement {
constructor(html: UIEventSource<string>) {
super(html);
this._html = html;
private _element : HTMLElement;
constructor(contents: UIEventSource<string | BaseUIElement>) {
super();
this._element = document.createElement("span")
const el = this._element
contents.addCallbackAndRun(contents => {
while(el.firstChild){
el.removeChild(
el.lastChild
)
}
if(contents === undefined){
return
}
if(typeof contents === "string"){
el.innerHTML = contents
}else{
el.appendChild(contents.ConstructElement())
}
})
}
InnerRender(): string {
return this._html.data;
protected InnerConstructElement(): HTMLElement {
return this._element;
}
}

View file

@ -1,20 +0,0 @@
import {UIElement} from "../UIElement";
export class VerticalCombine extends UIElement {
private readonly _elements: UIElement[];
constructor(elements: UIElement[]) {
super(undefined);
this._elements = elements;
}
InnerRender(): string {
let html = "";
for (const element of this._elements) {
if (element !== undefined && !element.IsEmpty()) {
html += "<div>" + element.Render() + "</div>";
}
}
return html;
}
}

154
UI/BaseUIElement.ts Normal file
View file

@ -0,0 +1,154 @@
import {Utils} from "../Utils";
import {UIEventSource} from "../Logic/UIEventSource";
/**
* A thin wrapper around a html element, which allows to generate a HTML-element.
*
* Assumes a read-only configuration, so it has no 'ListenTo'
*/
export default abstract class BaseUIElement {
private clss: Set<string> = new Set<string>();
private style: string;
private _onClick: () => void;
private _onHover: UIEventSource<boolean>;
protected _constructedHtmlElement: HTMLElement;
protected abstract InnerConstructElement(): HTMLElement;
public onClick(f: (() => void)) {
this._onClick = f;
this.SetClass("clickable")
if(this._constructedHtmlElement !== undefined){
this._constructedHtmlElement.onclick = f;
}
return this;
}
public IsHovered(): UIEventSource<boolean> {
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<boolean>(false);
return this._onHover;
}
AttachTo(divId: string) {
let element = document.getElementById(divId);
if (element === null) {
throw "SEVERE: could not attach UIElement to " + divId;
}
while (element.firstChild) {
//The list is LIVE so it will re-index each call
element.removeChild(element.firstChild);
}
const el = this.ConstructElement();
if(el !== undefined){
element.appendChild(el)
}
return this;
}
/**
* Adds all the relevant classes, space seperated
* @param clss
* @constructor
*/
public SetClass(clss: string) {
const all = clss.split(" ").map(clsName => clsName.trim());
let recordedChange = false;
for (const c of all) {
if (this.clss.has(clss)) {
continue;
}
this.clss.add(c);
recordedChange = true;
}
if (recordedChange) {
this._constructedHtmlElement?.classList.add(...Array.from(this.clss));
}
return this;
}
public RemoveClass(clss: string): BaseUIElement {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this._constructedHtmlElement?.classList.remove(clss)
}
return this;
}
public SetStyle(style: string): BaseUIElement {
this.style = style;
if(this._constructedHtmlElement !== undefined){
this._constructedHtmlElement.style.cssText = style;
}
return this;
}
/**
* The same as 'Render', but creates a HTML element instead of the HTML representation
*/
public ConstructElement(): HTMLElement {
if (Utils.runningFromConsole) {
return undefined;
}
if (this._constructedHtmlElement !== undefined) {
return this._constructedHtmlElement
}
const el = this.InnerConstructElement();
if(el === undefined){
return undefined;
}
this._constructedHtmlElement = el;
const style = this.style
if (style !== undefined && style !== "") {
el.style.cssText = style
}
if (this.clss.size > 0) {
try{
el.classList.add(...Array.from(this.clss))
}catch(e){
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "),"\nErr msg is ",e)
}
}
if (this._onClick !== undefined) {
const self = this;
el.onclick = (e) => {
// @ts-ignore
if (e.consumed) {
return;
}
self._onClick();
// @ts-ignore
e.consumed = true;
}
el.style.pointerEvents = "all";
el.style.cursor = "pointer";
}
if (this._onHover !== undefined) {
const self = this;
el.addEventListener('mouseover', () => self._onHover.setData(true));
el.addEventListener('mouseout', () => self._onHover.setData(false));
}
if (this._onHover !== undefined) {
const self = this;
el.addEventListener('mouseover', () => self._onHover.setData(true));
el.addEventListener('mouseout', () => self._onHover.setData(false));
}
return el
}
}

View file

@ -44,16 +44,14 @@ export default class AttributionPanel extends Combine {
const contribs = links.join(", ")
if (hiddenCount == 0) {
return Translations.t.general.attribution.mapContributionsBy.Subs({
contributors: contribs
}).InnerRender()
})
} else {
return Translations.t.general.attribution.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount
}).InnerRender();
});
}

View file

@ -4,10 +4,11 @@ import Translations from "../i18n/Translations";
import State from "../../State";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseLayer from "../../Models/BaseLayer";
import BaseUIElement from "../BaseUIElement";
export default class BackgroundSelector extends UIElement {
private _dropdown: UIElement;
private _dropdown: BaseUIElement;
private readonly _availableLayers: UIEventSource<BaseLayer[]>;
constructor() {
@ -31,8 +32,8 @@ export default class BackgroundSelector extends UIElement {
this._dropdown = new DropDown(Translations.t.general.backgroundMap, baseLayers, State.state.backgroundLayer);
}
InnerRender(): string {
return this._dropdown.Render();
InnerRender(): BaseUIElement {
return this._dropdown;
}
}

View file

@ -80,8 +80,8 @@ export default class FullWelcomePaneWithTabs extends UIElement {
.ListenTo(userDetails);
}
InnerRender(): string {
return this._component.Render();
InnerRender(): UIElement {
return this._component;
}

View file

@ -2,7 +2,7 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import State from "../../State";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
@ -65,7 +65,7 @@ export default class LayerSelection extends UIElement {
}))
const style = "display:flex;align-items:center;"
const styleWhole = "display:flex; flex-wrap: wrap"
this._checkboxes.push(new CheckBox(
this._checkboxes.push(new Toggle(
new Combine([new Combine([icon, name]).SetStyle(style), zoomStatus])
.SetStyle(styleWhole),
new Combine([new Combine([iconUnselected, "<del>", name, "</del>"]).SetStyle(style), zoomStatus])

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
@ -11,87 +10,93 @@ import * as personal from "../../assets/themes/personalLayout/personalLayout.jso
import Constants from "../../Models/Constants";
import LanguagePicker from "../LanguagePicker";
import IndexText from "./IndexText";
import BaseUIElement from "../BaseUIElement";
export default class MoreScreen extends UIElement {
private readonly _onMainScreen: boolean;
private _component: UIElement;
export default class MoreScreen extends Combine {
constructor(onMainScreen: boolean = false) {
super(State.state.locationControl);
this._onMainScreen = onMainScreen;
this.ListenTo(State.state.osmConnection.userDetails);
this.ListenTo(State.state.installedThemes);
super(MoreScreen.Init(onMainScreen, State.state));
}
InnerRender(): string {
private static Init(onMainScreen: boolean, state: State): BaseUIElement [] {
const tr = Translations.t.general.morescreen;
const els: UIElement[] = []
const themeButtons: UIElement[] = []
for (const layout of AllKnownLayouts.layoutsList) {
if (layout.id === personal.id) {
if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) {
continue;
}
}
themeButtons.push(this.createLinkButton(layout));
}
els.push(new VariableUiElement(
State.state.osmConnection.userDetails.map(userDetails => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme, {url:"https://github.com/pietervdvn/MapComplete/issues", newTab: true}).Render();
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme, {
url: "./customGenerator.html",
newTab: false
}).Render();
})
));
els.push(new Combine(themeButtons))
const customThemesNames = State.state.installedThemes.data ?? [];
if (customThemesNames.length > 0) {
els.push(Translations.t.general.customThemeIntro)
for (const installed of State.state.installedThemes.data) {
els.push(this.createLinkButton(installed.layout, installed.definition));
}
}
let intro: UIElement = tr.intro;
const themeButtonsElement = new Combine(els)
if (this._onMainScreen) {
let intro: BaseUIElement = tr.intro;
let themeButtonStyle = ""
let themeListStyle = ""
if (onMainScreen) {
intro = new Combine([
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
.SetClass("absolute top-2 right-3"),
new IndexText()
])
themeButtons.map(e => e?.SetClass("h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"))
themeButtonsElement.SetClass("md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4")
themeButtonStyle = "h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"
themeListStyle = "md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4"
}
this._component = new Combine([
return[
intro,
themeButtonsElement,
MoreScreen.createOfficialThemesList(state, themeButtonStyle).SetClass(themeListStyle),
MoreScreen.createUnofficialThemeList(themeButtonStyle)?.SetClass(themeListStyle),
tr.streetcomplete.SetClass("block text-base mx-10 my-3 mb-10")
]);
return this._component.Render();
];
}
private static createUnofficialThemeList(buttonClass: string): BaseUIElement{
const customThemes = State.state.installedThemes.data ?? [];
const els : BaseUIElement[] = []
if (customThemes.length > 0) {
els.push(Translations.t.general.customThemeIntro)
const customThemesElement = new Combine(
customThemes.map(theme => MoreScreen.createLinkButton(theme.layout, theme.definition)?.SetClass(buttonClass))
)
els.push(customThemesElement)
}
return new Combine(els)
}
private createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined) {
private static createOfficialThemesList(state: State, buttonClass: string): BaseUIElement {
let officialThemes = AllKnownLayouts.layoutsList
if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) {
officialThemes = officialThemes.filter(theme => theme.id !== personal.id)
}
let buttons = officialThemes.map((layout) => MoreScreen.createLinkButton(layout)?.SetClass(buttonClass))
let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
buttons.splice(0, 0, customGeneratorLink);
return new Combine(buttons)
}
/*
* Returns either a link to the issue tracker or a link to the custom generator, depending on the achieved number of changesets
* */
private static createCustomGeneratorButton(state: State): VariableUiElement {
const tr = Translations.t.general.morescreen;
return new VariableUiElement(
state.osmConnection.userDetails.map(userDetails => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme, {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
});
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme, {
url: "./customGenerator.html",
newTab: false
});
})
)
}
/**
* Creates a button linking to the given theme
* @param layout
* @param customThemeDefinition
* @private
*/
private static createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined): BaseUIElement {
if (layout === undefined) {
return undefined;
}
@ -100,17 +105,14 @@ export default class MoreScreen extends UIElement {
return undefined;
}
if (layout.hideFromOverview) {
const pref = State.state.osmConnection.GetPreference("hidden-theme-" + layout.id + "-enabled");
this.ListenTo(pref);
if (pref.data !== "true") {
return undefined;
}
return undefined;
}
if (layout.id === State.state.layoutToUse.data?.id) {
return undefined;
}
const currentLocation = State.state.locationControl.data;
const currentLocation = State.state.locationControl;
let path = window.location.pathname;
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"));
@ -119,19 +121,23 @@ export default class MoreScreen extends UIElement {
path = "."
}
const params = `z=${currentLocation.zoom ?? 1}&lat=${currentLocation.lat ?? 0}&lon=${currentLocation.lon ?? 0}`
let linkText =
`${path}/${layout.id.toLowerCase()}.html?${params}`
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
let linkSuffix = ""
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkText = `${path}/index.html?layout=${layout.id}&${params}`
linkPrefix = `${path}/index.html?layout=${layout.id}&`
}
if (customThemeDefinition) {
linkText = `${path}/index.html?userlayout=${layout.id}&${params}#${customThemeDefinition}`
linkPrefix = `${path}/index.html?userlayout=${layout.id}&`
linkSuffix = `#${customThemeDefinition}`
}
const linkText = currentLocation.map(currentLocation =>
`${linkPrefix}z=${currentLocation.zoom ?? 1}&lat=${currentLocation.lat ?? 0}&lon=${currentLocation.lon ?? 0}${linkSuffix}`)
let description = Translations.W(layout.shortDescription);
return new SubtleButton(layout.icon,
new Combine([
@ -144,4 +150,5 @@ export default class MoreScreen extends UIElement {
]), {url: linkText, newTab: false});
}
}

View file

@ -5,7 +5,7 @@ import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import Svg from "../../Svg";
import State from "../../State";
import Combine from "../Base/Combine";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
@ -79,7 +79,7 @@ export default class PersonalLayersPanel extends UIElement {
])
const cb = new CheckBox(
const cb = new Toggle(
new SubtleButton(
icon,
content),

View file

@ -19,7 +19,6 @@ export default class ShareButton extends UIElement{
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self= this;
htmlElement.addEventListener('click', () => {
if (navigator.share) {

View file

@ -1,4 +1,3 @@
import {VerticalCombine} from "../Base/VerticalCombine";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Translation} from "../i18n/Translation";
@ -9,7 +8,7 @@ import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
import State from "../../State";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants";
@ -40,7 +39,7 @@ export default class ShareScreen extends UIElement {
return Svg.no_checkmark_svg().SetStyle("width: 1.5em; display: inline-block;");
}
const includeLocation = new CheckBox(
const includeLocation = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLocation]),
new Combine([nocheck(), tr.fsIncludeCurrentLocation]),
true
@ -75,7 +74,7 @@ export default class ShareScreen extends UIElement {
const currentBackground = new VariableUiElement(currentLayer.map(layer => {
return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""}).Render();
}));
const includeCurrentBackground = new CheckBox(
const includeCurrentBackground = new Toggle(
new Combine([check(), currentBackground]),
new Combine([nocheck(), currentBackground]),
true
@ -90,7 +89,7 @@ export default class ShareScreen extends UIElement {
}, [currentLayer]));
const includeLayerChoices = new CheckBox(
const includeLayerChoices = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLayers]),
new Combine([nocheck(), tr.fsIncludeCurrentLayers]),
true
@ -120,7 +119,7 @@ export default class ShareScreen extends UIElement {
for (const swtch of switches) {
const checkbox = new CheckBox(
const checkbox = new Toggle(
new Combine([check(), Translations.W(swtch.human)]),
new Combine([nocheck(), Translations.W(swtch.human)]), !swtch.reverse
);
@ -143,7 +142,7 @@ export default class ShareScreen extends UIElement {
}
this._options = new VerticalCombine(optionCheckboxes)
this._options = new Combine(optionCheckboxes).SetClass("flex flex-col")
const url = (currentLocation ?? new UIEventSource(undefined)).map(() => {
const host = window.location.host;
@ -216,8 +215,8 @@ export default class ShareScreen extends UIElement {
).onClick(async () => {
const shareData = {
title: Translations.W(layout.id)?.InnerRender() ?? "",
text: Translations.W(layout.description)?.InnerRender() ?? "",
title: Translations.W(layout.title)?.InnerRenderAsString() ?? "",
text: Translations.W(layout.description)?.InnerRenderAsString() ?? "",
url: self._link.data,
}
@ -251,11 +250,11 @@ export default class ShareScreen extends UIElement {
}
InnerRender(): string {
InnerRender(): UIElement {
const tr = Translations.t.general.sharescreen;
return new VerticalCombine([
return new Combine([
this._editLayout,
tr.intro,
this._link,
@ -264,7 +263,7 @@ export default class ShareScreen extends UIElement {
tr.embedIntro,
this._options,
this._iframeCode,
]).Render()
]).SetClass("flex flex-col")
}
}

View file

@ -7,6 +7,7 @@ import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export default class ThemeIntroductionPanel extends UIElement {
private languagePicker: UIElement;
@ -44,7 +45,7 @@ export default class ThemeIntroductionPanel extends UIElement {
this.SetClass("link-underline")
}
InnerRender(): string {
InnerRender(): BaseUIElement {
const layout : LayoutConfig = this._layout.data;
return new Combine([
layout.description,
@ -54,7 +55,7 @@ export default class ThemeIntroductionPanel extends UIElement {
"<br/>",
this.languagePicker,
...layout.CustomCodeSnippets()
]).Render()
])
}

View file

@ -64,10 +64,10 @@ export default class UserBadge extends UIElement {
}
InnerRender(): string {
InnerRender(): UIElement {
const user = this._userDetails.data;
if (!user.loggedIn) {
return this._loginButton.Render();
return this._loginButton;
}
const linkStyle = "flex items-baseline"
@ -138,7 +138,7 @@ export default class UserBadge extends UIElement {
return new Combine([
userIcon,
usertext,
]).Render()
])
}

View file

@ -13,34 +13,37 @@ export default class CenterMessageBox extends UIElement {
this.ListenTo(State.state.layerUpdater.sufficientlyZoomed);
}
private static prep(): { innerHtml: string, done: boolean } {
private static prep(): { innerHtml: string | UIElement, done: boolean } {
if (State.state.centerMessage.data != "") {
return {innerHtml: State.state.centerMessage.data, done: false};
}
const lu = State.state.layerUpdater;
if (lu.timeout.data > 0) {
return {
innerHtml: Translations.t.centerMessage.retrying.Subs({count: "" + lu.timeout.data}).Render(),
innerHtml: Translations.t.centerMessage.retrying.Subs({count: "" + lu.timeout.data}),
done: false
};
}
if (lu.runningQuery.data) {
return {innerHtml: Translations.t.centerMessage.loadingData.Render(), done: false};
return {innerHtml: Translations.t.centerMessage.loadingData, done: false};
}
if (!lu.sufficientlyZoomed.data) {
return {innerHtml: Translations.t.centerMessage.zoomIn.Render(), done: false};
return {innerHtml: Translations.t.centerMessage.zoomIn, done: false};
} else {
return {innerHtml: Translations.t.centerMessage.ready.Render(), done: true};
return {innerHtml: Translations.t.centerMessage.ready, done: true};
}
}
InnerRender(): string {
InnerRender(): string | UIElement {
return CenterMessageBox.prep().innerHtml;
}
InnerUpdate(htmlElement: HTMLElement) {
if(htmlElement.parentElement === null){
return;
}
const pstyle = htmlElement.parentElement.style;
if (State.state.centerMessage.data != "") {
pstyle.opacity = "1";

View file

@ -1,113 +0,0 @@
import {UIElement} from "../UIElement";
import {TabbedComponent} from "../Base/TabbedComponent";
import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {GenerateEmpty} from "./GenerateEmpty";
import LayerPanelWithPreview from "./LayerPanelWithPreview";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {MultiInput} from "../Input/MultiInput";
import TagRenderingPanel from "./TagRenderingPanel";
import SingleSetting from "./SingleSetting";
import {VariableUiElement} from "../Base/VariableUIElement";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {DropDown} from "../Input/DropDown";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import Svg from "../../Svg";
export default class AllLayersPanel extends UIElement {
private panel: UIElement;
private readonly _config: UIEventSource<LayoutConfigJson>;
private readonly languages: UIEventSource<string[]>;
private readonly userDetails: UserDetails;
private readonly currentlySelected: UIEventSource<SingleSetting<any>>;
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<any>, userDetails: UserDetails) {
super(undefined);
this.userDetails = userDetails;
this._config = config;
this.languages = languages;
this.createPanels(userDetails);
const self = this;
this.dumbMode = false;
config.map<number>(config => config.layers.length).addCallback(() => self.createPanels(userDetails));
}
private createPanels(userDetails: UserDetails) {
const self = this;
const tabs = [];
const roamingTags = new MultiInput("Add a tagrendering",
() => GenerateEmpty.createEmptyTagRendering(),
() => {
return new TagRenderingPanel(self.languages, self.currentlySelected, self.userDetails)
}, undefined, {allowMovement: true});
new SingleSetting(this._config, roamingTags, "roamingRenderings", "Roaming Renderings", "These tagrenderings are shown everywhere");
const backgroundLayers = AvailableBaseLayers.layerOverview.map(baselayer => ({shown:
baselayer.name, value: baselayer.id}));
const dropDown = new DropDown("Choose the default background layer",
[{value: "osm",shown:"OpenStreetMap <b>(default)</b>"}, ...backgroundLayers])
new SingleSetting(self._config, dropDown, "defaultBackgroundId", "Default background layer",
"Selects the background layer that is used by default. If this layer is not available at the given point, OSM-Carto will be ued");
const layers = this._config.data.layers;
for (let i = 0; i < layers.length; i++) {
tabs.push({
header: new VariableUiElement(this._config.map((config: LayoutConfigJson) => {
const layer = config.layers[i];
if (typeof layer !== "string") {
try {
const iconTagRendering = new TagRenderingConfig(layer["icon"], undefined, "icon")
const icon = iconTagRendering.GetRenderValue({"id": "node/-1"}).txt;
return `<img src='${icon}'>`
} catch (e) {
return Svg.bug_img
// Nothing to do here
}
}
return Svg.help_img;
})),
content: new LayerPanelWithPreview(this._config, this.languages, i, userDetails)
});
}
tabs.push({
header: Svg.layersAdd_img,
content: new Combine([
"<h2>Layer editor</h2>",
"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(
Svg.layersAdd_ui(),
"Add a new layer"
).onClick(() => {
self._config.data.layers.push(GenerateEmpty.createEmptyLayer())
self._config.ping();
}),
"<h2>Default background layer</h2>",
dropDown,
"<h2>TagRenderings for every layer</h2>",
"Define tag renderings and questions here that should be shown on every layer of the theme.",
roamingTags
]
),
})
this.panel = new TabbedComponent(tabs, new UIEventSource<number>(Math.max(0, layers.length - 1)));
this.Update();
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,118 +0,0 @@
import {UIElement} from "../UIElement";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {UIEventSource} from "../../Logic/UIEventSource";
import SingleSetting from "./SingleSetting";
import GeneralSettings from "./GeneralSettings";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
import {TabbedComponent} from "../Base/TabbedComponent";
import PageSplit from "../Base/PageSplit";
import AllLayersPanel from "./AllLayersPanel";
import SharePanel from "./SharePanel";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import SavePanel from "./SavePanel";
import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource";
import HelpText from "./HelpText";
import Svg from "../../Svg";
import Constants from "../../Models/Constants";
import LZString from "lz-string";
import {Utils} from "../../Utils";
export default class CustomGeneratorPanel extends UIElement {
private mainPanel: UIElement;
private loginButton: UIElement;
private readonly connection: OsmConnection;
constructor(connection: OsmConnection, layout: LayoutConfigJson) {
super(connection.userDetails);
this.connection = connection;
this.SetClass("main-tabs");
this.loginButton = new SubtleButton("", "Login to create a custom theme").onClick(() => connection.AttemptLogin())
const self = this;
self.mainPanel = new FixedUiElement("Attempting to log in...");
connection.OnLoggedIn(userDetails => {
self.InitMainPanel(layout, userDetails, connection);
self.Update();
})
}
private InitMainPanel(layout: LayoutConfigJson, userDetails: UserDetails, connection: OsmConnection) {
const es = new UIEventSource(layout);
const encoded = es.map(config => LZString.compressToBase64(Utils.MinifyJSON(JSON.stringify(config, null, 0))));
encoded.addCallback(encoded => LocalStorageSource.Get("last-custom-theme"))
const liveUrl = encoded.map(encoded => `./index.html?userlayout=${es.data.id}#${encoded}`)
const testUrl = encoded.map(encoded => `./index.html?test=true&userlayout=${es.data.id}#${encoded}`)
const iframe = testUrl.map(url => `<iframe src='${url}' width='100%' height='99%' style="box-sizing: border-box" title='Theme Preview'></iframe>`);
const currentSetting = new UIEventSource<SingleSetting<any>>(undefined)
const generalSettings = new GeneralSettings(es, currentSetting);
const languages = generalSettings.languages;
const chronic = UIEventSource.Chronic(120 * 1000)
.map(date => {
if (es.data.id == undefined) {
return undefined
}
if (es.data.id === "") {
return undefined;
}
const pref = connection.GetLongPreference("installed-theme-" + es.data.id);
pref.setData(encoded.data);
return date;
});
const preview = new Combine([
new VariableUiElement(iframe)
]).SetClass("preview")
this.mainPanel = new TabbedComponent([
{
header: Svg.gear_img,
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: Svg.layers_img,
content: new AllLayersPanel(es, languages, userDetails)
},
{
header: Svg.floppy_img,
content: new SavePanel(this.connection, es, chronic)
},
{
header:Svg.share_img,
content: new SharePanel(es, liveUrl, userDetails)
}
])
}
InnerRender(): string {
const ud = this.connection.userDetails.data;
if (!ud.loggedIn) {
return new Combine([
"<h3>Not Logged in</h3>",
"You need to be logged in in order to create a custom theme",
this.loginButton
]).Render();
}
const journey = Constants.userJourney;
if (ud.csCount <= journey.themeGeneratorReadOnlyUnlock) {
return new Combine([
"<h3>Too little experience</h3>",
`<p>Creating your own (readonly) themes can only be done if you have more then <b>${journey.themeGeneratorReadOnlyUnlock}</b> changesets made</p>`,
`<p>Making a theme including survey options can be done at <b>${journey.themeGeneratorFullUnlock}</b> changesets</p>`
]).Render();
}
return this.mainPanel.Render()
}
}

View file

@ -1,88 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import {TextField} from "../Input/TextField";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import ValidatedTextField from "../Input/ValidatedTextField";
export default class GeneralSettingsPanel extends UIElement {
private panel: Combine;
public languages : UIEventSource<string[]>;
constructor(configuration: UIEventSource<LayoutConfigJson>, currentSetting: UIEventSource<SingleSetting<any>>) {
super(undefined);
const languagesField =
ValidatedTextField.Mapped(
str => {
console.log("Language from str", str);
return str?.split(";")?.map(str => str.trim().toLowerCase());
},
languages => languages.join(";"));
this.languages = languagesField.GetValue();
const version = new TextField();
const current_datetime = new Date();
let formatted_date = current_datetime.getFullYear() + "-" + (current_datetime.getMonth() + 1) + "-" + current_datetime.getDate() + " " + current_datetime.getHours() + ":" + current_datetime.getMinutes() + ":" + current_datetime.getSeconds()
version.GetValue().setData(formatted_date);
const locationRemark = "<br/>Note that, as soon as an URL-parameter sets the location or a location is known due to a previous visit, that the theme-set location is ignored"
const settingsTable = new SettingsTable(
[
new SingleSetting(configuration, new TextField({placeholder:"id"}), "id",
"Identifier", "The identifier of this theme. This should be a lowercase, unique string"),
new SingleSetting(configuration, version, "version", "Version",
"A version to indicate the theme version. Ideal is the date you created or updated the theme"),
new SingleSetting(configuration, languagesField, "language",
"Supported languages", "Which languages do you want to support in this theme? Type the two letter code representing your language, seperated by <span class='literal-code'>;</span>. For example:<span class='literal-code'>en;nl</span> "),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "title",
"Title", "The title as shown in the welcome message, in the browser title bar, in the more screen, ..."),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "shortDescription","Short description",
"The short description is shown as subtext in the social preview and on the 'more screen'-buttons. It should be at most one sentence of around ~25words"),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages, true),
"description", "Description", "The description is shown in the welcome-message when opening MapComplete. It is a small text welcoming users"),
new SingleSetting(configuration, new TextField({placeholder: "URL to icon"}), "icon",
"Icon", "A visual representation for your theme; used as logo in the welcomeMessage. If your theme is official, used as favicon and webapp logo",
{
showIconPreview: true
}),
new SingleSetting(configuration, ValidatedTextField.NumberInput("nat", n => n < 23), "startZoom","Initial zoom level",
"When a user first loads MapComplete, this zoomlevel is shown."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 90 && n > -90)), "startLat","Initial latitude",
"When a user first loads MapComplete, this latitude is shown as location."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 180 && n > -180)), "startLon","Initial longitude",
"When a user first loads MapComplete, this longitude is shown as location."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("pfloat", n => (n < 0.5 )), "widenFactor","Query widening",
"When a query is run, the data within bounds of the visible map is loaded.\n" +
"However, users tend to pan and zoom a lot. It is pretty annoying if every single pan means a reloading of the data.\n" +
"For this, the bounds are widened in order to make a small pan still within bounds of the loaded data.\n" +
"IF widenfactor is 0, this feature is disabled. A recommended value is between 0.5 and 0.01 (the latter for very dense queries)"),
new SingleSetting(configuration, new TextField({placeholder: "URL to social image"}), "socialImage",
"og:image (aka Social Image)", "<span class='alert'>Only works on incorporated themes</span>" +
"The Social Image is set as og:image for the HTML-site and helps social networks to show a preview", {showIconPreview: true})
], currentSetting);
this.panel = new Combine([
"<h3>General theme settings</h3>",
settingsTable
]);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,87 +0,0 @@
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: "yourlayer",
name: {},
minzoom: 12,
overpassTags: {and: [""]},
title: {},
description: {},
tagRenderings: [],
hideUnderlayingFeaturesMinPercentage: 0,
icon: {
render: "./assets/svg/bug.svg"
},
width: {
render: "8"
},
iconSize: {
render: "40,40,center"
},
color:{
render: "#00f"
}
}
}
public static createEmptyLayout(): LayoutConfigJson {
return {
id: "id",
title: {},
shortDescription: {},
description: {},
language: [],
maintainer: "",
icon: "./assets/svg/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
widenFactor: 0.05,
socialImage: "",
layers: [
GenerateEmpty.createEmptyLayer()
]
}
}
public static createTestLayout(): LayoutConfigJson {
return {
id: "test",
title: {"en": "Test layout"},
shortDescription: {},
description: {"en": "A layout for testing"},
language: ["en"],
maintainer: "Pieter Vander Vennet",
icon: "./assets/svg/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
widenFactor: 0.05,
socialImage: "",
layers: [{
id: "testlayer",
name: {en:"Testing layer"},
minzoom: 15,
overpassTags: {and: ["highway=residential"]},
title: {},
description: {"en": "Some Description"},
icon: {render: {en: "./assets/svg/pencil.svg"}},
width: {render: {en: "5"}},
tagRenderings: [{
render: {"en":"Test Rendering"}
}]
}]
}
}
public static createEmptyTagRendering(): TagRenderingConfigJson {
return {};
}
}

View file

@ -1,51 +0,0 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import SingleSetting from "./SingleSetting";
import Svg from "../../Svg";
export default class HelpText extends UIElement {
private helpText: UIElement;
private returnButton: UIElement;
constructor(currentSetting: UIEventSource<SingleSetting<any>>) {
super();
this.returnButton = new SubtleButton(Svg.close_ui(),
new VariableUiElement(
currentSetting.map(currentSetting => {
if (currentSetting === undefined) {
return "";
}
return "Return to general help";
}
)
))
.ListenTo(currentSetting)
.SetClass("small-button")
.onClick(() => currentSetting.setData(undefined));
this.helpText = new VariableUiElement(currentSetting.map((setting: SingleSetting<any>) => {
if (setting === undefined) {
return "<h1>Welcome to the Custom Theme Builder</h1>" +
"Here, one can make their own custom mapcomplete themes.<br/>" +
"Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it.<br/>" +
"Want to see how the quests are doing in number of visits? All the stats are open on <a href='https://pietervdvn.goatcounter.com' target='_blank'>goatcounter</a>";
}
return new Combine(["<h1>", setting._name, "</h1>", setting._description.Render()]).Render();
}))
}
InnerRender(): string {
return new Combine([this.helpText,
this.returnButton,
]).Render();
}
}

View file

@ -1,251 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import {TextField} from "../Input/TextField";
import {InputElement} from "../Input/InputElement";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import CheckBox from "../Input/CheckBox";
import AndOrTagInput from "../Input/AndOrTagInput";
import TagRenderingPanel from "./TagRenderingPanel";
import {DropDown} from "../Input/DropDown";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
import {MultiInput} from "../Input/MultiInput";
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
import PresetInputPanel from "./PresetInputPanel";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {FixedUiElement} from "../Base/FixedUiElement";
import ValidatedTextField from "../Input/ValidatedTextField";
import Svg from "../../Svg";
import Constants from "../../Models/Constants";
/**
* Shows the configuration for a single layer
*/
export default class LayerPanel extends UIElement {
private readonly _config: UIEventSource<LayoutConfigJson>;
private readonly settingsTable: UIElement;
private readonly mapRendering: UIElement;
private readonly deleteButton: UIElement;
public readonly titleRendering: UIElement;
public readonly selectedTagRendering: UIEventSource<TagRenderingPanel>
= new UIEventSource<TagRenderingPanel>(undefined);
private tagRenderings: UIElement;
private presetsPanel: UIElement;
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails) {
super();
this._config = config;
this.mapRendering = this.setupRenderOptions(config, languages, index, currentlySelected, userDetails);
const actualDeleteButton = new SubtleButton(
Svg.delete_icon_ui(),
"Yes, delete this layer"
).onClick(() => {
config.data.layers.splice(index, 1);
config.ping();
});
this.deleteButton = new CheckBox(
new Combine(
[
"<h3>Confirm layer deletion</h3>",
new SubtleButton(
Svg.close_ui(),
"No, don't delete"
),
"<span class='alert'>Deleting a layer can not be undone!</span>",
actualDeleteButton
]
),
new SubtleButton(
Svg.delete_icon_ui(),
"Remove this layer"
)
)
function setting(input: InputElement<any>, path: string | string[], name: string, description: string | UIElement): SingleSetting<any> {
let pathPre = ["layers", index];
if (typeof (path) === "string") {
pathPre.push(path);
} else {
pathPre = pathPre.concat(path);
}
return new SingleSetting<any>(config, input, pathPre, name, description);
}
this.settingsTable = new SettingsTable([
setting(new TextField({placeholder:"Layer id"}), "id", "Id", "An identifier for this layer<br/>This should be a simple, lowercase, human readable string that is used to identify the layer."),
setting(new MultiLingualTextFields(languages), "name", "Name", "The human-readable name of this layer<br/>Used in the layer control panel and the 'Personal theme'"),
setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.<br/>Shown in the layer selections and in the personal theme"),
setting(ValidatedTextField.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: 2, shown: "Show both the ways/areas and the centerpoints"},
{value: 1, 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);
const self = this;
const popupTitleRendering = new TagRenderingPanel(languages, currentlySelected, userDetails, {
title: "Popup title",
description: "This is the rendering shown as title in the popup for this element",
disableQuestions: true
});
new SingleSetting(config, popupTitleRendering, ["layers", index, "title"], "Popup title", "This is the rendering shown as title in the popup");
this.titleRendering = popupTitleRendering;
this.registerTagRendering(popupTitleRendering);
const renderings = config.map(config => {
const layer = config.layers[index] as LayerConfigJson;
// @ts-ignore
const renderings : TagRenderingConfigJson[] = layer.tagRenderings ;
return renderings;
});
const tagRenderings = new MultiInput<TagRenderingConfigJson>("Add a tag rendering/question",
() => ({}),
() => {
const tagPanel = new TagRenderingPanel(languages, currentlySelected, userDetails)
self.registerTagRendering(tagPanel);
return tagPanel;
}, renderings,
{allowMovement: true});
tagRenderings.GetValue().addCallback(
tagRenderings => {
(config.data.layers[index] as LayerConfigJson).tagRenderings = tagRenderings;
config.ping();
}
)
if (userDetails.csCount >= Constants.userJourney.themeGeneratorFullUnlock) {
const presetPanel = new MultiInput("Add a preset",
() => ({tags: [], title: {}}),
() => new PresetInputPanel(currentlySelected, languages),
undefined, {allowMovement: true});
new SingleSetting(config, presetPanel, ["layers", index, "presets"], "Presets", "")
this.presetsPanel = presetPanel;
} else {
this.presetsPanel = new FixedUiElement(`Creating a custom theme which also edits OSM is only unlocked after ${Constants.userJourney.themeGeneratorFullUnlock} changesets`).SetClass("alert");
}
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<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails
): UIElement {
const iconSelect = new TagRenderingPanel(
languages, currentlySelected, userDetails,
{
title: "Icon",
description: "A visual representation for this layer and for the points on the map.",
disableQuestions: true,
noLanguage: true
});
const size = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Icon Size",
description: "The size of the icons on the map in pixels. Can vary based on the tagging",
disableQuestions: true,
noLanguage: true
});
const color = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Way and area color",
description: "The color or a shown way or area. Can vary based on the tagging",
disableQuestions: true,
noLanguage: true
});
const stroke = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Stroke width",
description: "The width of lines representing ways and the outline of areas. Can vary based on the tags",
disableQuestions: true,
noLanguage: true
});
this.registerTagRendering(iconSelect);
this.registerTagRendering(size);
this.registerTagRendering(color);
this.registerTagRendering(stroke);
function setting(input: InputElement<any>, path, isIcon: boolean = false): SingleSetting<TagRenderingConfigJson> {
return new SingleSetting(config, input, ["layers", index, path], undefined, undefined)
}
return new SettingsTable([
setting(iconSelect, "icon"),
setting(size, "iconSize"),
setting(color, "color"),
setting(stroke, "width")
], currentlySelected);
}
private registerTagRendering(
tagRenderingPanel: TagRenderingPanel) {
tagRenderingPanel.IsHovered().addCallback(isHovering => {
if (!isHovering) {
return;
}
this.selectedTagRendering.setData(tagRenderingPanel);
})
}
InnerRender(): string {
return new Combine([
"<h2>General layer settings</h2>",
this.settingsTable,
"<h2>Popup contents</h2>",
this.titleRendering,
this.tagRenderings,
"<h2>Presets</h2>",
"Does this theme support adding a new point?<br/>If this should be the case, add a preset. Make sure that the preset tags do match the overpass-tags, otherwise it might seem like the newly added points dissapear ",
this.presetsPanel,
"<h2>Map rendering options</h2>",
this.mapRendering,
"<h2>Layer delete</h2>",
this.deleteButton
]).Render();
}
}

View file

@ -1,58 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import SingleSetting from "./SingleSetting";
import LayerPanel from "./LayerPanel";
import HelpText from "./HelpText";
import {MultiTagInput} from "../Input/MultiTagInput";
import {FromJSON} from "../../Customizations/JSON/FromJSON";
import Combine from "../Base/Combine";
import PageSplit from "../Base/PageSplit";
import TagRenderingPreview from "./TagRenderingPreview";
import UserDetails from "../../Logic/Osm/OsmConnection";
export default class LayerPanelWithPreview extends UIElement{
private panel: UIElement;
constructor(config: UIEventSource<any>, languages: UIEventSource<string[]>, index: number, userDetails: UserDetails) {
super();
const currentlySelected = new UIEventSource<(SingleSetting<any>)>(undefined);
const layer = new LayerPanel(config, languages, index, currentlySelected, userDetails);
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 TagRenderingPreview(layer.selectedTagRendering, previewTagValue);
this.panel = new PageSplit(
layer.SetClass("scrollable"),
new Combine([
helpText,
"</br>",
"<h2>Testing tags</h2>",
previewTagInput,
"<h2>Tag Rendering preview</h2>",
preview
]), 60
);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,64 +0,0 @@
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<any>, disableQuestions: boolean = false) {
super();
const currentSelected = new UIEventSource<SingleSetting<any>>(undefined);
this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({
if: undefined,
then: undefined
});
const self = this;
function setting(inputElement: InputElement<any>, 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. <span class='literal-code'>access=yes</span> and <span class='literal-code'>access=public</span>)." +
"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 <i>no</i> value defined, e.g. <span class='literal-code'>indoor=</span>. 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 <b>then</b> below will be used"),
setting(new MultiLingualTextFields(languages),
"then", "Then show", "If the condition above matches, this template <b>then</b> 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<boolean> = new UIEventSource<boolean>(false);
IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean {
return false;
}
}

View file

@ -1,58 +0,0 @@
import {InputElement} from "../Input/InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {MultiTagInput} from "../Input/MultiTagInput";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import Combine from "../Base/Combine";
export default class PresetInputPanel extends InputElement<{
title: string | any,
tags: string[],
description?: string | any
}> {
private readonly _value: UIEventSource<{
title: string | any,
tags: string[],
description?: string | any
}>;
private readonly panel: UIElement;
constructor(currentlySelected: UIEventSource<SingleSetting<any>>, languages: UIEventSource<string[]>) {
super();
this._value = new UIEventSource({tags: [], title: {}});
const self = this;
function s(input: InputElement<any>, path: string, name: string, description: string){
return new SingleSetting(self._value, input, path, name, description)
}
this.panel = new SettingsTable([
s(new MultiTagInput(), "tags","Preset tags","These tags will be applied on the newly created point"),
s(new MultiLingualTextFields(languages), "title","Preset title","This little text is shown in bold on the 'create new point'-button" ),
s(new MultiLingualTextFields(languages), "description","Description", "This text is shown in the button as description when creating a new point")
], currentlySelected).SetStyle("display: block; border: 1px solid black; border-radius: 1em;padding: 1em;");
}
InnerRender(): string {
return new Combine([this.panel]).Render();
}
GetValue(): UIEventSource<{
title: string | any,
tags: string[],
description?: string | any
}> {
return this._value;
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: any): boolean {
return false;
}
}

View file

@ -1,69 +0,0 @@
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {FixedUiElement} from "../Base/FixedUiElement";
import {TextField} from "../Input/TextField";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
export default class SavePanel extends UIElement {
private json: UIElement;
private lastSaveEl: UIElement;
private loadFromJson: UIElement;
constructor(
connection: OsmConnection,
config: UIEventSource<LayoutConfigJson>,
chronic: UIEventSource<Date>) {
super();
this.lastSaveEl = new VariableUiElement(chronic
.map(date => {
if (date === undefined) {
return new FixedUiElement("Your theme will be saved automatically within two minutes... Click here to force saving").SetClass("alert").Render()
}
return "Your theme was last saved at " + date.toISOString()
})).onClick(() => chronic.setData(new Date()));
const jsonStr = config.map(config =>
JSON.stringify(config, null, 2));
const jsonTextField = new TextField({
placeholder: "JSON Config",
value: jsonStr,
textArea: true,
textAreaRows: 20
});
this.json = jsonTextField;
this.loadFromJson = new SubtleButton(Svg.reload_ui(), "<b>Load the JSON file below</b>")
.onClick(() => {
try{
const json = jsonTextField.GetValue().data;
const parsed : LayoutConfigJson = JSON.parse(json);
config.setData(parsed);
}catch(e){
alert("Invalid JSON: "+e)
}
});
}
InnerRender(): string {
return new Combine([
"<h3>Save your theme</h3>",
this.lastSaveEl,
"<h3>JSON configuration</h3>",
"The url hash is actually no more then a BASE64-encoding of the below JSON. This json contains the full configuration of the theme.<br/>" +
"This configuration is mainly useful for debugging",
"<br/>",
this.loadFromJson,
this.json
]).SetClass("scrollable")
.Render();
}
}

View file

@ -1,58 +0,0 @@
import SingleSetting from "./SingleSetting";
import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import PageSplit from "../Base/PageSplit";
import Combine from "../Base/Combine";
export default class SettingsTable extends UIElement {
private _col1: UIElement[] = [];
private _col2: UIElement[] = [];
public selectedSetting: UIEventSource<SingleSetting<any>>;
constructor(elements: (SingleSetting<any> | string)[],
currentSelectedSetting?: UIEventSource<SingleSetting<any>>) {
super(undefined);
const self = this;
this.selectedSetting = currentSelectedSetting ?? new UIEventSource<SingleSetting<any>>(undefined);
for (const element of elements) {
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);
} else if (self.selectedSetting.data === element) {
self.selectedSetting.setData(undefined);
}
})
}
}
InnerRender(): string {
let elements = [];
for (let i = 0; i < this._col1.length; i++) {
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 new Combine(elements).Render();
}
}

View file

@ -1,34 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
import UserDetails from "../../Logic/Osm/OsmConnection";
export default class SharePanel extends UIElement {
private _config: UIEventSource<LayoutConfigJson>;
private _panel: UIElement;
constructor(config: UIEventSource<LayoutConfigJson>, liveUrl: UIEventSource<string>, userDetails: UserDetails) {
super(undefined);
this._config = config;
this._panel = new Combine([
"<h2>Share</h2>",
"Share the following link with friends:<br/>",
new VariableUiElement(liveUrl.map(url => `<a href='${url}' target="_blank">${url}</a>`)),
"<h2>Publish on some website</h2>",
"It is possible to load a JSON-file from the wide internet, but you'll need some (public CORS-enabled) server.",
`Put the raw json online, and use ${window.location.host}?userlayout=https://<your-url-here>.json`,
"Please note: it used to be possible to load from the wiki - this is not possible anymore due to technical reasons.",
"</div>"
]);
}
InnerRender(): string {
return this._panel.Render();
}
}

View file

@ -1,89 +0,0 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
export default class SingleSetting<T> {
public _value: InputElement<T>;
public _name: string;
public _description: UIElement;
public _options: { showIconPreview?: boolean };
constructor(config: UIEventSource<any>,
value: InputElement<T>,
path: string | (string | number)[],
name: string,
description: string | UIElement,
options?: {
showIconPreview?: boolean
}
) {
this._value = value;
this._name = name;
this._description = Translations.W(description);
this._options = options ?? {};
if (this._options.showIconPreview) {
this._description = new Combine([
this._description,
"<h3>Icon preview</h3>",
new VariableUiElement(this._value.GetValue().map(url => `<img src='${url}' class="image-large-preview">`))
]);
}
if(typeof (path) === "string"){
path = [path];
}
const lastPart = path[path.length - 1];
path.splice(path.length - 1, 1);
function assignValue(value) {
if (value === undefined) {
return;
}
// We have to rewalk every time as parts might be new
let configPart = config.data;
for (const pathPart of path) {
let newConfigPart = configPart[pathPart];
if (newConfigPart === undefined) {
if (typeof (pathPart) === "string") {
configPart[pathPart] = {};
} else {
configPart[pathPart] = [];
}
newConfigPart = configPart[pathPart];
}
configPart = newConfigPart;
}
configPart[lastPart] = value;
config.ping();
}
function loadValue() {
let configPart = config.data;
for (const pathPart of path) {
configPart = configPart[pathPart];
if (configPart === undefined) {
return;
}
}
const loadedValue = configPart[lastPart];
if (loadedValue !== undefined) {
value.GetValue().setData(loadedValue);
}
}
loadValue();
config.addCallback(() => loadValue());
value.GetValue().addCallback(assignValue);
assignValue(this._value.GetValue().data);
}
}

View file

@ -1,155 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import SingleSetting from "./SingleSetting";
import SettingsTable from "./SettingsTable";
import {TextField} 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";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {VariableUiElement} from "../Base/VariableUIElement";
import ValidatedTextField from "../Input/ValidatedTextField";
import SpecialVisualizations from "../SpecialVisualizations";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import Constants from "../../Models/Constants";
export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> {
public IsImage = false;
public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; };
public readonly validText: UIElement;
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private intro: UIElement;
private settingsTable: UIElement;
private readonly _value: UIEventSource<TagRenderingConfigJson>;
constructor(languages: UIEventSource<string[]>,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails,
options?: {
title?: string,
description?: string,
disableQuestions?: boolean,
isImage?: boolean,
noLanguage?: boolean
}) {
super();
this.SetClass("bordered");
this.SetClass("min-height");
this.options = options ?? {};
const questionsNotUnlocked = userDetails.csCount < Constants.userJourney.themeGeneratorFullUnlock;
this.options.disableQuestions =
(this.options.disableQuestions ?? false) ||
questionsNotUnlocked;
this.intro = new Combine(["<h3>", options?.title ?? "TagRendering", "</h3>",
options?.description ?? "A tagrendering converts OSM-tags into a value on screen. Fill out the field 'render' with the text that should appear. Note that `{key}` will be replaced with the corresponding `value`, if present.<br/>For specific known tags (e.g. if `foo=bar`, make a mapping). "])
this.IsImage = options?.isImage ?? false;
const value = new UIEventSource<TagRenderingConfigJson>({});
this._value = value;
function setting(input: InputElement<any>, id: string | string[], name: string, description: string | UIElement): SingleSetting<any> {
return new SingleSetting<any>(value, input, id, name, description);
}
this._value.addCallback(value => {
let doPing = false;
if (value?.freeform?.key == "") {
value.freeform = undefined;
doPing = true;
}
if (value?.render == "") {
value.render = undefined;
doPing = true;
}
if (doPing) {
this._value.ping();
}
})
const questionSettings = [
setting(options?.noLanguage ? new TextField({placeholder: "question"}) : new MultiLingualTextFields(languages)
, "question", "Question", "If the key or mapping doesn't match, this question is asked"),
"<h3>Freeform key</h3>",
setting(ValidatedTextField.KeyInput(true), ["freeform", "key"], "Freeform key<br/>",
"If specified, the rendering will search if this key is present." +
"If it is, the rendering above will be used to display the element.<br/>" +
"The rendering will go into question mode if <ul><li>this key is not present</li><li>No single mapping matches</li><li>A question is given</li>"),
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. <span class='literal-code'>fixme=User used a freeform field - to check</span>"),
];
const settings: (string | SingleSetting<any>)[] = [
setting(
options?.noLanguage ? new TextField({placeholder: "Rendering"}) :
new MultiLingualTextFields(languages), "render", "Value to show",
"Renders this value. Note that <span class='literal-code'>{key}</span>-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." +
"<br/><br/>" +
"Furhtermore, some special functions are supported:" + SpecialVisualizations.HelpMessage.Render()),
questionsNotUnlocked ? `You need at least ${Constants.userJourney.themeGeneratorFullUnlock} changesets to unlock the 'question'-field and to use your theme to edit OSM data` : "",
...(options?.disableQuestions ? [] : questionSettings),
"<h3>Mappings</h3>",
setting(new MultiInput<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }>("Add a mapping",
() => ({if: {and: []}, then: {}}),
() => new MappingInput(languages, options?.disableQuestions ?? false),
undefined, {allowMovement: true}), "mappings",
"If a tag matches, then show the first respective text", ""),
"<h3>Condition</h3>",
setting(new AndOrTagInput(), "condition", "Only show this tagrendering if the following condition applies",
"Only show this tag rendering if these tags matches. Optional field.<br/>Note that the Overpass-tags are already always included in this object"),
];
this.settingsTable = new SettingsTable(settings, currentlySelected);
this.validText = new VariableUiElement(value.map((json: TagRenderingConfigJson) => {
try {
new TagRenderingConfig(json, undefined, options?.title ?? "");
return "";
} catch (e) {
return "<span class='alert'>" + e + "</span>"
}
}));
}
InnerRender(): string {
return new Combine([
this.intro,
this.settingsTable,
this.validText]).Render();
}
GetValue(): UIEventSource<TagRenderingConfigJson> {
return this._value;
}
IsValid(t: TagRenderingConfigJson): boolean {
return false;
}
}

View file

@ -1,70 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import TagRenderingPanel from "./TagRenderingPanel";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import EditableTagRendering from "../Popup/EditableTagRendering";
export default class TagRenderingPreview extends UIElement {
private readonly previewTagValue: UIEventSource<any>;
private selectedTagRendering: UIEventSource<TagRenderingPanel>;
private panel: UIElement;
constructor(selectedTagRendering: UIEventSource<TagRenderingPanel>,
previewTagValue: UIEventSource<any>) {
super(selectedTagRendering);
this.selectedTagRendering = selectedTagRendering;
this.previewTagValue = previewTagValue;
this.panel = this.GetPanel(undefined);
const self = this;
this.selectedTagRendering.addCallback(trp => {
self.panel = self.GetPanel(trp);
self.Update();
})
}
private GetPanel(tagRenderingPanel: TagRenderingPanel): UIElement {
if (tagRenderingPanel === undefined) {
return new FixedUiElement("No tag rendering selected at the moment. Hover over a tag rendering to see what it looks like");
}
let es = tagRenderingPanel.GetValue();
let rendering: UIElement;
const self = this;
try {
rendering =
new VariableUiElement(es.map(tagRenderingConfig => {
try {
const tr = new EditableTagRendering(self.previewTagValue, new TagRenderingConfig(tagRenderingConfig, undefined,"preview"));
return tr.Render();
} catch (e) {
return new Combine(["Could not show this tagrendering:", e.message]).Render();
}
}
));
} catch (e) {
console.error("User defined tag rendering incorrect:", e);
rendering = new FixedUiElement(e).SetClass("alert");
}
return new Combine([
"<h3>",
tagRenderingPanel.options.title ?? "Extra tag rendering",
"</h3>",
tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup",
"<br/><br/>",
rendering]);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,7 +1,7 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import Combine from "../Base/Combine";
import State from "../../State";
import Svg from "../../Svg";
@ -30,7 +30,7 @@ export default class DeleteImage extends UIElement {
});
const cancelButton = Translations.t.general.cancel.SetClass("bg-white pl-4 pr-4").SetStyle( "border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;");
this.deleteDialog = new CheckBox(
this.deleteDialog = new Toggle(
new Combine([
deleteButton,
cancelButton
@ -40,17 +40,17 @@ export default class DeleteImage extends UIElement {
}
InnerRender(): string {
InnerRender() {
if(! State.state?.featureSwitchUserbadge?.data){
return "";
}
const value = this.tags.data[this.key];
if (value === undefined || value === "") {
return this.isDeletedBadge.Render();
return this.isDeletedBadge;
}
return this.deleteDialog.Render();
return this.deleteDialog;
}
}

View file

@ -31,7 +31,7 @@ export class ImageCarousel extends UIElement{
return uiElements;
});
this.slideshow = new SlideShow(uiElements).HideOnEmpty(true);
this.slideshow = new SlideShow(uiElements);
this.SetClass("block w-full");
this.slideshow.SetClass("w-full");
}

View file

@ -9,9 +9,10 @@ import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import Svg from "../../Svg";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../BaseUIElement";
export class ImageUploadFlow extends UIElement {
private readonly _licensePicker: UIElement;
private readonly _licensePicker: BaseUIElement;
private readonly _tags: UIEventSource<any>;
private readonly _selectedLicence: UIEventSource<string>;
private readonly _isUploading: UIEventSource<number> = new UIEventSource<number>(0)
@ -35,10 +36,8 @@ export class ImageUploadFlow extends UIElement {
{value: "CC-BY-SA 4.0", shown: Translations.t.image.ccbs},
{value: "CC-BY 4.0", shown: Translations.t.image.ccb}
],
State.state.osmConnection.GetPreference("pictures-license"),
"","",
"flex flex-col sm:flex-row"
);
State.state.osmConnection.GetPreference("pictures-license")
).SetClass("flex flex-col sm:flex-row");
licensePicker.SetStyle("float:left");
const t = Translations.t.image;
@ -186,8 +185,6 @@ export class ImageUploadFlow extends UIElement {
}
InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
this._licensePicker.Update()
const form = document.getElementById('fileselector-form-' + this.id) as HTMLFormElement
const selector = document.getElementById('fileselector-' + this.id)

View file

@ -35,7 +35,6 @@ export class SlideShow extends UIElement {
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
require("slick-carousel")
if(this._embeddedElements.data.length == 0){
return;

View file

@ -1,164 +0,0 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import CheckBox from "./CheckBox";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {MultiTagInput} from "./MultiTagInput";
import Svg from "../../Svg";
class AndOrConfig implements AndOrTagConfigJson {
public and: (string | AndOrTagConfigJson)[] = undefined;
public or: (string | AndOrTagConfigJson)[] = undefined;
}
export default class AndOrTagInput extends InputElement<AndOrTagConfigJson> {
private readonly _rawTags = new MultiTagInput();
private readonly _subAndOrs: AndOrTagInput[] = [];
private readonly _isAnd: UIEventSource<boolean> = new UIEventSource<boolean>(true);
private readonly _isAndButton;
private readonly _addBlock: UIElement;
private readonly _value: UIEventSource<AndOrConfig> = new UIEventSource<AndOrConfig>(undefined);
public bottomLeftButton: UIElement;
IsSelected: UIEventSource<boolean>;
constructor() {
super();
const self = this;
this._isAndButton = new CheckBox(
new SubtleButton(Svg.ampersand_ui(), null).SetClass("small-button"),
new SubtleButton(Svg.or_ui(), null).SetClass("small-button"),
this._isAnd);
this._addBlock =
new SubtleButton(Svg.addSmall_ui(), "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 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();
}
private createDeleteButton(elementId: string): UIElement {
const self = this;
return new SubtleButton(Svg.delete_icon_ui(), 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);
}
}
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<AndOrTagConfigJson> {
return this._value;
}
InnerRender(): string {
const leftColumn = new Combine([
this._isAndButton,
"<br/>",
this.bottomLeftButton ?? ""
]);
const tags = new Combine([
this._rawTags,
...this._subAndOrs,
this._addBlock
]).Render();
return `<span class="bordered"><table><tr><td>${leftColumn.Render()}</td><td>${tags}</td></tr></table></span>`;
}
IsValid(t: AndOrTagConfigJson): boolean {
return true;
}
}

View file

@ -1,32 +0,0 @@
import {UIElement} from "../UIElement";
import Translations from "../../UI/i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class CheckBox extends UIElement{
public readonly isEnabled: UIEventSource<boolean>;
private readonly _showEnabled: UIElement;
private readonly _showDisabled: UIElement;
constructor(showEnabled: string | UIElement, showDisabled: string | UIElement, data: UIEventSource<boolean> | boolean = false) {
super(undefined);
this.isEnabled =
data instanceof UIEventSource ? data : new UIEventSource(data ?? false);
this.ListenTo(this.isEnabled);
this._showEnabled = Translations.W(showEnabled);
this._showDisabled =Translations.W(showDisabled);
const self = this;
this.onClick(() => {
self.isEnabled.setData(!self.isEnabled.data);
})
}
InnerRender(): string {
if (this.isEnabled.data) {
return Translations.W(this._showEnabled).Render();
} else {
return Translations.W(this._showDisabled).Render();
}
}
}

View file

@ -16,7 +16,6 @@ export default class CheckBoxes extends InputElement<number[]> {
constructor(elements: UIElement[]) {
super(undefined);
this._elements = Utils.NoNull(elements);
this.dumbMode = false;
this.value = new UIEventSource<number[]>([])
this.ListenTo(this.value);
@ -51,7 +50,6 @@ export default class CheckBoxes extends InputElement<number[]> {
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self = this;
for (let i = 0; i < this._elements.length; i++) {

View file

@ -1,6 +1,5 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
export default class ColorPicker extends InputElement<string> {

View file

@ -1,14 +1,16 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
export default class CombinedInputElement<T> extends InputElement<T> {
protected InnerConstructElement(): HTMLElement {
return this._combined.ConstructElement();
}
private readonly _a: InputElement<T>;
private readonly _b: UIElement;
private readonly _combined: UIElement;
private readonly _b: BaseUIElement;
private readonly _combined: BaseUIElement;
public readonly IsSelected: UIEventSource<boolean>;
constructor(a: InputElement<T>, b: InputElement<T>) {
super();
this._a = a;
@ -23,11 +25,6 @@ export default class CombinedInputElement<T> extends InputElement<T> {
return this._a.GetValue();
}
InnerRender(): string {
return this._combined.Render();
}
IsValid(t: T): boolean {
return this._a.IsValid(t);
}

View file

@ -14,7 +14,6 @@ export default class DirectionInput extends InputElement<string> {
constructor(value?: UIEventSource<string>) {
super();
this.dumbMode = false;
this.value = value ?? new UIEventSource<string>(undefined);
this.value.addCallbackAndRun(rotation => {
@ -48,7 +47,6 @@ export default class DirectionInput extends InputElement<string> {
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self = this;
function onPosChange(x: number, y: number) {

View file

@ -1,50 +1,81 @@
import {UIElement} from "../UIElement";
import {InputElement} from "./InputElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export class DropDown<T> extends InputElement<T> {
private readonly _label: UIElement;
private readonly _values: { value: T; shown: UIElement }[];
private static _nextDropdownId = 0;
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _element: HTMLElement;
private readonly _value: UIEventSource<T>;
private readonly _values: { value: T; shown: string | BaseUIElement }[];
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _label_class: string;
private readonly _select_class: string;
private _form_style: string;
constructor(label: string | UIElement,
values: { value: T, shown: string | UIElement }[],
constructor(label: string | BaseUIElement,
values: { value: T, shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
label_class: string = "",
select_class: string = "",
form_style: string = "flex") {
super(undefined);
this._form_style = form_style;
this._value = value ?? new UIEventSource<T>(undefined);
this._label = Translations.W(label);
this._label_class = label_class || '';
this._select_class = select_class || '';
this._values = values.map(v => {
return {
value: v.value,
shown: Translations.W(v.shown)
options?: {
select_class?: string
}
}
);
for (const v of this._values) {
this.ListenTo(v.shown._source);
) {
super();
this._values = values;
if (values.length <= 1) {
return;
}
this.ListenTo(this._value);
this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble furter to other elements, e.g. checkboxes
const id = DropDown._nextDropdownId;
DropDown._nextDropdownId++;
const el = document.createElement("form")
this._element = el;
el.id = "dropdown" + id;
{
const labelEl = Translations.W(label).ConstructElement()
const labelHtml = document.createElement("label")
labelHtml.appendChild(labelEl)
labelHtml.htmlFor = el.id;
}
{
const select = document.createElement("select")
select.classList.add(...(options?.select_class?.split(" ") ?? []))
for (let i = 0; i < values.length; i++) {
const option = document.createElement("option")
option.value = "" + i
option.appendChild(Translations.W(values[i].shown).ConstructElement())
}
select.onchange = (() => {
var index = select.selectedIndex;
value.setData(values[index].value);
});
value.addCallbackAndRun(selected => {
for (let i = 0; i < values.length; i++) {
const value = values[i].value;
if (value === selected) {
select.selectedIndex = i;
}
}
})
}
this.onClick(() => {
}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
}
GetValue(): UIEventSource<T> {
return this._value;
}
IsValid(t: T): boolean {
for (const value of this._values) {
if (value.value === t) {
@ -54,44 +85,8 @@ export class DropDown<T> extends InputElement<T> {
return false
}
InnerRender(): string {
if(this._values.length <=1){
return "";
}
let options = "";
for (let i = 0; i < this._values.length; i++) {
options += "<option value='" + i + "'>" + this._values[i].shown.InnerRender() + "</option>"
}
return `<form class="${this._form_style}">` +
`<label class='${this._label_class}' for='dropdown-${this.id}'>${this._label.Render()}</label>` +
`<select class='${this._select_class}' name='dropdown-${this.id}' id='dropdown-${this.id}'>` +
options +
`</select>` +
`</form>`;
protected InnerConstructElement(): HTMLElement {
return this._element;
}
protected InnerUpdate(element) {
var e = document.getElementById("dropdown-" + this.id);
if(e === null){
return;
}
const self = this;
e.onchange = (() => {
// @ts-ignore
var index = parseInt(e.selectedIndex);
self._value.setData(self._values[index].value);
});
var t = this._value.data;
for (let i = 0; i < this._values.length ; i++) {
const value = this._values[i].value;
if (value === t) {
// @ts-ignore
e.selectedIndex = i;
}
}
}
}

View file

@ -1,7 +1,7 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export abstract class InputElement<T> extends UIElement{
export abstract class InputElement<T> extends BaseUIElement{
abstract GetValue() : UIEventSource<T>;
abstract IsSelected: UIEventSource<boolean>;

View file

@ -3,13 +3,12 @@ import {UIEventSource} from "../../Logic/UIEventSource";
export default class InputElementMap<T, X> extends InputElement<X> {
public readonly IsSelected: UIEventSource<boolean>;
private readonly _inputElement: InputElement<T>;
private isSame: (x0: X, x1: X) => boolean;
private readonly fromX: (x: X) => T;
private readonly toX: (t: T) => X;
private readonly _value: UIEventSource<X>;
public readonly IsSelected: UIEventSource<boolean>;
constructor(inputElement: InputElement<T>,
isSame: (x0: X, x1: X) => boolean,
@ -41,19 +40,19 @@ export default class InputElementMap<T, X> extends InputElement<X> {
return this._value;
}
InnerRender(): string {
return this._inputElement.InnerRender();
}
IsValid(x: X): boolean {
if(x === undefined){
if (x === undefined) {
return false;
}
const t = this.fromX(x);
if(t === undefined){
if (t === undefined) {
return false;
}
return this._inputElement.IsValid(t);
}
protected InnerConstructElement(): HTMLElement {
return this._inputElement.ConstructElement();
}
}

View file

@ -1,125 +0,0 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
export class MultiInput<T> extends InputElement<T[]> {
private readonly _value: UIEventSource<T[]>;
IsSelected: UIEventSource<boolean>;
private elements: UIElement[] = [];
private inputElements: InputElement<T>[] = [];
private addTag: UIElement;
private _options: { allowMovement?: boolean };
constructor(
addAElement: string,
newElement: (() => T),
createInput: (() => InputElement<T>),
value: UIEventSource<T[]> = undefined,
options?: {
allowMovement?: boolean
}) {
super(undefined);
this._value = value ?? new UIEventSource<T[]>([]);
value = this._value;
this.ListenTo(value.map((latest : T[]) => latest.length));
this._options = options ?? {};
this.addTag = new SubtleButton(Svg.addSmall_ui(), addAElement)
.SetClass("small-button")
.onClick(() => {
this.IsSelected.setData(true);
value.data.push(newElement());
value.ping();
});
const self = this;
value.map<number>((tags: string[]) => tags.length).addCallback(() => self.createElements(createInput));
this.createElements(createInput);
this._value.addCallback(tags => self.load(tags));
this.IsSelected = new UIEventSource<boolean>(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<T>)) {
this.inputElements.splice(0, this.inputElements.length);
this.elements = [];
const self = this;
for (let i = 0; i < this._value.data.length; 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 moveUpBtn = Svg.up_ui()
.SetClass('small-image').onClick(() => {
const v = self._value.data[i];
self._value.data[i] = self._value.data[i - 1];
self._value.data[i - 1] = v;
self._value.ping();
});
const moveDownBtn =
Svg.down_ui()
.SetClass('small-image') .onClick(() => {
const v = self._value.data[i];
self._value.data[i] = self._value.data[i + 1];
self._value.data[i + 1] = v;
self._value.ping();
});
const controls = [];
if (i > 0 && this._options.allowMovement) {
controls.push(moveUpBtn);
}
if (i + 1 < this._value.data.length && this._options.allowMovement) {
controls.push(moveDownBtn);
}
const deleteBtn =
Svg.delete_icon_ui().SetClass('small-image')
.onClick(() => {
self._value.data.splice(i, 1);
self._value.ping();
});
controls.push(deleteBtn);
this.elements.push(new Combine([input.SetStyle("width: calc(100% - 2em - 5px)"), new Combine(controls).SetStyle("display:flex;flex-direction:column;width:min-content;")]).SetClass("tag-input-row"))
}
this.Update();
}
InnerRender(): string {
return new Combine([...this.elements, this.addTag]).Render();
}
IsValid(t: T[]): boolean {
return false;
}
GetValue(): UIEventSource<T[]> {
return this._value;
}
}

View file

@ -1,99 +0,0 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {TextField} from "./TextField";
export default class MultiLingualTextFields extends InputElement<any> {
private _fields: Map<string, TextField> = new Map<string, TextField>();
private readonly _value: UIEventSource<any>;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
constructor(languages: UIEventSource<string[]>,
textArea: boolean = false,
value: UIEventSource<Map<string, UIEventSource<string>>> = undefined) {
super(undefined);
this._value = value ?? new UIEventSource({});
this._value.addCallbackAndRun(latestData => {
if (typeof (latestData) === "string") {
console.warn("Refusing string for multilingual input", latestData);
self._value.setData({});
}
})
const self = this;
function setup(languages: string[]) {
if (languages === undefined) {
return;
}
const newFields = new Map<string, TextField>();
for (const language of languages) {
if (language.length != 2) {
continue;
}
let oldField = self._fields.get(language);
if (oldField === undefined) {
oldField = new TextField({textArea: textArea});
oldField.GetValue().addCallback(str => {
self._value.data[language] = str;
self._value.ping();
});
oldField.GetValue().setData(self._value.data[language]);
oldField.IsSelected.addCallback(() => {
let selected = false;
self._fields.forEach(value => {selected = selected || value.IsSelected.data});
self.IsSelected.setData(selected);
})
}
newFields.set(language, oldField);
}
self._fields = newFields;
self.Update();
}
setup(languages.data);
languages.addCallback(setup);
function load(latest: any){
if(latest === undefined){
return;
}
for (const lang in latest) {
self._fields.get(lang)?.GetValue().setData(latest[lang]);
}
}
this._value.addCallback(load);
load(this._value.data);
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
this._fields.forEach(value => value.Update());
}
GetValue(): UIEventSource<Map<string, UIEventSource<string>>> {
return this._value;
}
InnerRender(): string {
let html = "";
this._fields.forEach((field, lang) => {
html += `<tr><td>${lang}</td><td>${field.Render()}</td></tr>`
})
if(html === ""){
return "Please define one or more languages"
}
return `<table>${html}</table>`;
}
IsValid(t: any): boolean {
return true;
}
}

View file

@ -1,15 +0,0 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import TagInput from "./SingleTagInput";
import {MultiInput} from "./MultiInput";
export class MultiTagInput extends MultiInput<string> {
constructor(value: UIEventSource<string[]> = new UIEventSource<string[]>([])) {
super("Add a new tag",
() => "",
() => new TagInput(),
value
);
}
}

View file

@ -1,123 +0,0 @@
import {UIElement} from "../UIElement";
import {InputElement} from "./InputElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
export class NumberField extends InputElement<number> {
private readonly value: UIEventSource<number>;
public readonly enterPressed = new UIEventSource<string>(undefined);
private readonly _placeholder: UIElement;
private options?: {
placeholder?: string | UIElement,
value?: UIEventSource<number>,
isValid?: ((i: number) => boolean),
min?: number,
max?: number
};
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _isValid: (i:number) => boolean;
constructor(options?: {
placeholder?: string | UIElement,
value?: UIEventSource<number>,
isValid?: ((i:number) => boolean),
min?: number,
max?:number
}) {
super(undefined);
this.options = options;
const self = this;
this.value = new UIEventSource<number>(undefined);
this.value = options?.value ?? new UIEventSource<number>(undefined);
this._isValid = options.isValid ?? ((i) => true);
this._placeholder = Translations.W(options.placeholder ?? "");
this.ListenTo(this._placeholder._source);
this.onClick(() => {
self.IsSelected.setData(true)
});
this.value.addCallback((t) => {
const field = document.getElementById("txt-"+this.id);
if (field === undefined || field === null) {
return;
}
field.className = self.IsValid(t) ? "" : "invalid";
if (t === undefined || t === null) {
return;
}
// @ts-ignore
field.value = t;
});
this.dumbMode = false;
}
GetValue(): UIEventSource<number> {
return this.value;
}
InnerRender(): string {
const placeholder = this._placeholder.InnerRender().replace("'", "&#39");
let min = "";
if(this.options.min){
min = `min='${this.options.min}'`;
}
let max = "";
if(this.options.min){
max = `max='${this.options.max}'`;
}
return `<span id="${this.id}"><form onSubmit='return false' class='form-text-field'>` +
`<input type='number' ${min} ${max} placeholder='${placeholder}' id='txt-${this.id}'>` +
`</form></span>`;
}
InnerUpdate() {
const field = document.getElementById("txt-" + this.id);
const self = this;
field.oninput = () => {
// How much characters are on the right, not including spaces?
// @ts-ignore
const endDistance = field.value.substring(field.selectionEnd).replace(/ /g,'').length;
// @ts-ignore
let val: number = Number(field.value);
if (!self.IsValid(val)) {
self.value.setData(undefined);
} else {
self.value.setData(val);
}
};
if (this.value.data !== undefined && this.value.data !== null) {
// @ts-ignore
field.value = this.value.data;
}
field.addEventListener("focusin", () => self.IsSelected.setData(true));
field.addEventListener("focusout", () => self.IsSelected.setData(false));
field.addEventListener("keyup", function (event) {
if (event.key === "Enter") {
// @ts-ignore
self.enterPressed.setData(field.value);
}
});
}
IsValid(t: number): boolean {
if (t === undefined || t === null) {
return false
}
return this._isValid(t);
}
}

View file

@ -5,48 +5,37 @@ export default class SimpleDatePicker extends InputElement<string> {
private readonly value: UIEventSource<string>
private readonly _element: HTMLElement;
constructor(
value?: UIEventSource<string>
) {
super();
this.value = value ?? new UIEventSource<string>(undefined);
const self = this;
const el = document.createElement("input")
this._element = el;
el.type = "date"
el.oninput = () => {
// Already in YYYY-MM-DD value!
self.value.setData(el.value);
}
this.value.addCallbackAndRun(v => {
if(v === undefined){
return;
}
self.SetValue(v);
el.value = v;
});
}
InnerRender(): string {
return `<span id="${this.id}"><input type='date' id='date-${this.id}'></span>`;
}
private SetValue(date: string){
const field = document.getElementById("date-" + this.id);
if (field === undefined || field === null) {
return;
}
// @ts-ignore
field.value = date;
}
protected InnerUpdate() {
const field = document.getElementById("date-" + this.id);
if (field === undefined || field === null) {
return;
}
const self = this;
field.oninput = () => {
// Already in YYYY-MM-DD value!
// @ts-ignore
self.value.setData(field.value);
}
}
protected InnerConstructElement(): HTMLElement {
return this._element
}
GetValue(): UIEventSource<string> {
return this.value;
}

View file

@ -1,113 +0,0 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {DropDown} from "./DropDown";
import {TextField} from "./TextField";
import Combine from "../Base/Combine";
import {Utils} from "../../Utils";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FromJSON} from "../../Customizations/JSON/FromJSON";
import ValidatedTextField from "./ValidatedTextField";
export default class SingleTagInput extends InputElement<string> {
private readonly _value: UIEventSource<string>;
IsSelected: UIEventSource<boolean>;
private key: InputElement<string>;
private value: InputElement<string>;
private operator: DropDown<string>
private readonly helpMessage: UIElement;
constructor(value: UIEventSource<string> = undefined) {
super(undefined);
this._value = value ?? new UIEventSource<string>("");
this.helpMessage = new VariableUiElement(this._value.map(tagDef => {
try {
FromJSON.Tag(tagDef, "");
return "";
} catch (e) {
return `<br/><span class='alert'>${e}</span>`
}
}
));
this.key = ValidatedTextField.KeyInput();
this.value = new TextField({
placeholder: "value - if blank, matches if key is NOT present",
value: new UIEventSource<string>("")
}
);
this.operator = new DropDown<string>("", [
{value: "=", shown: "="},
{value: "~", shown: "~"},
{value: "!~", shown: "!~"}
]);
this.operator.GetValue().setData("=");
const self = this;
function updateValue() {
if (self.key.GetValue().data === undefined ||
self.value.GetValue().data === undefined ||
self.operator.GetValue().data === undefined) {
return undefined;
}
self._value.setData(self.key.GetValue().data + self.operator.GetValue().data + self.value.GetValue().data);
}
this.key.GetValue().addCallback(() => updateValue());
this.operator.GetValue().addCallback(() => updateValue());
this.value.GetValue().addCallback(() => updateValue());
function loadValue(value: string) {
if (value === undefined) {
return;
}
let parts: string[];
if (value.indexOf("=") >= 0) {
parts = Utils.SplitFirst(value, "=");
self.operator.GetValue().setData("=");
} else if (value.indexOf("!~") > 0) {
parts = Utils.SplitFirst(value, "!~");
self.operator.GetValue().setData("!~");
} else if (value.indexOf("~") > 0) {
parts = Utils.SplitFirst(value, "~");
self.operator.GetValue().setData("~");
} else {
console.warn("Invalid value for tag: ", value)
return;
}
self.key.GetValue().setData(parts[0]);
self.value.GetValue().setData(parts[1]);
}
self._value.addCallback(loadValue);
loadValue(self._value.data);
this.IsSelected = this.key.IsSelected.map(
isSelected => isSelected || this.value.IsSelected.data, [this.value.IsSelected]
)
}
IsValid(t: string): boolean {
return false;
}
InnerRender(): string {
return new Combine([
this.key, this.operator, this.value,
this.helpMessage
]).Render();
}
GetValue(): UIEventSource<string> {
return this._value;
}
}

View file

@ -1,99 +1,85 @@
import {UIElement} from "../UIElement";
import {InputElement} from "./InputElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import BaseUIElement from "../BaseUIElement";
export class TextField extends InputElement<string> {
private readonly value: UIEventSource<string>;
public readonly enterPressed = new UIEventSource<string>(undefined);
private readonly _placeholder: UIElement;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _htmlType: string;
private readonly _inputMode : string;
private readonly _textAreaRows: number;
private readonly _isValid: (string,country) => boolean;
private _label: UIElement;
private _element: HTMLElement;
private readonly _isValid: (s: string, country?: () => string) => boolean;
constructor(options?: {
placeholder?: string | UIElement,
placeholder?: string | BaseUIElement,
value?: UIEventSource<string>,
textArea?: boolean,
htmlType?: string,
inputMode?: string,
label?: UIElement,
label?: BaseUIElement,
textAreaRows?: number,
isValid?: ((s: string, country?: () => string) => boolean)
}) {
super(undefined);
super();
const self = this;
this.value = new UIEventSource<string>("");
options = options ?? {};
this._htmlType = options.textArea ? "area" : (options.htmlType ?? "text");
this.value = options?.value ?? new UIEventSource<string>(undefined);
this._label = options.label;
this._textAreaRows = options.textAreaRows;
this._isValid = options.isValid ?? ((str, country) => true);
this._placeholder = Translations.W(options.placeholder ?? "");
this._inputMode = options.inputMode;
this.ListenTo(this._placeholder._source);
this._isValid = options.isValid ?? (_ => true);
this.onClick(() => {
self.IsSelected.setData(true)
});
this.value.addCallback((t) => {
const field = document.getElementById("txt-"+this.id);
if (field === undefined || field === null) {
return;
}
field.className = self.IsValid(t) ? "" : "invalid";
if (t === undefined || t === null) {
const placeholder = Translations.W(options. placeholder ?? "").ConstructElement().innerText.replace("'", "&#39");
this.SetClass("form-text-field")
let inputEl : HTMLElement
if(options.htmlType === "area"){
const el = document.createElement("textarea")
el.placeholder = placeholder
el.rows = options.textAreaRows
el.cols = 50
el.style.cssText = "max-width: 100%; width: 100%; box-sizing: border-box"
inputEl = el;
}else{
const el = document.createElement("input")
el.type = options.htmlType
el.inputMode = options.inputMode
el.placeholder = placeholder
inputEl = el
}
const form = document.createElement("form")
form.onsubmit = () => false;
if(options.label){
form.appendChild(options.label.ConstructElement())
}
this._element = form;
const field = inputEl;
this.value.addCallbackAndRun(value => {
if (!(value !== undefined && value !== null)) {
return;
}
// @ts-ignore
field.value = t;
});
this.dumbMode = false;
}
field.value = value;
if(self.IsValid(value)){
self.RemoveClass("invalid")
}else{
self.SetClass("invalid")
}
GetValue(): UIEventSource<string> {
return this.value;
}
})
InnerRender(): string {
const placeholder = this._placeholder.InnerRender().replace("'", "&#39");
if (this._htmlType === "area") {
return `<span id="${this.id}"><textarea id="txt-${this.id}" placeholder='${placeholder}' class="form-text-field" rows="${this._textAreaRows}" cols="50" style="max-width: 100%; width: 100%; box-sizing: border-box"></textarea></span>`
}
let label = "";
if (this._label != undefined) {
label = this._label.Render();
}
let inputMode = ""
if(this._inputMode !== undefined){
inputMode = `inputmode="${this._inputMode}" `
}
return new Combine([
`<span id="${this.id}">`,
`<form onSubmit='return false' class='form-text-field'>`,
label,
`<input type='${this._htmlType}' ${inputMode} placeholder='${placeholder}' id='txt-${this.id}'/>`,
`</form>`,
`</span>`
]).Render();
}
InnerUpdate() {
const field = document.getElementById("txt-" + this.id);
const self = this;
field.oninput = () => {
// How much characters are on the right, not including spaces?
// @ts-ignore
const endDistance = field.value.substring(field.selectionEnd).replace(/ /g,'').length;
@ -107,11 +93,11 @@ export class TextField extends InputElement<string> {
// Setting the value might cause the value to be set again. We keep the distance _to the end_ stable, as phone number formatting might cause the start to change
// See https://github.com/pietervdvn/MapComplete/issues/103
// We reread the field value - it might have changed!
// @ts-ignore
val = field.value;
let newCursorPos = val.length - endDistance;
while(newCursorPos >= 0 &&
while(newCursorPos >= 0 &&
// We count the number of _actual_ characters (non-space characters) on the right of the new value
// This count should become bigger then the end distance
val.substr(newCursorPos).replace(/ /g, '').length < endDistance
@ -119,14 +105,10 @@ export class TextField extends InputElement<string> {
newCursorPos --;
}
// @ts-ignore
self.SetCursorPosition(newCursorPos);
TextField.SetCursorPosition(newCursorPos);
};
if (this.value.data !== undefined && this.value.data !== null) {
// @ts-ignore
field.value = this.value.data;
}
field.addEventListener("focusin", () => self.IsSelected.setData(true));
field.addEventListener("focusout", () => self.IsSelected.setData(false));
@ -136,22 +118,31 @@ export class TextField extends InputElement<string> {
// @ts-ignore
self.enterPressed.setData(field.value);
}
});
});
}
public SetCursorPosition(i: number) {
const field = document.getElementById('txt-' + this.id);
if(field === undefined || field === null){
GetValue(): UIEventSource<string> {
return this.value;
}
protected InnerConstructElement(): HTMLElement {
return this._element;
}
private static SetCursorPosition(textfield: HTMLElement, i: number) {
if(textfield === undefined || textfield === null){
return;
}
if (i === -1) {
// @ts-ignore
i = field.value.length;
i = textfield.value.length;
}
field.focus();
textfield.focus();
// @ts-ignore
field.setSelectionRange(i, i);
textfield.setSelectionRange(i, i);
}

22
UI/Input/Toggle.ts Normal file
View file

@ -0,0 +1,22 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
/**
* The 'Toggle' is a UIElement showing either one of two elements, depending on the state.
* It can be used to implement e.g. checkboxes or collapsible elements
*/
export default class Toggle extends VariableUiElement{
public readonly isEnabled: UIEventSource<boolean>;
constructor(showEnabled: string | BaseUIElement, showDisabled: string | BaseUIElement, data: UIEventSource<boolean> = new UIEventSource<boolean>(false)) {
super(
data.map(isEnabled => isEnabled ? showEnabled : showDisabled)
);
this.onClick(() => {
data.setData(!data.data);
})
}
}

View file

@ -1,8 +1,6 @@
import {UIElement} from "./UIElement";
import {DropDown} from "./Input/DropDown";
import Locale from "./i18n/Locale";
import Svg from "../Svg";
import Img from "./Base/Img";
export default class LanguagePicker {
@ -18,7 +16,7 @@ export default class LanguagePicker {
return new DropDown(label, languages.map(lang => {
return {value: lang, shown: lang}
}
), Locale.language, '', 'bg-indigo-100 p-1 rounded hover:bg-indigo-200');
), Locale.language, { select_class: 'bg-indigo-100 p-1 rounded hover:bg-indigo-200'});
}

View file

@ -13,8 +13,8 @@ export default class MapControlButton extends UIElement {
this.SetStyle("box-shadow: 0 0 10px var(--shadow-color);");
}
InnerRender(): string {
return this._contents.Render();
InnerRender() {
return this._contents;
}
}

View file

@ -9,21 +9,28 @@ import Constants from "../../Models/Constants";
import opening_hours from "opening_hours";
export default class OpeningHoursVisualization extends UIElement {
private static readonly weekdays = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday,
]
private readonly _key: string;
constructor(tags: UIEventSource<any>, key: string) {
super(tags);
this._key = key;
this.ListenTo(UIEventSource.Chronic(60*1000)); // Automatically reload every minute
this.ListenTo(UIEventSource.Chronic(60 * 1000)); // Automatically reload every minute
this.ListenTo(UIEventSource.Chronic(500, () => {
return tags.data._country === undefined;
}));
}
}
private static GetRanges(oh: any, from: Date, to: Date): ({
isOpen: boolean,
isSpecial: boolean,
@ -38,7 +45,7 @@ export default class OpeningHoursVisualization extends UIElement {
const start = new Date(from);
// We go one day more into the past, in order to force rendering of holidays in the start of the period
start.setDate(from.getDate() - 1);
const iterator = oh.getIterator(start);
let prevValue = undefined;
@ -63,8 +70,8 @@ export default class OpeningHoursVisualization extends UIElement {
// simply closed, nothing special here
continue;
}
if(value.startDate < from){
if (value.startDate < from) {
continue;
}
// Get day: sunday is 0, monday is 1. We move everything so that monday == 0
@ -80,8 +87,190 @@ export default class OpeningHoursVisualization extends UIElement {
return new Date(d.setDate(diff));
}
InnerRender(): string | UIElement {
private allChangeMoments(ranges: {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastMonday = OpeningHoursVisualization.getMonday(today);
const nextSunday = new Date(lastMonday);
nextSunday.setDate(nextSunday.getDate() + 7);
const tags = this._source.data;
if (tags._country === undefined) {
return "Loading country information...";
}
let oh = null;
try {
// noinspection JSPotentiallyInvalidConstructorUsage
oh = new opening_hours(tags[this._key], {
lat: tags._lat,
lon: tags._lon,
address: {
country_code: tags._country
}
}, {tag_key: this._key});
} catch (e) {
console.log(e);
return new Combine([Translations.t.general.opening_hours.error_loading,
State.state?.osmConnection?.userDetails?.data?.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked ?
`<span class='subtle'>${e}</span>`
: ""
]);
}
if (!oh.getState() && !oh.getUnknown()) {
// POI is currently closed
const nextChange: Date = oh.getNextChange();
if (
// Shop isn't gonna open anymore in this timerange
nextSunday < nextChange
// And we are already in the weekend to show next week
&& (today.getDay() == 0 || today.getDay() == 6)
) {
// We mover further along
lastMonday.setDate(lastMonday.getDate() + 7);
nextSunday.setDate(nextSunday.getDate() + 7);
}
}
// ranges[0] are all ranges for monday
const ranges = OpeningHoursVisualization.GetRanges(oh, lastMonday, nextSunday);
if (ranges.map(r => r.length).reduce((a, b) => a + b, 0) == 0) {
// Closed!
const opensAtDate = oh.getNextChange();
if (opensAtDate === undefined) {
const comm = oh.getComment() ?? oh.getUnknown();
if (!!comm) {
return new FixedUiElement(comm).SetClass("ohviz-closed");
}
if (oh.getState()) {
return Translations.t.general.opening_hours.open_24_7.SetClass("ohviz-closed")
}
return Translations.t.general.opening_hours.closed_permanently.SetClass("ohviz-closed")
}
const moment = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}`
return Translations.t.general.opening_hours.closed_until.Subs({date: moment}).SetClass("ohviz-closed")
}
const isWeekstable = oh.isWeekStable();
let [changeHours, changeHourText] = OpeningHoursVisualization.allChangeMoments(ranges);
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
const earliestOpen = Math.min(8 * 60 * 60, ...changeHours);
let latestclose = Math.max(...changeHours);
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60)
const rows: UIElement[] = [];
const availableArea = latestclose - earliestOpen;
// @ts-ignore
const now = (100 * (((new Date() - today) / 1000) - earliestOpen)) / availableArea;
let header: UIElement[] = [];
if (now >= 0 && now <= 100) {
header.push(new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now"))
}
for (const changeMoment of changeHours) {
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
if (offset < 0 || offset > 100) {
continue;
}
const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line");
header.push(el);
}
for (let i = 0; i < changeHours.length; i++) {
let changeMoment = changeHours[i];
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
if (offset < 0 || offset > 100) {
continue;
}
const el = new FixedUiElement(
`<div style='margin-top: ${i % 2 == 0 ? '1.5em;' : "1%"}'>${changeHourText[i]}</div>`
)
.SetStyle(`left:${offset}%`)
.SetClass("ohviz-time-indication");
header.push(el);
}
rows.push(new Combine([`<td width="5%">&NonBreakingSpace;</td>`,
`<td style="position:relative;height:2.5em;">`,
new Combine(header), `</td>`]));
for (let i = 0; i < 7; i++) {
const dayRanges = ranges[i];
const isToday = (new Date().getDay() + 6) % 7 === i;
let weekday = OpeningHoursVisualization.weekdays[i];
let dateToShow = ""
if (!isWeekstable) {
const day = new Date(lastMonday)
day.setDate(day.getDate() + i);
dateToShow = "" + day.getDate() + "/" + (day.getMonth() + 1);
}
let innerContent: (string | UIElement)[] = [];
// Add the lines
for (const changeMoment of changeHours) {
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
innerContent.push(new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line"))
}
// Add the actual ranges
for (const range of dayRanges) {
if (!range.isOpen && !range.isSpecial) {
innerContent.push(
new FixedUiElement(range.comment ?? dateToShow).SetClass("ohviz-day-off"))
continue;
}
const startOfDay: Date = new Date(range.startDate);
startOfDay.setHours(0, 0, 0, 0);
// @ts-ignore
const startpoint = (range.startDate - startOfDay) / 1000 - earliestOpen;
// @ts-ignore
const width = (100 * (range.endDate - range.startDate) / 1000) / (latestclose - earliestOpen);
const startPercentage = (100 * startpoint / availableArea);
innerContent.push(
new FixedUiElement(range.comment ?? dateToShow).SetStyle(`left:${startPercentage}%; width:${width}%`).SetClass("ohviz-range"))
}
// Add line for 'now'
if (now >= 0 && now <= 100) {
innerContent.push(new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now"))
}
let clss = ""
if (isToday) {
clss = "ohviz-today"
}
rows.push(new Combine(
[`<td class="ohviz-weekday ${clss}">${weekday}</td>`,
`<td style="position:relative;" class="${clss}">`,
...innerContent,
`</td>`]))
}
return new Combine([
"<table class='ohviz' style='width:100%; word-break: normal; word-wrap: normal'>",
...rows.map(el => "<tr>" + el.Render() + "</tr>"),
"</table>"
]).SetClass("ohviz-container");
}
private static allChangeMoments(ranges: {
isOpen: boolean,
isSpecial: boolean,
comment: string,
@ -131,194 +320,4 @@ export default class OpeningHoursVisualization extends UIElement {
return [changeHours, changeHourText]
}
private static readonly weekdays = [
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
Translations.t.general.weekdays.abbreviations.wednesday,
Translations.t.general.weekdays.abbreviations.thursday,
Translations.t.general.weekdays.abbreviations.friday,
Translations.t.general.weekdays.abbreviations.saturday,
Translations.t.general.weekdays.abbreviations.sunday,
]
InnerRender(): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastMonday = OpeningHoursVisualization.getMonday(today);
const nextSunday = new Date(lastMonday);
nextSunday.setDate(nextSunday.getDate() + 7);
const tags = this._source.data;
if (tags._country === undefined) {
return "Loading country information...";
}
let oh = null;
try {
oh = new opening_hours(tags[this._key], {
lat: tags._lat,
lon: tags._lon,
address: {
country_code: tags._country
}
}, {tag_key: this._key});
} catch (e) {
console.log(e);
const msg = new Combine([Translations.t.general.opening_hours.error_loading,
State.state?.osmConnection?.userDetails?.data?.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked ?
`<span class='subtle'>${e}</span>`
: ""
]);
return msg.Render();
}
if (!oh.getState() && !oh.getUnknown()) {
// POI is currently closed
const nextChange: Date = oh.getNextChange();
if (
// Shop isn't gonna open anymore in this timerange
nextSunday < nextChange
// And we are already in the weekend to show next week
&& (today.getDay() == 0 || today.getDay() == 6)
) {
// We mover further along
lastMonday.setDate(lastMonday.getDate() + 7);
nextSunday.setDate(nextSunday.getDate() + 7);
}
}
// ranges[0] are all ranges for monday
const ranges = OpeningHoursVisualization.GetRanges(oh, lastMonday, nextSunday);
if (ranges.map(r => r.length).reduce((a, b) => a + b, 0) == 0) {
// Closed!
const opensAtDate = oh.getNextChange();
if(opensAtDate === undefined){
const comm = oh.getComment() ?? oh.getUnknown();
if(!!comm){
return new FixedUiElement(comm).SetClass("ohviz-closed").Render();
}
if(oh.getState()){
return Translations.t.general.opening_hours.open_24_7.SetClass("ohviz-closed").Render()
}
return Translations.t.general.opening_hours.closed_permanently.SetClass("ohviz-closed").Render()
}
const moment = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}`
return Translations.t.general.opening_hours.closed_until.Subs({date: moment}).SetClass("ohviz-closed").Render()
}
const isWeekstable = oh.isWeekStable();
let [changeHours, changeHourText] = this.allChangeMoments(ranges);
// By default, we always show the range between 8 - 19h, in order to give a stable impression
// Ofc, a bigger range is used if needed
const earliestOpen = Math.min(8 * 60 * 60, ...changeHours);
let latestclose = Math.max(...changeHours);
// We always make sure there is 30m of leeway in order to give enough room for the closing entry
latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60)
const rows: UIElement[] = [];
const availableArea = latestclose - earliestOpen;
// @ts-ignore
const now = (100 * (((new Date() - today) / 1000) - earliestOpen)) / availableArea;
let header = "";
if (now >= 0 && now <= 100) {
header += new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render()
}
for (const changeMoment of changeHours) {
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
if (offset < 0 || offset > 100) {
continue;
}
const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line").Render();
header += el;
}
for (let i = 0; i < changeHours.length; i++) {
let changeMoment = changeHours[i];
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
if (offset < 0 || offset > 100) {
continue;
}
const el = new FixedUiElement(
`<div style='margin-top: ${i % 2 == 0 ? '1.5em;' : "1%"}'>${changeHourText[i]}</div>`
)
.SetStyle(`left:${offset}%`)
.SetClass("ohviz-time-indication").Render();
header += el;
}
rows.push(new Combine([`<td width="5%">&NonBreakingSpace;</td>`,
`<td style="position:relative;height:2.5em;">${header}</td>`]));
for (let i = 0; i < 7; i++) {
const dayRanges = ranges[i];
const isToday = (new Date().getDay() + 6) % 7 === i;
let weekday = OpeningHoursVisualization.weekdays[i].Render();
let dateToShow = ""
if (!isWeekstable) {
const day = new Date(lastMonday)
day.setDate(day.getDate() + i);
dateToShow = "" + day.getDate() + "/" + (day.getMonth() + 1);
}
let innerContent: string[] = [];
// Add the lines
for (const changeMoment of changeHours) {
const offset = 100 * (changeMoment - earliestOpen) / availableArea;
innerContent.push(new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line").Render())
}
// Add the actual ranges
for (const range of dayRanges) {
if (!range.isOpen && !range.isSpecial) {
innerContent.push(
new FixedUiElement(range.comment ?? dateToShow).SetClass("ohviz-day-off").Render())
continue;
}
const startOfDay: Date = new Date(range.startDate);
startOfDay.setHours(0, 0, 0, 0);
// @ts-ignore
const startpoint = (range.startDate - startOfDay) / 1000 - earliestOpen;
// @ts-ignore
const width = (100 * (range.endDate - range.startDate) / 1000) / (latestclose - earliestOpen);
const startPercentage = (100 * startpoint / availableArea);
innerContent.push(
new FixedUiElement(range.comment ?? dateToShow).SetStyle(`left:${startPercentage}%; width:${width}%`).SetClass("ohviz-range").Render())
}
// Add line for 'now'
if (now >= 0 && now <= 100) {
innerContent.push(new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render())
}
let clss = ""
if (isToday) {
clss = "ohviz-today"
}
rows.push(new Combine(
[`<td class="ohviz-weekday ${clss}">${weekday}</td>`,
`<td style="position:relative;" class="${clss}">${innerContent.join("")}</td>`]))
}
return new Combine([
"<table class='ohviz' style='width:100%; word-break: normal; word-wrap: normal'>",
rows.map(el => "<tr>" + el.Render() + "</tr>").join(""),
"</table>"
]).SetClass("ohviz-container").Render();
}
}

View file

@ -5,7 +5,6 @@
*/
import OpeningHoursPicker from "./OpeningHoursPicker";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
@ -14,21 +13,20 @@ import {InputElement} from "../Input/InputElement";
import PublicHolidayInput from "./PublicHolidayInput";
import Translations from "../i18n/Translations";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class OpeningHoursInput extends InputElement<string> {
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _value: UIEventSource<string>;
private readonly _ohPicker: UIElement;
private readonly _leftoverWarning: UIElement;
private readonly _phSelector: UIElement;
private readonly _element: BaseUIElement;
constructor(value: UIEventSource<string> = new UIEventSource<string>("")) {
super();
const leftoverRules = value.map<string[]>(str => {
if (str === undefined) {
return []
@ -61,11 +59,11 @@ export default class OpeningHoursInput extends InputElement<string> {
}
return "";
})
this._phSelector = new PublicHolidayInput(ph);
const phSelector = new PublicHolidayInput(ph);
function update() {
const regular = OH.ToString(rulesFromOhPicker.data);
const rules : string[] = [
const rules: string[] = [
regular,
...leftoverRules.data,
ph.data
@ -76,39 +74,35 @@ export default class OpeningHoursInput extends InputElement<string> {
rulesFromOhPicker.addCallback(update);
ph.addCallback(update);
this._leftoverWarning = new VariableUiElement(leftoverRules.map((leftovers: string[]) => {
const leftoverWarning = new VariableUiElement(leftoverRules.map((leftovers: string[]) => {
if (leftovers.length == 0) {
return "";
}
return new Combine([
Translations.t.general.opening_hours.not_all_rules_parsed,
new FixedUiElement(leftovers.map(r => `${r}<br/>`).join("")).SetClass("subtle")
]).Render();
new FixedUiElement(leftovers.map(r => `${r}<br/>`).join("")).SetClass("subtle")
]);
}))
this._ohPicker = new OpeningHoursPicker(rulesFromOhPicker);
const ohPicker = new OpeningHoursPicker(rulesFromOhPicker);
this._element = new Combine([
leftoverWarning,
ohPicker,
phSelector
])
}
protected InnerConstructElement(): HTMLElement {
return this._element.ConstructElement()
}
GetValue(): UIEventSource<string> {
return this._value;
}
InnerRender(): string {
return new Combine([
this._leftoverWarning,
this._ohPicker,
this._phSelector
]).Render();
}
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: string): boolean {
return true;
}

View file

@ -5,6 +5,7 @@ import Combine from "../Base/Combine";
import OpeningHoursPickerTable from "./OpeningHoursPickerTable";
import {OH, OpeningHour} from "./OpeningHours";
import {InputElement} from "../Input/InputElement";
import BaseUIElement from "../BaseUIElement";
export default class OpeningHoursPicker extends InputElement<OpeningHour[]> {
private readonly _ohs: UIEventSource<OpeningHour[]>;
@ -12,7 +13,7 @@ export default class OpeningHoursPicker extends InputElement<OpeningHour[]> {
private readonly _backgroundTable: OpeningHoursPickerTable;
private readonly _weekdays: UIEventSource<UIElement[]> = new UIEventSource<UIElement[]>([]);
private readonly _weekdays: UIEventSource<BaseUIElement[]> = new UIEventSource<BaseUIElement[]>([]);
constructor(ohs: UIEventSource<OpeningHour[]> = new UIEventSource<OpeningHour[]>([])) {
super();
@ -49,8 +50,12 @@ export default class OpeningHoursPicker extends InputElement<OpeningHour[]> {
}
InnerRender(): string {
return this._backgroundTable.Render();
InnerRender(): BaseUIElement {
return this._backgroundTable;
}
protected InnerConstructElement(): HTMLElement {
return this._backgroundTable.ConstructElement();
}
GetValue(): UIEventSource<OpeningHour[]> {

View file

@ -8,12 +8,13 @@ import {Utils} from "../../Utils";
import {OpeningHour} from "./OpeningHours";
import {InputElement} from "../Input/InputElement";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
export default class OpeningHoursPickerTable extends InputElement<OpeningHour[]> {
public readonly IsSelected: UIEventSource<boolean>;
private readonly weekdays: UIEventSource<UIElement[]>;
private readonly weekdays: UIEventSource<BaseUIElement[]>;
public static readonly days: UIElement[] =
public static readonly days: BaseUIElement[] =
[
Translations.t.general.weekdays.abbreviations.monday,
Translations.t.general.weekdays.abbreviations.tuesday,
@ -28,8 +29,8 @@ export default class OpeningHoursPickerTable extends InputElement<OpeningHour[]>
private readonly source: UIEventSource<OpeningHour[]>;
constructor(weekdays: UIEventSource<UIElement[]>, source?: UIEventSource<OpeningHour[]>) {
super(weekdays);
constructor(weekdays: UIEventSource<BaseUIElement[]>, source?: UIEventSource<OpeningHour[]>) {
super();
this.weekdays = weekdays;
this.source = source ?? new UIEventSource<OpeningHour[]>([]);
this.IsSelected = new UIEventSource<boolean>(false);

View file

@ -48,10 +48,10 @@ export default class OpeningHoursRange extends UIElement {
}
InnerRender(): string {
InnerRender(): UIElement {
const oh = this._oh.data;
if (oh === undefined) {
return "";
return undefined;
}
const height = this.getHeight();
@ -62,7 +62,6 @@ export default class OpeningHoursRange extends UIElement {
return new Combine(content)
.SetClass("oh-timerange-inner")
.Render();
}
private getHeight(): number {

View file

@ -143,7 +143,7 @@ export default class PublicHolidayInput extends InputElement<string> {
}
}
InnerRender(): string {
InnerRender(): UIElement {
const mode = this._mode.data;
if (mode === " ") {
return new Combine([this._dropdown,
@ -154,9 +154,9 @@ export default class PublicHolidayInput extends InputElement<string> {
" ",
Translations.t.general.opening_hours.openTill,
" ",
this._endHour]).Render();
this._endHour]);
}
return this._dropdown.Render();
return this._dropdown;
}
GetValue(): UIEventSource<string> {

View file

@ -39,7 +39,6 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
const titleIcons = new Combine(
layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon,
"block w-8 h-8 align-baseline box-content sm:p-0.5", "width: 2rem !important;")
.HideOnEmpty(true)
))
.SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2")

View file

@ -48,7 +48,7 @@ export default class QuestionBox extends UIElement {
this.SetClass("block mb-8")
}
InnerRender(): string {
InnerRender() {
const allQuestions : UIElement[] = []
for (let i = 0; i < this._tagRenderingQuestions.length; i++) {
let tagRendering = this._tagRenderings[i];
@ -72,7 +72,7 @@ export default class QuestionBox extends UIElement {
}
return new Combine(allQuestions).Render();
return new Combine(allQuestions);
}
}

View file

@ -21,15 +21,15 @@ export class SaveButton extends UIElement {
.onClick(() => osmConnection?.AttemptLogin())
}
InnerRender(): string {
InnerRender() {
if(this._userDetails != undefined && !this._userDetails.data.loggedIn){
return this._friendlyLogin.Render();
return this._friendlyLogin;
}
let inactive_class = ''
if (this._value.data === false || (this._value.data ?? "") === "") {
inactive_class = "btn-disabled";
}
return Translations.t.general.save.Clone().SetClass(`btn ${inactive_class}`).Render();
return Translations.t.general.save.Clone().SetClass(`btn ${inactive_class}`);
}
}

View file

@ -30,7 +30,7 @@ export default class TagRenderingAnswer extends UIElement {
this.SetStyle("word-wrap: anywhere;");
}
InnerRender(): string {
InnerRender(): string | UIElement{
if (this._configuration.condition !== undefined) {
if (!this._configuration.condition.matchesProperties(this._tags.data)) {
return "";
@ -80,14 +80,14 @@ export default class TagRenderingAnswer extends UIElement {
])
}
return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle).Render();
return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle);
}
}
const tr = this._configuration.GetRenderValue(tags);
if (tr !== undefined) {
this._content = SubstitutedTranslation.construct(tr, this._tags);
return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle).Render();
return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle);
}
return "";

View file

@ -94,7 +94,7 @@ export default class TagRenderingQuestion extends UIElement {
).SetClass("block")
}
InnerRender(): string {
InnerRender() {
return new Combine([
this._question,
this._inputElement,
@ -103,7 +103,6 @@ export default class TagRenderingQuestion extends UIElement {
this._appliedTags]
)
.SetClass("question")
.Render()
}
private GenerateInputElement(): InputElement<TagsFilter> {

View file

@ -26,7 +26,7 @@ export default class ReviewElement extends UIElement {
InnerRender(): string {
InnerRender(): UIElement {
const elements = [];
const revs = this._reviews.data;
@ -56,7 +56,7 @@ export default class ReviewElement extends UIElement {
.SetClass("review-attribution"))
return new Combine(elements).SetClass("block").Render();
return new Combine(elements).SetClass("block");
}
}

View file

@ -86,10 +86,10 @@ export default class ReviewForm extends InputElement<Review> {
return this._value;
}
InnerRender(): string {
InnerRender(): UIElement {
if(!this.userDetails.data.loggedIn){
return Translations.t.reviews.plz_login.Render();
return Translations.t.reviews.plz_login;
}
return new Combine([
@ -103,7 +103,6 @@ export default class ReviewForm extends InputElement<Review> {
Translations.t.reviews.tos.SetClass("subtle")
])
.SetClass("review-form")
.Render();
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);

View file

@ -26,7 +26,7 @@ export default class SingleReview extends UIElement{
scoreTen % 2 == 1 ? "<img src='./assets/svg/star_half.svg' class='h-8 md:h-12'/>" : ""
]).SetClass("flex w-max")
}
InnerRender(): string {
InnerRender(): UIElement {
const d = this._review.date;
let review = this._review;
const el= new Combine(
@ -51,7 +51,7 @@ export default class SingleReview extends UIElement{
if(review.made_by_user.data){
el.SetClass("border-attention-catch")
}
return el.Render();
return el;
}
}

View file

@ -6,11 +6,8 @@ import Combine from "./Base/Combine";
import State from "../State";
import {FixedUiElement} from "./Base/FixedUiElement";
import SpecialVisualizations from "./SpecialVisualizations";
import {Utils} from "../Utils";
export class SubstitutedTranslation extends UIElement {
private static cachedTranslations:
Map<string, Map<Translation, Map<UIEventSource<any>, SubstitutedTranslation>>> = new Map<string, Map<Translation, Map<UIEventSource<any>, SubstitutedTranslation>>>();
private readonly tags: UIEventSource<any>;
private readonly translation: Translation;
private content: UIElement[];
@ -37,39 +34,24 @@ export class SubstitutedTranslation extends UIElement {
public static construct(
translation: Translation,
tags: UIEventSource<any>): SubstitutedTranslation {
/* let cachedTranslations = Utils.getOrSetDefault(SubstitutedTranslation.cachedTranslations, SubstitutedTranslation.GenerateSubCache);
const innerMap = Utils.getOrSetDefault(cachedTranslations, translation, SubstitutedTranslation.GenerateMap);
const cachedTranslation = innerMap.get(tags);
if (cachedTranslation !== undefined) {
return cachedTranslation;
}*/
const st = new SubstitutedTranslation(translation, tags);
// innerMap.set(tags, st);
return st;
return new SubstitutedTranslation(translation, tags);
}
public static SubstituteKeys(txt: string, tags: any) {
for (const key in tags) {
if(!tags.hasOwnProperty(key)) {
continue
}
txt = txt.replace(new RegExp("{" + key + "}", "g"), tags[key])
}
return txt;
}
private static GenerateMap() {
return new Map<UIEventSource<any>, SubstitutedTranslation>()
}
private static GenerateSubCache() {
return new Map<Translation, Map<UIEventSource<any>, SubstitutedTranslation>>();
}
InnerRender(): string {
InnerRender() {
if (this.content.length == 1) {
return this.content[0].Render();
return this.content[0];
}
return new Combine(this.content).Render();
return new Combine(this.content);
}
private CreateContent(): UIElement[] {
@ -118,11 +100,11 @@ export class SubstitutedTranslation extends UIElement {
}
// Let's to a small sanity check to help the theme designers:
if(template.search(/{[^}]+\([^}]*\)}/) >= 0){
if (template.search(/{[^}]+\([^}]*\)}/) >= 0) {
// Hmm, we might have found an invalid rendering name
console.warn("Found a suspicious special rendering value in: ", template, " did you mean one of: ", SpecialVisualizations.specialVisualizations.map(sp => sp.funcName+"()").join(", "))
console.warn("Found a suspicious special rendering value in: ", template, " did you mean one of: ", SpecialVisualizations.specialVisualizations.map(sp => sp.funcName + "()").join(", "))
}
// IF we end up here, no changes have to be made - except to remove any resting {}
return [new FixedUiElement(template.replace(/{.*}/g, ""))];
}

View file

@ -1,25 +1,19 @@
import {UIEventSource} from "../Logic/UIEventSource";
import {Utils} from "../Utils";
import BaseUIElement from "./BaseUIElement";
export abstract class UIElement extends UIEventSource<string> {
export abstract class UIElement extends BaseUIElement{
private static nextId: number = 0;
public readonly id: string;
public readonly _source: UIEventSource<any>;
public dumbMode = false;
private clss: Set<string> = new Set<string>();
private style: string;
private _hideIfEmpty = false;
private lastInnerRender: string;
private _onClick: () => void;
private _onHover: UIEventSource<boolean>;
protected constructor(source: UIEventSource<any> = undefined) {
super("");
this.id = "ui-element-" + UIElement.nextId;
super()
this.id = `ui-${this.constructor.name}-${UIElement.nextId}`;
this._source = source;
UIElement.nextId++;
this.dumbMode = true;
this.ListenTo(source);
}
@ -27,183 +21,97 @@ export abstract class UIElement extends UIEventSource<string> {
if (source === undefined) {
return this;
}
this.dumbMode = false;
const self = this;
source.addCallback(() => {
self.lastInnerRender = undefined;
self.Update();
if(self._constructedHtmlElement !== undefined){
self.UpdateElement(self._constructedHtmlElement);
}
})
return this;
}
public onClick(f: (() => void)) {
this.dumbMode = false;
this._onClick = f;
this.SetClass("clickable")
this.Update();
return this;
}
public IsHovered(): UIEventSource<boolean> {
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<boolean>(false);
return this._onHover;
}
Update(): void {
if (Utils.runningFromConsole) {
return;
}
let element = document.getElementById(this.id);
if (element === undefined || element === null) {
// The element is not painted or, in the case of 'dumbmode' this UI-element is not explicitely present
if (this.dumbMode) {
// We update all the children anyway
this.UpdateAllChildren();
}
return;
}
const newRender = this.InnerRender();
if (newRender !== this.lastInnerRender) {
this.lastInnerRender = newRender;
this.setData(this.InnerRender());
element.innerHTML = this.data;
}
if (this._hideIfEmpty) {
if (element.innerHTML === "") {
element.parentElement.style.display = "none";
} else {
element.parentElement.style.display = "";
}
}
if (this._onClick !== undefined) {
const self = this;
element.onclick = (e) => {
// @ts-ignore
if (e.consumed) {
return;
}
self._onClick();
// @ts-ignore
e.consumed = true;
}
element.style.pointerEvents = "all";
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);
this.UpdateAllChildren();
}
HideOnEmpty(hide: boolean): UIElement {
this._hideIfEmpty = hide;
this.Update();
return this;
}
Render(): string {
this.lastInnerRender = this.InnerRender();
if (this.dumbMode) {
return this.lastInnerRender;
}
let style = "";
if (this.style !== undefined && this.style !== "") {
style = `style="${this.style}" `;
}
let clss = "";
if (this.clss.size > 0) {
clss = `class='${Array.from(this.clss).join(" ")}' `;
}
return `<span ${clss}${style}id='${this.id}' gen="${this.constructor.name}">${this.lastInnerRender}</span>`
return "Don't use Render!"
}
AttachTo(divId: string) {
this.dumbMode = false;
let element = document.getElementById(divId);
if (element === null) {
throw "SEVERE: could not attach UIElement to " + divId;
}
element.innerHTML = this.Render();
this.Update();
return this;
}
public abstract InnerRender(): string;
public InnerRenderAsString(): string {
let rendered = this.InnerRender();
if (typeof rendered !== "string") {
let html = rendered.ConstructElement()
return html.innerHTML
}
return rendered
}
public IsEmpty(): boolean {
return this.InnerRender() === "";
return this.InnerRender() === undefined || this.InnerRender() === "";
}
/**
* Adds all the relevant classes, space seperated
* @param clss
* @constructor
* Should be overridden for specific HTML functionality
*/
public SetClass(clss: string) {
this.dumbMode = false;
const all = clss.split(" ");
let recordedChange = false;
for (const c of all) {
if (this.clss.has(clss)) {
continue;
protected InnerConstructElement(): HTMLElement {
// Uses the old fashioned way to construct an element using 'InnerRender'
const innerRender = this.InnerRender();
if (innerRender === undefined || innerRender === "") {
return undefined;
}
const el = document.createElement("span")
if (typeof innerRender === "string") {
el.innerHTML = innerRender
} else {
const subElement = innerRender.ConstructElement();
if (subElement === undefined) {
return undefined;
}
this.clss.add(c);
recordedChange = true;
el.appendChild(subElement)
}
if (recordedChange) {
this.Update();
}
return this;
return el;
}
public RemoveClass(clss: string): UIElement {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this.Update();
}
return this;
}
protected UpdateElement(el: HTMLElement) : void{
const innerRender = this.InnerRender();
public SetStyle(style: string): UIElement {
this.dumbMode = false;
this.style = style;
this.Update();
return this;
}
// Called after the HTML has been replaced. Can be used for css tricks
protected InnerUpdate(htmlElement: HTMLElement) {
}
private UpdateAllChildren() {
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();
}
}
if (typeof innerRender === "string") {
if(el.innerHTML !== innerRender){
el.innerHTML = innerRender
}
} else {
const subElement = innerRender.ConstructElement();
if(el.children.length === 1 && el.children[0] === subElement){
return; // Nothing changed
}
while (el.firstChild) {
el.removeChild(el.firstChild);
}
if (subElement === undefined) {
return;
}
el.appendChild(subElement)
}
}
/**
* @deprecated The method should not be used
*/
protected abstract InnerRender(): string | BaseUIElement;
}

View file

@ -1,24 +1,22 @@
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import Locale from "./Locale";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export class Translation extends UIElement {
export class Translation extends BaseUIElement {
public static forcedLanguage = undefined;
public readonly translations: object
return
allIcons;
constructor(translations: object, context?: string) {
super(Locale.language)
super()
if (translations === undefined) {
throw `Translation without content (${context})`
}
let count = 0;
for (const translationsKey in translations) {
if(!translations.hasOwnProperty(translationsKey)){
if (!translations.hasOwnProperty(translationsKey)) {
continue
}
count++;
@ -46,15 +44,29 @@ export class Translation extends UIElement {
return en;
}
for (const i in this.translations) {
if (!this.translations.hasOwnProperty(i)) {
continue;
}
return this.translations[i]; // Return a random language
}
console.error("Missing language ", Locale.language.data, "for", this.translations)
return "";
}
InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
Locale.language.addCallbackAndRun(_ => {
el.innerHTML = this.txt
})
return el;
}
public SupportedLanguages(): string[] {
const langs = []
for (const translationsKey in this.translations) {
if (!this.translations.hasOwnProperty(translationsKey)) {
continue;
}
if (translationsKey === "#") {
continue;
}
@ -66,9 +78,15 @@ export class Translation extends UIElement {
public Subs(text: any): Translation {
const newTranslations = {};
for (const lang in this.translations) {
if (!this.translations.hasOwnProperty(lang)) {
continue;
}
let template: string = this.translations[lang];
for (const k in text) {
const combined = [];
if (!text.hasOwnProperty(k)) {
continue
}
const combined: (string)[] = [];
const parts = template.split("{" + k + "}");
const el: string | UIElement = text[k];
if (el === undefined) {
@ -85,12 +103,12 @@ export class Translation extends UIElement {
// @ts-ignore
const date: Date = el;
rtext = date.toLocaleString();
} else if (el.InnerRender === undefined) {
} else if (el.InnerRenderAsString === undefined) {
console.error("InnerREnder is not defined", el);
throw "Hmmm, el.InnerRender is not defined?"
} else {
Translation.forcedLanguage = lang; // This is a very dirty hack - it'll bite me one day
rtext = el.InnerRender();
rtext = el.InnerRenderAsString();
}
for (let i = 0; i < parts.length - 1; i++) {
@ -98,7 +116,7 @@ export class Translation extends UIElement {
combined.push(rtext)
}
combined.push(parts[parts.length - 1]);
template = new Combine(combined).InnerRender();
template = combined.join("")
}
newTranslations[lang] = template;
}
@ -107,16 +125,11 @@ export class Translation extends UIElement {
}
InnerRender(): string {
return this.txt
}
public replace(a: string, b: string) {
if (a.startsWith("{") && a.endsWith("}")) {
a = a.substr(1, a.length - 2);
}
const result = this.Subs({[a]: b});
return result;
return this.Subs({[a]: b});
}
public Clone() {
@ -127,6 +140,9 @@ export class Translation extends UIElement {
const tr = {};
for (const lng in this.translations) {
if (!this.translations.hasOwnProperty(lng)) {
continue
}
let txt = this.translations[lng];
txt = txt.replace(/\..*/, "");
txt = Utils.EllipsesAfter(txt, 255);
@ -139,6 +155,9 @@ export class Translation extends UIElement {
public ExtractImages(isIcon = false): string[] {
const allIcons: string[] = []
for (const key in this.translations) {
if (!this.translations.hasOwnProperty(key)) {
continue;
}
const render = this.translations[key]
if (isIcon) {

View file

@ -2,6 +2,7 @@ import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import AllTranslationAssets from "../../AllTranslationAssets";
import {Translation} from "./Translation";
import BaseUIElement from "../BaseUIElement";
export default class Translations {
@ -10,7 +11,7 @@ export default class Translations {
}
static t = AllTranslationAssets.t;
public static W(s: string | UIElement): UIElement {
public static W(s: string | BaseUIElement): BaseUIElement {
if (typeof (s) === "string") {
return new FixedUiElement(s);
}

View file

@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="index.css" rel="stylesheet"/>
<link href="css/tabbedComponent.css" rel="stylesheet"/>
<title>Custom Theme Generator for Mapcomplete</title>
<style type="text/css">
img {
min-width: 35px;
min-height: 35px;
}
input{
border: 0.5px solid #939393;
}
.icon-preview {
max-width: 2em;
max-height: 2em ;
}
.image-large-preview {
max-width: 100%;
max-height: 30vh;
}
.json{
width:100%;
box-sizing: border-box;
}
.bordered {
border: 1px solid black;
display:block;
padding: 0.5em;
border-radius: 0.5em;
box-sizing: border-box;
}
.tag-input-row {
display: block ruby;
box-sizing: border-box;
margin-right: 2em;
width: calc(100% - 3em);
padding-right: 0.5em;
height: min-content;
}
.min-height {
display: block;
height: min-content;
}
.main-tabs{
height: 100vh;
}
.tab-content {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
margin-left: 0.5em;
}
.main-tabs > .tabs-header-bar {
background: #eee;
}
.scrollable {
display: block;
overflow-y: scroll;
height: calc(100vh - 9em - 10px);
}
.main-tabs > .tab-content {
display: block;
height: 100%;
padding-bottom: 0 ;
padding-right: 0;
}
.main-tabs > .tab-content > span{
display: block;
height: 100%;
}
body {
height: 100%;
}
#maindiv {
height: calc(100% - 6em);
}
</style>
</head>
<body>
<div id="maindiv">
Loading the MapComplete custom theme builder...<br/>
If this message persists, make sure javascript is enabled and no script blocker is blocking this.
</div>
<script src="./customGenerator.ts"></script>
</body>
</html>

View file

@ -1,32 +0,0 @@
import {UIEventSource} from "./Logic/UIEventSource";
import {GenerateEmpty} from "./UI/CustomGenerator/GenerateEmpty";
import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson";
import {OsmConnection} from "./Logic/Osm/OsmConnection";
import CustomGeneratorPanel from "./UI/CustomGenerator/CustomGeneratorPanel";
import {LocalStorageSource} from "./Logic/Web/LocalStorageSource";
import {Utils} from "./Utils";
import LZString from "lz-string";
let layout = GenerateEmpty.createEmptyLayout();
if (window.location.hash.length > 10) {
const hash = window.location.hash.substr(1)
try{
layout = JSON.parse(atob(hash)) as LayoutConfigJson;
}catch(e){
console.log("Initial load of theme failed, attempt nr 2 with decompression", e)
layout = JSON.parse( Utils.UnMinify(LZString.decompressFromBase64(hash)))
}
} else {
const hash = LocalStorageSource.Get("last-custom-theme").data
if (hash !== undefined) {
console.log("Using theme from local storage")
layout = JSON.parse(atob(hash)) as LayoutConfigJson;
}
}
const connection = new OsmConnection(false, new UIEventSource<string>(undefined), "customGenerator", false);
new CustomGeneratorPanel(connection, layout)
.AttachTo("maindiv");

View file

@ -14,7 +14,7 @@ import ValidatedTextField from "../UI/Input/ValidatedTextField";
const TurndownService = require('turndown')
function WriteFile(filename, html: UIElement) : void {
const md = new TurndownService().turndown(html.InnerRender());
const md = new TurndownService().turndown(html.InnerRenderAsString());
writeFileSync(filename, md);
}

View file

@ -88,8 +88,8 @@ async function createManifest(layout: LayoutConfig) {
console.log(icon)
throw "Icon is not an svg for " + layout.id
}
const ogTitle = Translations.W(layout.title).InnerRender();
const ogDescr = Translations.W(layout.description ?? "").InnerRender();
const ogTitle = Translations.W(layout.title).InnerRenderAsString();
const ogDescr = Translations.W(layout.description ?? "").InnerRenderAsString();
return {
name: name,
@ -109,8 +109,8 @@ async function createLandingPage(layout: LayoutConfig, manifest) {
Locale.language.setData(layout.language[0]);
const ogTitle = Translations.W(layout.title)?.InnerRender();
const ogDescr = Translations.W(layout.shortDescription ?? "Easily add and edit geodata with OpenStreetMap")?.InnerRender();
const ogTitle = Translations.W(layout.title)?.InnerRenderAsString();
const ogDescr = Translations.W(layout.shortDescription ?? "Easily add and edit geodata with OpenStreetMap")?.InnerRenderAsString();
const ogImage = layout.socialImage;
let customCss = "";

View file

@ -20,7 +20,7 @@ function generateWikiEntry(layout: LayoutConfig) {
|region= Worldwide
|lang= ${languages}
|descr= A MapComplete theme: ${Translations.W(layout.description)
.InnerRender()
.InnerRenderAsString()
.replace("<a href='", "[[")
.replace(/'>.*<\/a>/, "]]")
}

16
test.ts
View file

@ -1,3 +1,15 @@
import ValidatedTextField from "./UI/Input/ValidatedTextField";
import {Translation} from "./UI/i18n/Translation";
import Locale from "./UI/i18n/Locale";
import Combine from "./UI/Base/Combine";
ValidatedTextField.InputForType("phone").AttachTo("maindiv")
new Combine(["Some language:",new Translation({en:"English",nl:"Nederlands",fr:"Françcais"})]).AttachTo("maindiv")
Locale.language.setData("nl")
window.setTimeout(() => {
Locale.language.setData("en")
}, 1000)
window.setTimeout(() => {
Locale.language.setData("fr")
}, 5000)

View file

@ -145,7 +145,7 @@ export default class TagSpec extends T{
equal("Has no name", tr.GetRenderValue({"noname": "yes"})?.txt);
equal("Ook een {name}", tr.GetRenderValue({"name": "xyz"})?.txt);
equal("Ook een xyz", SubstitutedTranslation.construct(tr.GetRenderValue({"name": "xyz"}),
new UIEventSource<any>({"name": "xyz"})).InnerRender());
new UIEventSource<any>({"name": "xyz"})).InnerRenderAsString());
equal(undefined, tr.GetRenderValue({"foo": "bar"}));
})],
@ -196,7 +196,7 @@ export default class TagSpec extends T{
const uiEl = new EditableTagRendering(new UIEventSource<any>(
{leisure: "park", "access": "no"}), constr
);
const rendered = uiEl.InnerRender();
const rendered = uiEl.InnerRenderAsString();
equal(true, rendered.indexOf("Niet toegankelijk") > 0)
}

View file

@ -27,7 +27,7 @@ export default class TagQuestionSpec extends T {
}, undefined, "Testing tag"
);
const questionElement = new TagRenderingQuestion(tags, config);
const html = questionElement.InnerRender();
const html = questionElement.InnerRenderAsString();
T.assertContains("What is the name of this bookcase?", html);
T.assertContains("<input type='text'", html);
}],
@ -53,7 +53,7 @@ export default class TagQuestionSpec extends T {
}, undefined, "Testing tag"
);
const questionElement = new TagRenderingQuestion(tags, config);
const html = questionElement.InnerRender();
const html = questionElement.InnerRenderAsString();
T.assertContains("What is the name of this bookcase?", html);
T.assertContains("This bookcase has no name", html);
T.assertContains("<input type='text'", html);