Add custom theme generator

This commit is contained in:
Pieter Vander Vennet 2020-08-08 21:17:17 +02:00
parent 14930e2f93
commit 8d3c8ed9d9
8 changed files with 570 additions and 16 deletions

View file

@ -10,18 +10,78 @@ import FixedText from "../Questions/FixedText";
import {ImageCarouselWithUploadConstructor} from "../../UI/Image/ImageCarouselWithUpload"; import {ImageCarouselWithUploadConstructor} from "../../UI/Image/ImageCarouselWithUpload";
export interface TagRenderingConfigJson {
// If this key is present, then...
key?: string,
// Use this string to render
render: string,
// One of string, int, nat, float, pfloat, email, phone. Default: string
type?: string,
// If it is not known (and no mapping below matches), this question is asked; a textfield is inserted in the rendering above
question?: string,
// If a value is added with the textfield, this extra tag is addded. Optional field
addExtraTags?: string | string[] | { k: string, v: string }[];
// Alternatively, these tags are shown if they match - even if the key above is not there
// If unknown, these become a radio button
mappings?:
{
if: string,
then: string
}[]
}
export interface LayerConfigJson {
id: string;
icon: string;
title: TagRenderingConfigJson;
description: string;
minzoom: number,
color: string;
overpassTags: string | string[] | { k: string, v: string }[];
presets: [
{
// icon: optional. Uses the layer icon by default
icon?: string;
// title: optional. Uses the layer title by default
title?: string;
// description: optional. Uses the layer description by default
description?: string;
// tags: optional list {k:string, v:string}[]
tags?: string | string[] | { k: string, v: string }[]
}
],
tagRenderings: TagRenderingConfigJson []
}
export interface LayoutConfigJson {
name: string;
title: string;
description: string;
language: string;
layers: LayerConfigJson[],
startZoom: number;
startLat: number;
startLon: number;
/**
* Either a URL or a base64 encoded value (which should include 'data:image/svg+xml;base64,'
*/
icon: string;
}
export class CustomLayoutFromJSON { export class CustomLayoutFromJSON {
public static exampleLayer = { public static exampleLayer: LayerConfigJson = {
id: "bookcase", id: "bookcase",
icon: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgaWQ9InN2ZzExMzgyIgogICBoZWlnaHQ9IjkwMCIKICAgd2lkdGg9IjkwMCIKICAgdmVyc2lvbj0iMS4wIj4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczExMzg0IiAvPgogIDxnCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjkwMTAzMjU4LDAsMCwwLjkwMTAzMjU4LDExMi44NDA1OCwtMS45MDYwMTc3KSI+CiAgICA8ZwogICAgICAgaWQ9ImcxMTQ3NiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTE0NzIiCiAgICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zaXplOjEyMDEuOTI0OTI2NzZweDtmb250LWZhbWlseTonQml0c3RyZWFtIFZlcmEgU2Fucyc7dGV4dC1hbGlnbjpjZW50ZXI7dGV4dC1hbmNob3I6bWlkZGxlO2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgIGQ9Ik0gNDc0LjUwODg4LDcxOC4yMjg0MSBIIDMwMy40OTU0NyB2IC0yMi4zMDEzNCBjIC0yLjRlLTQsLTM3Ljk1MTA4IDQuMzAzNTIsLTY4Ljc2MjExIDEyLjkxMTMsLTkyLjQzMzE5IDguNjA3MjgsLTIzLjY3MDMyIDIzLjYzMzUyLC00NS4yODY5NSA0MC42NTMyNCwtNjQuODQ5OTYgMTcuMDE5MTQsLTE5LjU2MjExIDQxLjk4NzM0LC0yNi4zMzI2NCAxMDEuNDU3OTMsLTc1LjYzMDg1IDMxLjY5MDk1LC0yNS44MjIwMyA1NS4yODEzLC03Ny4xNTIzIDU1LjI4MTc1LC05OC42NzE3NCAyLjIxMjMyLC01Ni45MjI0NSAtMTMuOTM5ODMsLTc5LjM0MjIgLTM0LjU2Mjg3LC05OS45NjUyNCAtMjIuNjczNTUsLTE5LjY3NzE3IC02MC42NzAyNywtMzAuMDY5OTggLTkwLjk5ODkyLC0zMC4wNjk5OCAtMjcuNzc5MjEsNi45ZS00IC02OC40NjczNSw4LjA4ODcxIC04Ny43NjY2LDI1LjM3MDQ3IC0yNS45MzgxNywxNy4yODMwOCAtNjUuMjM3NDcsNzMuNzA2MTEgLTU3LjA0Njg3LDEzMC41NDU3NyBsIC0xOTQuNTE2OTQzLDEuNzAyMjIgYyAwLC0xNTcuMjEzOTkgMjkuMzkzNjk5LC0xOTguNjk0NjUgOTkuMDA0MTEzLC0yNjMuMDMwMzIgNjcuMzk3MzksLTU0LjM3NjY0MyAxMjYuNTMxMjgsLTczLjI2ODM2NSAyNDMuODQ3NTcsLTczLjI2ODM2NSA4OS43MTc5MSwwIDE2MS44OTcyOCwxNy44MDI4MSAyMTQuMzI1NTIsNTMuNDA1ODU1IDcxLjIwNzE0LDQ4LjEyNDcyIDEyMi4zMDEwNSwxMTEuMTgzNTQgMTIyLjMwMTA1LDIzMC4xMTI4MSAtNi45ZS00LDQ0LjMyMDgxIC0xOS4xNTI1Myw5MC43ODYzOCAtNDMuMDcyNiwxMjguMzMyOTkgLTE4LjM4OTQ3LDMwLjkwOTM4IC02MC4zNzUxMSw2Ni40NTIzNiAtMTE4LjIxMjM3LDEwNC40MTYyOCAtNDIuODM2MDcsMjUuNzY4NiAtNjYuNjcxOTYsNTMuMTE5MjYgLTc3LjAzOTY0LDcyLjA5NDYgLTEwLjM2ODYzLDE4Ljk3NjAzIC0xNS41NTI3MSw0My43MjI2NyAtMTUuNTUyMjUsNzQuMjM5OTkgeiIgLz4KICAgICAgPHBhdGgKICAgICAgICAgaWQ9InBhdGgxMTQ3NCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MztzdHJva2UtbGluZWNhcDpzcXVhcmU7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS4xMDYzODMsLTUuNTMxOTE0OSkiCiAgICAgICAgIGQ9Im0gNDgyLjM4Mjk4LDg2OS44MDkwMiBhIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgLTE4OC4wODUxMSwwIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgMTg4LjA4NTExLDAgeiIgLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo=", icon: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgaWQ9InN2ZzExMzgyIgogICBoZWlnaHQ9IjkwMCIKICAgd2lkdGg9IjkwMCIKICAgdmVyc2lvbj0iMS4wIj4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczExMzg0IiAvPgogIDxnCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjkwMTAzMjU4LDAsMCwwLjkwMTAzMjU4LDExMi44NDA1OCwtMS45MDYwMTc3KSI+CiAgICA8ZwogICAgICAgaWQ9ImcxMTQ3NiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTE0NzIiCiAgICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zaXplOjEyMDEuOTI0OTI2NzZweDtmb250LWZhbWlseTonQml0c3RyZWFtIFZlcmEgU2Fucyc7dGV4dC1hbGlnbjpjZW50ZXI7dGV4dC1hbmNob3I6bWlkZGxlO2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgIGQ9Ik0gNDc0LjUwODg4LDcxOC4yMjg0MSBIIDMwMy40OTU0NyB2IC0yMi4zMDEzNCBjIC0yLjRlLTQsLTM3Ljk1MTA4IDQuMzAzNTIsLTY4Ljc2MjExIDEyLjkxMTMsLTkyLjQzMzE5IDguNjA3MjgsLTIzLjY3MDMyIDIzLjYzMzUyLC00NS4yODY5NSA0MC42NTMyNCwtNjQuODQ5OTYgMTcuMDE5MTQsLTE5LjU2MjExIDQxLjk4NzM0LC0yNi4zMzI2NCAxMDEuNDU3OTMsLTc1LjYzMDg1IDMxLjY5MDk1LC0yNS44MjIwMyA1NS4yODEzLC03Ny4xNTIzIDU1LjI4MTc1LC05OC42NzE3NCAyLjIxMjMyLC01Ni45MjI0NSAtMTMuOTM5ODMsLTc5LjM0MjIgLTM0LjU2Mjg3LC05OS45NjUyNCAtMjIuNjczNTUsLTE5LjY3NzE3IC02MC42NzAyNywtMzAuMDY5OTggLTkwLjk5ODkyLC0zMC4wNjk5OCAtMjcuNzc5MjEsNi45ZS00IC02OC40NjczNSw4LjA4ODcxIC04Ny43NjY2LDI1LjM3MDQ3IC0yNS45MzgxNywxNy4yODMwOCAtNjUuMjM3NDcsNzMuNzA2MTEgLTU3LjA0Njg3LDEzMC41NDU3NyBsIC0xOTQuNTE2OTQzLDEuNzAyMjIgYyAwLC0xNTcuMjEzOTkgMjkuMzkzNjk5LC0xOTguNjk0NjUgOTkuMDA0MTEzLC0yNjMuMDMwMzIgNjcuMzk3MzksLTU0LjM3NjY0MyAxMjYuNTMxMjgsLTczLjI2ODM2NSAyNDMuODQ3NTcsLTczLjI2ODM2NSA4OS43MTc5MSwwIDE2MS44OTcyOCwxNy44MDI4MSAyMTQuMzI1NTIsNTMuNDA1ODU1IDcxLjIwNzE0LDQ4LjEyNDcyIDEyMi4zMDEwNSwxMTEuMTgzNTQgMTIyLjMwMTA1LDIzMC4xMTI4MSAtNi45ZS00LDQ0LjMyMDgxIC0xOS4xNTI1Myw5MC43ODYzOCAtNDMuMDcyNiwxMjguMzMyOTkgLTE4LjM4OTQ3LDMwLjkwOTM4IC02MC4zNzUxMSw2Ni40NTIzNiAtMTE4LjIxMjM3LDEwNC40MTYyOCAtNDIuODM2MDcsMjUuNzY4NiAtNjYuNjcxOTYsNTMuMTE5MjYgLTc3LjAzOTY0LDcyLjA5NDYgLTEwLjM2ODYzLDE4Ljk3NjAzIC0xNS41NTI3MSw0My43MjI2NyAtMTUuNTUyMjUsNzQuMjM5OTkgeiIgLz4KICAgICAgPHBhdGgKICAgICAgICAgaWQ9InBhdGgxMTQ3NCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MztzdHJva2UtbGluZWNhcDpzcXVhcmU7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS4xMDYzODMsLTUuNTMxOTE0OSkiCiAgICAgICAgIGQ9Im0gNDgyLjM4Mjk4LDg2OS44MDkwMiBhIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgLTE4OC4wODUxMSwwIDk0LjA0MjU1Nyw3My4wMjEyNzggMCAxIDEgMTg4LjA4NTExLDAgeiIgLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo=",
title: "Bookcase", title: {render: "Bookcase"},
description: "A small, public cabinet with books. Anyone can leave or take a book", description: "A small, public cabinet with books. Anyone can leave or take a book",
minzoom: 12, minzoom: 12,
color: "#0000ff", color: "#0000ff",
overpassTags: "amenity=public_bookcase", overpassTags: "amenity=public_bookcase",
presets: [ presets: [
{ {
title: "bookcase"
// icon: optional. Uses the layer icon by default // icon: optional. Uses the layer icon by default
// title: optional. Uses the layer title by default // title: optional. Uses the layer title by default
// description: optional. Uses the layer description by default // description: optional. Uses the layer description by default
@ -55,7 +115,7 @@ export class CustomLayoutFromJSON {
] ]
} }
public static exampleLayout = { public static exampleLayout: LayoutConfigJson = {
name: "bookcases", name: "bookcases",
title: "Custom Open bookcases map", title: "Custom Open bookcases map",
description: "Welcome to a custom layout", description: "Welcome to a custom layout",
@ -84,7 +144,7 @@ export class CustomLayoutFromJSON {
} }
let freeform = undefined; let freeform = undefined;
if (json.key !== undefined && json.render !== undefined) { if (json.key !== undefined && json.key !== "" && json.render !== undefined) {
const type = json.type ?? "text"; const type = json.type ?? "text";
freeform = { freeform = {
key: json.key, key: json.key,
@ -142,10 +202,11 @@ export class CustomLayoutFromJSON {
}; };
} }
private static TagFromJson(json: any): Tag { private static TagFromJson(json: string | { k: string, v: string }): Tag {
if (json === undefined) { if (json === undefined) {
return undefined; return undefined;
} }
console.log(json)
if (typeof (json) === "string") { if (typeof (json) === "string") {
const kv = json.split("="); const kv = json.split("=");
return new Tag(kv[0].trim(), kv[1].trim()); return new Tag(kv[0].trim(), kv[1].trim());
@ -153,8 +214,8 @@ export class CustomLayoutFromJSON {
return new Tag(json.k.trim(), json.v.trim()) return new Tag(json.k.trim(), json.v.trim())
} }
private static TagsFromJson(json: any): Tag[] { private static TagsFromJson(json: string | { k: string, v: string }[]): Tag[] {
if (json === undefined) { if (json === undefined || json === "") {
return undefined; return undefined;
} }
if (typeof (json) === "string") { if (typeof (json) === "string") {

View file

@ -21,11 +21,10 @@ export class OsmConnection {
public userDetails: UIEventSource<UserDetails>; public userDetails: UIEventSource<UserDetails>;
private _dryRun: boolean; private _dryRun: boolean;
constructor(dryRun: boolean, oauth_token: UIEventSource<string>) { constructor(dryRun: boolean, oauth_token: UIEventSource<string>, singlePage: boolean = true) {
let pwaStandAloneMode = false; let pwaStandAloneMode = false;
try { try {
if (window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches) { if (window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches) {
pwaStandAloneMode = true; pwaStandAloneMode = true;
} }
@ -36,7 +35,7 @@ export class OsmConnection {
const iframeMode = window !== window.top; const iframeMode = window !== window.top;
if ( iframeMode) { if ( iframeMode || !singlePage) {
// In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway... // In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway...
// Same for an iframe... // Same for an iframe...
this.auth = new osmAuth({ this.auth = new osmAuth({

View file

@ -24,7 +24,7 @@ export class State {
// The singleton of the global state // The singleton of the global state
public static state: State; public static state: State;
public static vNumber = "0.0.3"; public static vNumber = "0.0.4";
public static runningFromConsole: boolean = false; public static runningFromConsole: boolean = false;
@ -32,6 +32,7 @@ export class State {
THe layout to use THe layout to use
*/ */
public readonly layoutToUse = new UIEventSource<Layout>(undefined); public readonly layoutToUse = new UIEventSource<Layout>(undefined);
public layoutDefinition : string;
/** /**
The mapping from id -> UIEventSource<properties> The mapping from id -> UIEventSource<properties>

View file

@ -22,6 +22,7 @@ export class ShareScreen extends UIElement {
private _iframeCode: UIElement; private _iframeCode: UIElement;
private _link: UIElement; private _link: UIElement;
private _linkStatus: UIElement; private _linkStatus: UIElement;
private _editLayout: UIElement;
constructor() { constructor() {
super(undefined) super(undefined)
@ -129,11 +130,16 @@ export class ShareScreen extends UIElement {
const parts = Utils.NoNull(optionParts.map((eventSource) => eventSource.data)); const parts = Utils.NoNull(optionParts.map((eventSource) => eventSource.data));
if (parts.length === 0) { let hash = "";
return literalText; if (State.state.layoutDefinition !== undefined) {
hash = ("#" + State.state.layoutDefinition)
} }
return literalText + "?" + parts.join("&"); if (parts.length === 0) {
return literalText + hash;
}
return literalText + "?" + parts.join("&") + hash;
}, optionParts); }, optionParts);
this._iframeCode = new VariableUiElement( this._iframeCode = new VariableUiElement(
url.map((url) => { url.map((url) => {
@ -150,6 +156,13 @@ export class ShareScreen extends UIElement {
}) })
); );
this._editLayout = new FixedUiElement("");
if(State.state.layoutDefinition !== undefined){
this._editLayout =
new FixedUiElement(`<h3>Edit this theme</h3>`+
`<a target='_blank' https://pietervdvn.github.io/MapComplete/customGenerator.html#${State.state.layoutDefinition}'>Click here to edit</a>`)
}
const status = new UIEventSource(" "); const status = new UIEventSource(" ");
this._linkStatus = new VariableUiElement(status); this._linkStatus = new VariableUiElement(status);
@ -200,7 +213,8 @@ export class ShareScreen extends UIElement {
tr.addToHomeScreen, tr.addToHomeScreen,
tr.embedIntro, tr.embedIntro,
this._options, this._options,
this._iframeCode this._iframeCode,
this._editLayout
]).Render() ]).Render()
} }

58
customGenerator.html Normal file
View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<link href="index.css" rel="stylesheet"/>
<title>Custom Theme Generator for Mapcomplete</title>
<style type="text/css">
#maindiv {
position: absolute;
width: 50vw;
height: 100%;
left: 0;
top: 0;
}
#preview {
position: absolute;
width: 50vw;
height: 100vh;
right: 0;
top: 0;
}
.bordered {
border: 1px solid black;
display:block;
padding: 0.5em;
border-radius: 0.5em;
}
body {
height: 100%;
}
</style>
</head>
<body>
<div id="maindiv">
<h1>Custom theme generator</h1>
Welcome to the custom theme creator.<br/>
In order to use this theme generator, you need at least 500 changesets.<br/>
As the spirit of mapcomplete is to not have <b>any</b> kind of hosted backend, the custom themes are encoded in the
URL:
the full configuration is saved in a JSON, which is base64-encoded and appended to the hash of the URL.<br/>
This means that <b>closing this page removes your theme</b>.</br>
<div id="loggedIn">'loggedIn' not attached</div>
<div id="layoutCreator"></div>
</div>
<div id="preview">'preview' not attached</div>
<script src="./customGenerator.ts"></script>
</body>
</html>

20
customGenerator.ts Normal file
View file

@ -0,0 +1,20 @@
import {OsmConnection, UserDetails} from "./Logic/Osm/OsmConnection";
import {UIEventSource} from "./UI/UIEventSource";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
import {Preview, ThemeGenerator} from "./themeGenerator";
const connection = new OsmConnection(true, new UIEventSource<string>(undefined), false);
connection.AttemptLogin();
new VariableUiElement(connection.userDetails.map<string>((userdetails : UserDetails) => {
if(userdetails.loggedIn){
return "Logged in as "+userdetails.name
}else{
return "Not logged in"
}
})).AttachTo("loggedIn").onClick(() => connection.LogOut());
const themeGenerator = new ThemeGenerator(connection, window.location.hash?.substr(1));
themeGenerator.AttachTo("layoutCreator")
new Preview(themeGenerator.url).AttachTo("preview");

View file

@ -70,7 +70,7 @@ let layoutToUse: Layout = AllKnownLayouts.allSets[defaultLayout] ?? AllKnownLayo
const layoutFromBase64 = QueryParameters.GetQueryParameter("userlayout", "false").data; const layoutFromBase64 = QueryParameters.GetQueryParameter("userlayout", "false").data;
if(layoutFromBase64 === "true"){ if(layoutFromBase64 !== "false"){
layoutToUse = CustomLayoutFromJSON.FromQueryParam(hash.substr(1)); layoutToUse = CustomLayoutFromJSON.FromQueryParam(hash.substr(1));
} }
@ -86,6 +86,9 @@ console.log("Using layout: ", layoutToUse.name);
TagRendering.injectFunction(); TagRendering.injectFunction();
State.state = new State(layoutToUse); State.state = new State(layoutToUse);
if(layoutFromBase64 !== "false"){
State.state.layoutDefinition = hash.substr(1);
}
InitUiElements.InitBaseMap(); InitUiElements.InitBaseMap();
new FixedUiElement("").AttachTo("decoration"); // Remove the decoration new FixedUiElement("").AttachTo("decoration"); // Remove the decoration

398
themeGenerator.ts Normal file
View file

@ -0,0 +1,398 @@
import {UIElement} from "./UI/UIElement";
import {OsmConnection, UserDetails} from "./Logic/Osm/OsmConnection";
import {UIEventSource} from "./UI/UIEventSource";
import Combine from "./UI/Base/Combine";
import {TextField} from "./UI/Input/TextField";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
import {VerticalCombine} from "./UI/Base/VerticalCombine";
import {FixedUiElement} from "./UI/Base/FixedUiElement";
import {TabbedComponent} from "./UI/Base/TabbedComponent";
import {LayerConfigJson, LayoutConfigJson, TagRenderingConfigJson} from "./Customizations/JSON/CustomLayoutFromJSON";
import {Button} from "./UI/Base/Button";
import {type} from "os";
import {Tag} from "./Logic/TagsFilter";
function TagsToString(tags: string | string [] | { k: string, v: string }[]) {
if (tags === undefined) {
return undefined;
}
if (typeof (tags) == "string") {
return tags;
}
const newTags = [];
console.log(tags)
for (const tag of tags) {
if (typeof (tag) == "string") {
newTags.push(tag)
} else {
newTags.push(tag.k + "=" + tag.v);
}
}
return newTags.join(",");
}
export class Preview extends UIElement {
private url: UIEventSource<string>;
constructor(url: UIEventSource<string>) {
super(url);
this.url = url;
}
InnerRender(): string {
const url = this.url.data;
return ""; // `<iframe src="${url}" width="100%" height="100%" title="Test"></iframe>`
}
}
class MappingGenerator extends UIElement {
private elements: UIElement[];
constructor(fullConfig: UIEventSource<LayoutConfigJson>,
layerConfig: LayerConfigJson,
tagRendering: TagRenderingConfigJson,
mapping: { if: string | string[] | { k: string, v: string }[] },
generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement) {
super(undefined);
this.CreateElements(fullConfig, layerConfig, tagRendering, mapping, generateField)
}
private CreateElements(fullConfig: UIEventSource<LayoutConfigJson>, layerConfig: LayerConfigJson,
tagRendering: TagRenderingConfigJson,
mapping,
generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement) {
{
const self = this;
this.elements = [
new FixedUiElement("<h5>Mapping</h5>"),
generateField(fullConfig, "If these tags apply", "if", mapping),
generateField(fullConfig, "Then: show this text", "then", mapping),
new Button("Remove this mapping", () => {
for (let i = 0; i < tagRendering.mappings.length; i++) {
if (tagRendering.mappings[i] === mapping) {
tagRendering.mappings.splice(i, 1);
self.elements = [
new FixedUiElement("Tag mapping removed")
]
self.Update();
break;
}
}
})
];
}
}
InnerRender(): string {
const combine = new VerticalCombine(this.elements);
combine.clss = "bordered";
return combine.Render();
}
}
class TagRenderingGenerator
extends UIElement {
private elements: UIElement[];
constructor(fullConfig: UIEventSource<LayoutConfigJson>,
layerConfig: LayerConfigJson,
tagRendering: TagRenderingConfigJson,
generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement,
isTitle: boolean = false) {
super(undefined);
this.CreateElements(fullConfig, layerConfig, tagRendering, generateField, isTitle)
}
private CreateElements(fullConfig: UIEventSource<LayoutConfigJson>, layerConfig: LayerConfigJson, tagRendering: TagRenderingConfigJson, generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement, isTitle: boolean) {
const self = this;
this.elements = [
new FixedUiElement(isTitle ? "<h3>Popup title</h3>" : "<h3>TagRendering/TagQuestion</h3>"),
generateField(fullConfig, "Key", "key", tagRendering),
generateField(fullConfig, "Rendering", "render", tagRendering),
generateField(fullConfig, "Type", "type", tagRendering),
generateField(fullConfig, "Question", "question", tagRendering),
generateField(fullConfig, "Extra tags", "addExtraTags", tagRendering),
...(tagRendering.mappings ?? []).map((mapping) => {
return new MappingGenerator(fullConfig, layerConfig, tagRendering, mapping,
generateField)
}),
new Button("Add mapping", () => {
tagRendering.mappings.push({if: "", then: ""});
self.CreateElements(fullConfig, layerConfig, tagRendering, generateField, isTitle);
self.Update();
})
]
if (!isTitle) {
const b = new Button("Remove this preset", () => {
for (let i = 0; i < layerConfig.tagRenderings.length; i++) {
if (layerConfig.tagRenderings[i] === tagRendering) {
layerConfig.tagRenderings.splice(i, 1);
self.elements = [
new FixedUiElement("Tag rendering removed")
]
self.Update();
break;
}
}
});
this.elements.push(b);
}
}
InnerRender(): string {
const combine = new VerticalCombine(this.elements);
combine.clss = "bordered";
return combine.Render();
}
}
class PresetGenerator extends UIElement {
private elements: UIElement[];
constructor(fullConfig: UIEventSource<LayoutConfigJson>, layerConfig: LayerConfigJson,
preset0: { title?: string, description?: string, icon?: string, tags?: string | string[] | { k: string, v: string }[] },
generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement) {
super(undefined);
const self = this;
this.elements = [
new FixedUiElement("<h3>Preset</h3>"),
generateField(fullConfig, "Title", "title", preset0),
generateField(fullConfig, "Description", "description", preset0, layerConfig.description),
generateField(fullConfig, "icon", "icon", preset0, layerConfig.icon),
generateField(fullConfig, "tags", "tags", preset0, TagsToString(layerConfig.overpassTags)),
new Button("Remove this preset", () => {
for (let i = 0; i < layerConfig.presets.length; i++) {
if (layerConfig.presets[i] === preset0) {
layerConfig.presets.splice(i, 1);
self.elements = [
new FixedUiElement("Preset removed")
]
self.Update();
break;
}
}
})
]
}
InnerRender(): string {
const combine = new VerticalCombine(this.elements);
combine.clss = "bordered";
return combine.Render();
}
}
class LayerGenerator extends UIElement {
private fullConfig: UIEventSource<LayoutConfigJson>;
private layerConfig: UIEventSource<LayerConfigJson>;
private generateField: ((label: string, key: string, root: any, deflt?: string) => UIElement);
private uielements: UIElement[];
constructor(fullConfig: UIEventSource<LayoutConfigJson>,
layerConfig: LayerConfigJson,
generateField: ((src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement)) {
super(undefined);
this.layerConfig = new UIEventSource<LayerConfigJson>(layerConfig);
this.fullConfig = fullConfig;
this.CreateElements(fullConfig, layerConfig, generateField)
}
private CreateElements(fullConfig: UIEventSource<LayoutConfigJson>, layerConfig: LayerConfigJson, generateField: (src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement) {
const self = this;
this.uielements = [
generateField(fullConfig, "id", "id", layerConfig),
generateField(fullConfig, "The title of this layer", "title", layerConfig),
generateField(fullConfig, "A description of objects for this layer", "description", layerConfig),
generateField(fullConfig, "The icon of this layer, either a URL or a base64-encoded svg", "icon", layerConfig),
generateField(fullConfig, "The default stroke color", "color", layerConfig),
generateField(fullConfig, "The minimal needed zoom to start loading", "minzoom", layerConfig),
generateField(fullConfig, "The tags to load from overpass", "overpassTags", layerConfig),
...layerConfig.presets.map(preset => new PresetGenerator(fullConfig, layerConfig, preset, generateField)),
new Button("Add a preset", () => {
layerConfig.presets.push({
icon: undefined,
title: "",
description: "",
tags: TagsToString(layerConfig.overpassTags)
});
self.CreateElements(fullConfig, layerConfig, generateField);
self.Update();
}),
new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.title, generateField, true),
...layerConfig.tagRenderings.map(tr => new TagRenderingGenerator(fullConfig, layerConfig, tr, generateField)),
new Button("Add a tag rendering", () => {
layerConfig.tagRenderings.push({
key: "",
addExtraTags: "",
mappings: [],
question: "",
render: "",
type: "text"
});
self.CreateElements(fullConfig, layerConfig, generateField);
self.Update();
}),
]
}
InnerRender(): string {
return new VerticalCombine(this.uielements).Render();
}
}
class AllLayerComponent extends UIElement {
private tabs: TabbedComponent;
private config: UIEventSource<LayoutConfigJson>;
private generateField: ((src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement);
constructor(config: UIEventSource<LayoutConfigJson>, generateField: ((src: UIEventSource<any>, label: string, key: string, root: any, deflt?: string) => UIElement)) {
super(undefined);
this.generateField = generateField;
this.config = config;
const self = this;
let previousLayerAmount = config.data.layers.length;
config.addCallback((data) => {
if (data.layers.length != previousLayerAmount) {
previousLayerAmount = data.layers.length;
self.UpdateTabs();
self.Update();
}
});
this.UpdateTabs();
}
private UpdateTabs() {
const layerPanes: { header: UIElement | string, content: UIElement | string }[] = [];
const config = this.config;
for (const layer of this.config.data.layers) {
const header = this.config.map(() => {
return `<img src="${layer?.icon ?? "./assets/help.svg"}">`
});
layerPanes.push({
header: new VariableUiElement(header),
content: new LayerGenerator(config, layer, this.generateField)
})
}
layerPanes.push({
header: "<img src='./assets/add.svg'>",
content: new Button("Add a new layer", () => {
config.data.layers.push({
id: "",
title: {
render: "Title"
},
icon: "./assets/bug.svg",
color: "",
description: "",
minzoom: 12,
overpassTags: "",
presets: [{}],
tagRenderings: []
});
config.ping();
})
})
this.tabs = new TabbedComponent(layerPanes);
}
InnerRender(): string {
return this.tabs.Render();
}
}
export class ThemeGenerator extends UIElement {
private readonly userDetails: UIEventSource<UserDetails>;
private readonly themeObject: UIEventSource<LayoutConfigJson>;
private readonly allQuestionFields: UIElement[];
public url: UIEventSource<string>;
constructor(connection: OsmConnection, windowHash) {
super(connection.userDetails);
this.userDetails = connection.userDetails;
const defaultTheme = {layers: [], icon: "./assets/bug.svg"};
let loadedTheme = undefined;
if (windowHash !== undefined && windowHash.length > 4) {
loadedTheme = JSON.parse(atob(windowHash));
}
this.themeObject = new UIEventSource<LayoutConfigJson>(loadedTheme ?? defaultTheme);
const jsonObjectRoot = this.themeObject.data;
const base64 = this.themeObject.map(JSON.stringify).map(btoa);
this.url = base64.map((data) => `${window.location.origin}/index.html?userlayout=true#` + data);
const self = this;
this.allQuestionFields = [
this.JsonField(this.themeObject, "Name of this theme", "name", jsonObjectRoot),
this.JsonField(this.themeObject, "Title (shown in the window and in the welcome message)", "title", jsonObjectRoot),
this.JsonField(this.themeObject, "Description (shown in the welcome message and various other places)", "description", jsonObjectRoot),
this.JsonField(this.themeObject, "The supported language", "language", jsonObjectRoot),
this.JsonField(this.themeObject, "startLat", "startLat", jsonObjectRoot),
this.JsonField(this.themeObject, "startLon", "startLon", jsonObjectRoot),
this.JsonField(this.themeObject, "startzoom", "startZoom", jsonObjectRoot),
this.JsonField(this.themeObject, "icon: either a URL to an image file, a relative url to a MapComplete asset ('./asset/help.svg') or a base64-encoded value (including 'data:image/svg+xml;base64,'", "icon", jsonObjectRoot, "./assets/bug.svg"),
new AllLayerComponent(this.themeObject, self.JsonField)
]
}
private JsonField(themeObject: UIEventSource<LayoutConfigJson>, label: string, key: string, root: any, deflt: string = "") {
const value = new UIEventSource<string>(TagsToString(root[key]) ?? deflt);
value.addCallback((v) => {
root[key] = v;
themeObject.ping(); // We assume the root is a part of the themeObject
})
return new Combine([
label,
new TextField<string>({
fromString: (str) => str,
toString: (str) => str,
value: value
})]);
}
InnerRender(): string {
if (!this.userDetails.data.loggedIn) {
return "Not logged in"
}
if (this.userDetails.data.csCount < 500) {
return "You need at least 500 changesets to create your own theme";
}
return new VerticalCombine([
// new VariableUiElement(this.themeObject.map(JSON.stringify)),
new VariableUiElement(this.url.map((url) => `Current URL: <a href="${url}" target="_blank">Click here to open</a>`)),
...this.allQuestionFields,
]).Render();
}
}