, layerConfig: LayerConfigJson) {
// Init some defaults
layerConfig.title = layerConfig.title ?? {
key: "*",
addExtraTags: "",
mappings: [],
question: "",
render: "Title",
type: "text"
};
layerConfig.title.key = "*";
layerConfig.icon = layerConfig.icon ?? {
key: "*",
addExtraTags: "",
mappings: [],
question: "",
render: "./assets/bug.svg",
type: "text"
};
layerConfig.icon.key = "*";
layerConfig.color = layerConfig.color ?? {
key: "*",
addExtraTags: "",
mappings: [],
question: "",
render: "#00f",
type: "text"
};
layerConfig.color.key = "*";
layerConfig.width = layerConfig.width?? {
key: "*",
addExtraTags: "",
mappings: [],
question: "",
render: "10",
type: "nat"
};
layerConfig.width.key = "*"
const self = this;
this.uielements = [
new FixedUiElement("A layer is a collection of related objects which have the same or very similar tags renderings. In general, all objects of one layer have the same icon (or at least very similar icons)
"),
createFieldUI("Name", "id", layerConfig, {description: "The name of this layer"}),
createFieldUI("A description of objects for this layer", "description", layerConfig, {description: "The description of this layer"}),
createFieldUI("Minimum zoom level", "minzoom", layerConfig, {
type: "nat",
deflt: "12",
description: "The minimum zoom level to start loading data. This is mainly limited by the expected number of objects: if there are a lot of objects, then pick something higher. A generous bounding box is put around the map, so some scrolling should be possible"
}),
createFieldUI("The tags to load from overpass", "overpassTags", layerConfig, {
type: "tags",
description: "Tags to load from overpass. " +
"The format is key=value&key0=value0&key1=value1, e.g. amenity=public_bookcase or amenity=compressed_air&bicycle=yes." +
"Special values are:" +
"" +
"- key=* to indicate that this key can be anything
. " +
"- key= means 'key is NOT present'
" +
"- key!=value means 'key does NOT have this value'
" +
"- key~=regex indicates a regex, e.g. highway~=residential|tertiary
"+
"
"+
". E.g. something that is indoor, not private and has no name tag can be queried as indoor=yes&name=&access!=private"
}),
createFieldUI("Wayhandling","wayHandling", layerConfig, {
type:"wayhandling",
description: "Specifies how ways (lines and areas) are handled: either the way is shown, a center point is shown or both"
}),
new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.title, {
header: "Title element",
description: "This element is shown in the title of the popup in a header-tag",
removable: false,
hideQuestion: true
}),
new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.icon , {
header: "Icon",
description: "This decides which icon is used to represent an element on the map. Leave blank if you don't want icons to pop up",
removable: false,
hideQuestion: true
}),
new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.color, {
header: "Colour",
description: "This decides which color is used to represent a way on the map. Note that if an icon is defined as well, the icon will be showed too",
removable: false,
hideQuestion: true
}),
new TagRenderingGenerator(fullConfig, layerConfig, layerConfig.width , {
header: "Line thickness",
description: "This decides the line thickness of ways (in pixels)",
removable: false,
hideQuestion: true
}),
...layerConfig.tagRenderings.map(tr => new TagRenderingGenerator(fullConfig, layerConfig, tr, {
header: "Tag rendering",
description: "A single tag rendering",
removable: true,
hideQuestion: false
})),
new Button("Add a tag rendering", () => {
layerConfig.tagRenderings.push({
key: undefined,
addExtraTags: undefined,
mappings: [],
question: undefined,
render: undefined,
type: "text"
});
self.CreateElements(fullConfig, layerConfig);
self.Update();
}),
...layerConfig.presets.map(preset => new PresetGenerator(fullConfig, layerConfig, preset)),
new Button("Add a preset", () => {
layerConfig.presets.push({
icon: undefined,
title: "",
description: "",
tags: TagsToString(layerConfig.overpassTags)
});
self.CreateElements(fullConfig, layerConfig);
self.Update();
}),
new Button("Remove this layer", () => {
const layers = fullConfig.data.layers;
for (let i = 0; i < layers.length; i++) {
if(layers[i] === layerConfig){
layers.splice(i, 1);
break;
}
}
self.Update();
pingThemeObject();
})
]
}
InnerRender(): string {
return new VerticalCombine(this.uielements).Render();
}
}
class AllLayerComponent extends UIElement {
private tabs: TabbedComponent;
private config: UIEventSource;
constructor(config: UIEventSource) {
super(undefined);
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 iconUrl = CustomLayoutFromJSON.TagRenderingFromJson(layer?.icon)
?.GetContent({id: "node/-1"});
const header = this.config.map(() => {
return ``
});
layerPanes.push({
header: new VariableUiElement(header),
content: new LayerGenerator(config, layer)
})
}
layerPanes.push({
header: "",
content: new Button("Add a new layer", () => {
config.data.layers.push({
id: "",
title: {
key: "*",
render: "Title"
},
icon: {
key: "*",
render: "./assets/bug.svg"
},
color: {
key: "*",
render: "#0000ff"
},
width: {
key:"*",
render: "10"
},
description: "",
minzoom: 12,
overpassTags: "",
wayHandling: LayerDefinition.WAYHANDLING_CENTER_AND_WAY,
presets: [],
tagRenderings: []
});
config.ping();
})
})
this.tabs = new TabbedComponent(layerPanes);
}
InnerRender(): string {
return this.tabs.Render();
}
}
export class ThemeGenerator extends UIElement {
private readonly userDetails: UIEventSource;
public readonly themeObject: UIEventSource;
private readonly allQuestionFields: UIElement[];
public url: UIEventSource;
private loginButton: Button
constructor(connection: OsmConnection, windowHash) {
super(connection.userDetails);
this.userDetails = connection.userDetails;
this.loginButton = new Button("Log in with OSM", () => {
connection.AttemptLogin()
})
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(loadedTheme ?? defaultTheme);
const jsonObjectRoot = this.themeObject.data;
connection.userDetails.addCallback((userDetails) => {
jsonObjectRoot.maintainer = userDetails.name;
});
jsonObjectRoot.maintainer = connection.userDetails.data.name;
const base64 = this.themeObject.map(JSON.stringify).map(btoa);
let baseUrl = "https://pietervdvn.github.io/MapComplete";
if (window.location.hostname === "127.0.0.1") {
baseUrl = "http://127.0.0.1:1234";
}
this.url = base64.map((data) => `${baseUrl}/index.html?test=true&userlayout=${this.themeObject.data.name}#${data}`);
const self = this;
pingThemeObject = () => {self.themeObject.ping()};
createFieldUI = (label, key, root, options) => {
options = options ?? {description: "?"};
options.type = options.type ?? "string";
const value = new UIEventSource(TagsToString(root[key]) ?? options?.deflt);
let textField: UIElement;
if (options.type === "typeSelector") {
const options: { value: string, shown: string | UIElement }[] = [];
for (const possibleType in ValidatedTextField.inputValidation) {
if (possibleType !== "$") {
options.push({value: possibleType, shown: possibleType});
}
}
textField = new DropDown("",
options,
value)
} else if (options.type === "wayhandling") {
const options: { value: string, shown: string | UIElement }[] =
[{value: "" + LayerDefinition.WAYHANDLING_DEFAULT, shown: "Show a line/area as line/area"},
{
value: "" + LayerDefinition.WAYHANDLING_CENTER_AND_WAY,
shown: "Show a line/area as line/area AND show an icon at the center"
},
{
value: "" + LayerDefinition.WAYHANDLING_CENTER_ONLY,
shown: "Only show the centerpoint of a way"
}];
textField = new DropDown("",
options,
value)
} else if (options.type === "key") {
textField = new TextField({
placeholder: "single key",
startValidated: false,
value:value,
toString: str => str,
fromString: str => {
if(str === undefined){
return "";
}
if (str === "*") {
return str;
}
str = str.trim();
if (str.match("^_*[a-zA-Z]*[a-zA-Z0-9:_]*$") == null) {
return undefined;
}
return str;
}
})
} else if (options.type === "tags") {
textField = ValidatedTextField.TagTextField(value.map(CustomLayoutFromJSON.TagsFromJson, [], tags => {
if (tags === undefined) {
return undefined;
}
return tags.map((tag: Tag) => tag.key + "=" + tag.value).join("&");
}), options?.emptyAllowed ?? false);
} else if (options.type === "img" || options.type === "colour") {
textField = new TextField({
placeholder: options.type,
fromString: (str) => str,
toString: (str) => str,
value: value,
startValidated: true
});
} else if (options.type) {
textField = ValidatedTextField.ValidatedTextField(options.type, {value: value});
} else {
textField = new TextField({
placeholder: options.type,
fromString: (str) => str,
toString: (str) => str,
value: value,
startValidated: true
});
}
let sendingPing = false;
value.addCallback((v) => {
if (v === undefined || v === "") {
delete root[key];
} else {
root[key] = v;
}
if(!sendingPing){
sendingPing = true;
self.themeObject.ping(); // We assume the root is a part of the themeObject
sendingPing = false;
}
});
self.themeObject.addCallback(() => {
value.setData(root[key]);
})
return new Combine([
label,
textField,
"
",
"" + options.description + ""
]);
}
this.allQuestionFields = [
createFieldUI("Name of this theme", "name", jsonObjectRoot, {description: "An identifier for this theme"}),
createFieldUI("Title", "title", jsonObjectRoot, {
deflt: "Title",
description: "The title of this theme, as shown in the welcome message and in the title bar of the browser"
}),
createFieldUI("icon", "icon", jsonObjectRoot, {
deflt: "./assets/bug.svg",
type: "img",
description: "The icon representing this MapComplete instance. It is shown in the welcome message and -if adopted as official theme- used as favicon and to browse themes"
}),
createFieldUI("Description", "description", jsonObjectRoot, {
description: "Shown in the welcome message",
deflt: "Description"
}),
createFieldUI("The supported language", "language", jsonObjectRoot, {
description: "The language of this mapcomplete instance. MapComplete can be translated, see here for more information",
deflt: "en"
}),
createFieldUI("startLat", "startLat", jsonObjectRoot, {
type: "float",
deflt: "0",
description: "The latitude where this theme should start. Note that this is only for completely fresh users, as the last location is saved"
}),
createFieldUI("startLon", "startLon", jsonObjectRoot, {
type: "float",
deflt: "0",
description: "The longitude where this theme should start. Note that this is only for completely fresh users, as the last location is saved"
}),
createFieldUI("startzoom", "startZoom", jsonObjectRoot, {
type: "nat",
deflt: "12",
description: "The initial zoom level where the map is located"
}),
createFieldUI("Query widening factor", "widenFactor", jsonObjectRoot, {
type: "pfloat",
deflt: "0.05",
description: "When a query is run, the current map view is taken and a margin with a certain factor is added to allow panning and zooming. If you are running heavy queries (e.g. highway=residential), to much data is returned. In that case, lower the widenfactor, e.g. to 0.01-0.02"
}),
new AllLayerComponent(this.themeObject)
]
}
InnerRender(): string {
if (!this.userDetails.data.loggedIn) {
return new Combine(["Not logged in. You need to be logged in to create a theme.", this.loginButton]).Render();
}
if (this.userDetails.data.csCount < State.userJourney.themeGeneratorUnlock ) {
return `You need at least ${State.userJourney.themeGeneratorUnlock} changesets to create your own theme.`;
}
return new VerticalCombine([
...this.allQuestionFields,
]).Render();
}
}