Add custom theme generator
This commit is contained in:
parent
14930e2f93
commit
8d3c8ed9d9
8 changed files with 570 additions and 16 deletions
|
@ -10,18 +10,78 @@ import FixedText from "../Questions/FixedText";
|
|||
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 {
|
||||
|
||||
public static exampleLayer = {
|
||||
public static exampleLayer: LayerConfigJson = {
|
||||
id: "bookcase",
|
||||
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",
|
||||
minzoom: 12,
|
||||
color: "#0000ff",
|
||||
overpassTags: "amenity=public_bookcase",
|
||||
presets: [
|
||||
{
|
||||
title: "bookcase"
|
||||
// icon: optional. Uses the layer icon by default
|
||||
// title: optional. Uses the layer title by default
|
||||
// description: optional. Uses the layer description by default
|
||||
|
@ -55,7 +115,7 @@ export class CustomLayoutFromJSON {
|
|||
]
|
||||
}
|
||||
|
||||
public static exampleLayout = {
|
||||
public static exampleLayout: LayoutConfigJson = {
|
||||
name: "bookcases",
|
||||
title: "Custom Open bookcases map",
|
||||
description: "Welcome to a custom layout",
|
||||
|
@ -84,7 +144,7 @@ export class CustomLayoutFromJSON {
|
|||
}
|
||||
|
||||
let freeform = undefined;
|
||||
if (json.key !== undefined && json.render !== undefined) {
|
||||
if (json.key !== undefined && json.key !== "" && json.render !== undefined) {
|
||||
const type = json.type ?? "text";
|
||||
freeform = {
|
||||
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) {
|
||||
return undefined;
|
||||
}
|
||||
console.log(json)
|
||||
if (typeof (json) === "string") {
|
||||
const kv = json.split("=");
|
||||
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())
|
||||
}
|
||||
|
||||
private static TagsFromJson(json: any): Tag[] {
|
||||
if (json === undefined) {
|
||||
private static TagsFromJson(json: string | { k: string, v: string }[]): Tag[] {
|
||||
if (json === undefined || json === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof (json) === "string") {
|
||||
|
|
|
@ -21,11 +21,10 @@ export class OsmConnection {
|
|||
public userDetails: UIEventSource<UserDetails>;
|
||||
private _dryRun: boolean;
|
||||
|
||||
constructor(dryRun: boolean, oauth_token: UIEventSource<string>) {
|
||||
constructor(dryRun: boolean, oauth_token: UIEventSource<string>, singlePage: boolean = true) {
|
||||
|
||||
let pwaStandAloneMode = false;
|
||||
try {
|
||||
|
||||
if (window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches) {
|
||||
pwaStandAloneMode = true;
|
||||
}
|
||||
|
@ -36,7 +35,7 @@ export class OsmConnection {
|
|||
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...
|
||||
// Same for an iframe...
|
||||
this.auth = new osmAuth({
|
||||
|
|
3
State.ts
3
State.ts
|
@ -24,7 +24,7 @@ export class State {
|
|||
// The singleton of the global state
|
||||
public static state: State;
|
||||
|
||||
public static vNumber = "0.0.3";
|
||||
public static vNumber = "0.0.4";
|
||||
|
||||
public static runningFromConsole: boolean = false;
|
||||
|
||||
|
@ -32,6 +32,7 @@ export class State {
|
|||
THe layout to use
|
||||
*/
|
||||
public readonly layoutToUse = new UIEventSource<Layout>(undefined);
|
||||
public layoutDefinition : string;
|
||||
|
||||
/**
|
||||
The mapping from id -> UIEventSource<properties>
|
||||
|
|
|
@ -22,6 +22,7 @@ export class ShareScreen extends UIElement {
|
|||
private _iframeCode: UIElement;
|
||||
private _link: UIElement;
|
||||
private _linkStatus: UIElement;
|
||||
private _editLayout: UIElement;
|
||||
|
||||
constructor() {
|
||||
super(undefined)
|
||||
|
@ -129,11 +130,16 @@ export class ShareScreen extends UIElement {
|
|||
|
||||
const parts = Utils.NoNull(optionParts.map((eventSource) => eventSource.data));
|
||||
|
||||
if (parts.length === 0) {
|
||||
return literalText;
|
||||
let hash = "";
|
||||
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);
|
||||
this._iframeCode = new VariableUiElement(
|
||||
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(" ");
|
||||
this._linkStatus = new VariableUiElement(status);
|
||||
|
@ -200,7 +213,8 @@ export class ShareScreen extends UIElement {
|
|||
tr.addToHomeScreen,
|
||||
tr.embedIntro,
|
||||
this._options,
|
||||
this._iframeCode
|
||||
this._iframeCode,
|
||||
this._editLayout
|
||||
]).Render()
|
||||
}
|
||||
|
||||
|
|
58
customGenerator.html
Normal file
58
customGenerator.html
Normal 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
20
customGenerator.ts
Normal 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");
|
5
index.ts
5
index.ts
|
@ -70,7 +70,7 @@ let layoutToUse: Layout = AllKnownLayouts.allSets[defaultLayout] ?? AllKnownLayo
|
|||
|
||||
|
||||
const layoutFromBase64 = QueryParameters.GetQueryParameter("userlayout", "false").data;
|
||||
if(layoutFromBase64 === "true"){
|
||||
if(layoutFromBase64 !== "false"){
|
||||
layoutToUse = CustomLayoutFromJSON.FromQueryParam(hash.substr(1));
|
||||
}
|
||||
|
||||
|
@ -86,6 +86,9 @@ console.log("Using layout: ", layoutToUse.name);
|
|||
|
||||
TagRendering.injectFunction();
|
||||
State.state = new State(layoutToUse);
|
||||
if(layoutFromBase64 !== "false"){
|
||||
State.state.layoutDefinition = hash.substr(1);
|
||||
}
|
||||
InitUiElements.InitBaseMap();
|
||||
|
||||
new FixedUiElement("").AttachTo("decoration"); // Remove the decoration
|
||||
|
|
398
themeGenerator.ts
Normal file
398
themeGenerator.ts
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue