reset to previous commit

This commit is contained in:
karelleketers 2021-07-26 15:39:27 +02:00
parent d5b614fc44
commit 196d40084d
90 changed files with 4953 additions and 1922 deletions

View file

@ -3,6 +3,7 @@ import UnitConfigJson from "./UnitConfigJson";
import Translations from "../../UI/i18n/Translations";
import BaseUIElement from "../../UI/BaseUIElement";
import Combine from "../../UI/Base/Combine";
import {FixedUiElement} from "../../UI/Base/FixedUiElement";
export class Unit {
public readonly appliesToKeys: Set<string>;
@ -81,7 +82,10 @@ export class Unit {
return undefined;
}
const [stripped, denom] = this.findDenomination(value)
const human = denom.human
const human = denom?.human
if(human === undefined){
return new FixedUiElement(stripped ?? value);
}
const elems = denom.prefix ? [human, stripped] : [stripped, human];
return new Combine(elems)
@ -152,7 +156,7 @@ export class Denomination {
if (stripped === null) {
return null;
}
return stripped + " " + this.canonical.trim()
return (stripped + " " + this.canonical.trim()).trim();
}
/**

View file

@ -0,0 +1,27 @@
import { TagsFilter } from "../../Logic/Tags/TagsFilter";
import { Translation } from "../../UI/i18n/Translation";
import Translations from "../../UI/i18n/Translations";
import FilterConfigJson from "./FilterConfigJson";
import { FromJSON } from "./FromJSON";
export default class FilterConfig {
readonly options: {
question: Translation;
osmTags: TagsFilter;
}[];
constructor(json: FilterConfigJson, context: string) {
this.options = json.options.map((option, i) => {
const question = Translations.T(
option.question,
context + ".options-[" + i + "].question"
);
const osmTags = FromJSON.Tag(
option.osmTags,
`${context}.options-[${i}].osmTags`
);
return { question: question, osmTags: osmTags };
});
}
}

View file

@ -0,0 +1,11 @@
import { AndOrTagConfigJson } from "./TagConfigJson";
export default interface FilterConfigJson {
/**
* The options for a filter
* If there are multiple options these will be a list of radio buttons
* If there is only one option this will be a checkbox
* Filtering is done based on the given osmTags that are compared to the objects in that layer.
*/
options: { question: string | any; osmTags: AndOrTagConfigJson | string }[];
}

View file

@ -18,19 +18,18 @@ import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../../UI/BaseUIElement";
import {Unit} from "./Denomination";
import DeleteConfig from "./DeleteConfig";
import FilterConfig from "./FilterConfig";
export default class LayerConfig {
static WAYHANDLING_DEFAULT = 0;
static WAYHANDLING_CENTER_ONLY = 1;
static WAYHANDLING_CENTER_AND_WAY = 2;
id: string;
name: Translation
name: Translation;
description: Translation;
source: SourceConfig;
calculatedTags: [string, string][]
calculatedTags: [string, string][];
doNotDownload: boolean;
passAllFeatures: boolean;
isShown: TagRenderingConfig;
@ -39,7 +38,7 @@ export default class LayerConfig {
title?: TagRenderingConfig;
titleIcons: TagRenderingConfig[];
icon: TagRenderingConfig;
iconOverlays: { if: TagsFilter, then: TagRenderingConfig, badge: boolean }[]
iconOverlays: { if: TagsFilter; then: TagRenderingConfig; badge: boolean }[];
iconSize: TagRenderingConfig;
label: TagRenderingConfig;
rotation: TagRenderingConfig;
@ -48,33 +47,40 @@ export default class LayerConfig {
dashArray: TagRenderingConfig;
wayHandling: number;
public readonly units: Unit[];
public readonly deletion: DeleteConfig | null
public readonly deletion: DeleteConfig | null;
presets: {
title: Translation,
tags: Tag[],
description?: Translation,
preciseInput?: { preferredBackground?: string }
}[];
tagRenderings: TagRenderingConfig [];
tagRenderings: TagRenderingConfig[];
filters: FilterConfig[];
constructor(json: LayerConfigJson,
units?:Unit[],
context?: string,
official: boolean = true,) {
constructor(
json: LayerConfigJson,
units?: Unit[],
context?: string,
official: boolean = true
) {
this.units = units ?? [];
context = context + "." + json.id;
const self = this;
this.id = json.id;
this.name = Translations.T(json.name, context + ".name");
if(json.description !== undefined){
if(Object.keys(json.description).length === 0){
if (json.description !== undefined) {
if (Object.keys(json.description).length === 0) {
json.description = undefined;
}
}
this.description =Translations.T(json.description, context + ".description") ;
this.description = Translations.T(
json.description,
context + ".description"
);
let legacy = undefined;
if (json["overpassTags"] !== undefined) {
@ -83,45 +89,54 @@ export default class LayerConfig {
}
if (json.source !== undefined) {
if (legacy !== undefined) {
throw context + "Both the legacy 'layer.overpasstags' and the new 'layer.source'-field are defined"
throw (
context +
"Both the legacy 'layer.overpasstags' and the new 'layer.source'-field are defined"
);
}
let osmTags: TagsFilter = legacy;
if (json.source["osmTags"]) {
osmTags = FromJSON.Tag(json.source["osmTags"], context + "source.osmTags");
osmTags = FromJSON.Tag(
json.source["osmTags"],
context + "source.osmTags"
);
}
if(json.source["geoJsonSource"] !== undefined){
throw context + "Use 'geoJson' instead of 'geoJsonSource'"
if (json.source["geoJsonSource"] !== undefined) {
throw context + "Use 'geoJson' instead of 'geoJsonSource'";
}
this.source = new SourceConfig({
osmTags: osmTags,
geojsonSource: json.source["geoJson"],
geojsonSourceLevel: json.source["geoJsonZoomLevel"],
overpassScript: json.source["overpassScript"],
isOsmCache: json.source["isOsmCache"]
}, this.id);
this.source = new SourceConfig(
{
osmTags: osmTags,
geojsonSource: json.source["geoJson"],
geojsonSourceLevel: json.source["geoJsonZoomLevel"],
overpassScript: json.source["overpassScript"],
isOsmCache: json.source["isOsmCache"],
},
this.id
);
} else {
this.source = new SourceConfig({
osmTags: legacy
})
osmTags: legacy,
});
}
this.calculatedTags = undefined;
if (json.calculatedTags !== undefined) {
if (!official) {
console.warn(`Unofficial theme ${this.id} with custom javascript! This is a security risk`)
console.warn(
`Unofficial theme ${this.id} with custom javascript! This is a security risk`
);
}
this.calculatedTags = [];
for (const kv of json.calculatedTags) {
const index = kv.indexOf("=")
const index = kv.indexOf("=");
const key = kv.substring(0, index);
const code = kv.substring(index + 1);
this.calculatedTags.push([key, code])
this.calculatedTags.push([key, code]);
}
}
@ -130,13 +145,19 @@ export default class LayerConfig {
this.minzoom = json.minzoom ?? 0;
this.maxzoom = json.maxzoom ?? 1000;
this.wayHandling = json.wayHandling ?? 0;
this.presets = (json.presets ?? []).map((pr, i) =>
({
this.presets = (json.presets ?? []).map((pr, i) => {
if (pr.preciseInput === true) {
pr.preciseInput = {
preferredBackground: undefined
}
}
return {
title: Translations.T(pr.title, `${context}.presets[${i}].title`),
tags: pr.tags.map(t => FromJSON.SimpleTag(t)),
description: Translations.T(pr.description, `${context}.presets[${i}].description`)
}))
tags: pr.tags.map((t) => FromJSON.SimpleTag(t)),
description: Translations.T(pr.description, `${context}.presets[${i}].description`),
preciseInput: pr.preciseInput
}
});
/** Given a key, gets the corresponding property from the json (or the default if not found
*
@ -148,7 +169,11 @@ export default class LayerConfig {
if (deflt === undefined) {
return undefined;
}
return new TagRenderingConfig(deflt, self.source.osmTags, `${context}.${key}.default value`);
return new TagRenderingConfig(
deflt,
self.source.osmTags,
`${context}.${key}.default value`
);
}
if (typeof v === "string") {
const shared = SharedTagRenderings.SharedTagRendering.get(v);
@ -156,54 +181,80 @@ export default class LayerConfig {
return shared;
}
}
return new TagRenderingConfig(v, self.source.osmTags, `${context}.${key}`);
return new TagRenderingConfig(
v,
self.source.osmTags,
`${context}.${key}`
);
}
/**
* Converts a list of tagRenderingCOnfigJSON in to TagRenderingConfig
* A string is interpreted as a name to call
*/
function trs(tagRenderings?: (string | TagRenderingConfigJson)[], readOnly = false) {
function trs(
tagRenderings?: (string | TagRenderingConfigJson)[],
readOnly = false
) {
if (tagRenderings === undefined) {
return [];
}
return Utils.NoNull(tagRenderings.map(
(renderingJson, i) => {
return Utils.NoNull(
tagRenderings.map((renderingJson, i) => {
if (typeof renderingJson === "string") {
if (renderingJson === "questions") {
if (readOnly) {
throw `A tagrendering has a question, but asking a question does not make sense here: is it a title icon or a geojson-layer? ${context}. The offending tagrendering is ${JSON.stringify(renderingJson)}`
throw `A tagrendering has a question, but asking a question does not make sense here: is it a title icon or a geojson-layer? ${context}. The offending tagrendering is ${JSON.stringify(
renderingJson
)}`;
}
return new TagRenderingConfig("questions", undefined)
return new TagRenderingConfig("questions", undefined);
}
const shared = SharedTagRenderings.SharedTagRendering.get(renderingJson);
const shared =
SharedTagRenderings.SharedTagRendering.get(renderingJson);
if (shared !== undefined) {
return shared;
}
const keys = Array.from(SharedTagRenderings.SharedTagRendering.keys())
if(Utils.runningFromConsole){
const keys = Array.from(
SharedTagRenderings.SharedTagRendering.keys()
);
if (Utils.runningFromConsole) {
return undefined;
}
throw `Predefined tagRendering ${renderingJson} not found in ${context}.\n Try one of ${(keys.join(", "))}\n If you intent to output this text literally, use {\"render\": <your text>} instead"}`;
throw `Predefined tagRendering ${renderingJson} not found in ${context}.\n Try one of ${keys.join(
", "
)}\n If you intent to output this text literally, use {\"render\": <your text>} instead"}`;
}
return new TagRenderingConfig(renderingJson, self.source.osmTags, `${context}.tagrendering[${i}]`);
}));
return new TagRenderingConfig(
renderingJson,
self.source.osmTags,
`${context}.tagrendering[${i}]`
);
})
);
}
this.tagRenderings = trs(json.tagRenderings, false);
this.filters = (json.filter ?? []).map((option, i) => {
return new FilterConfig(option, `${context}.filter-[${i}]`)
});
const titleIcons = [];
const defaultIcons = ["phonelink", "emaillink", "wikipedialink", "osmlink", "sharelink"];
for (const icon of (json.titleIcons ?? defaultIcons)) {
const defaultIcons = [
"phonelink",
"emaillink",
"wikipedialink",
"osmlink",
"sharelink",
];
for (const icon of json.titleIcons ?? defaultIcons) {
if (icon === "defaults") {
titleIcons.push(...defaultIcons);
} else {
@ -213,74 +264,85 @@ export default class LayerConfig {
this.titleIcons = trs(titleIcons, true);
this.title = tr("title", undefined);
this.icon = tr("icon", "");
this.iconOverlays = (json.iconOverlays ?? []).map((overlay, i) => {
let tr = new TagRenderingConfig(overlay.then, self.source.osmTags, `iconoverlays.${i}`);
if (typeof overlay.then === "string" && SharedTagRenderings.SharedIcons.get(overlay.then) !== undefined) {
let tr = new TagRenderingConfig(
overlay.then,
self.source.osmTags,
`iconoverlays.${i}`
);
if (
typeof overlay.then === "string" &&
SharedTagRenderings.SharedIcons.get(overlay.then) !== undefined
) {
tr = SharedTagRenderings.SharedIcons.get(overlay.then);
}
return {
if: FromJSON.Tag(overlay.if),
then: tr,
badge: overlay.badge ?? false
}
badge: overlay.badge ?? false,
};
});
const iconPath = this.icon.GetRenderValue({id: "node/-1"}).txt;
if (iconPath.startsWith(Utils.assets_path)) {
const iconKey = iconPath.substr(Utils.assets_path.length);
if (Svg.All[iconKey] === undefined) {
throw "Builtin SVG asset not found: " + iconPath
throw "Builtin SVG asset not found: " + iconPath;
}
}
this.isShown = tr("isShown", "yes");
this.iconSize = tr("iconSize", "40,40,center");
this.label = tr("label", "")
this.label = tr("label", "");
this.color = tr("color", "#0000ff");
this.width = tr("width", "7");
this.rotation = tr("rotation", "0");
this.dashArray = tr("dashArray", "");
this.deletion = null;
if(json.deletion === true){
json.deletion = {
}
}
if(json.deletion !== undefined && json.deletion !== false){
this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`)
}
this.deletion = null;
if (json.deletion === true) {
json.deletion = {};
}
if (json.deletion !== undefined && json.deletion !== false) {
this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`);
}
if (json["showIf"] !== undefined) {
throw "Invalid key on layerconfig " + this.id + ": showIf. Did you mean 'isShown' instead?";
throw (
"Invalid key on layerconfig " +
this.id +
": showIf. Did you mean 'isShown' instead?"
);
}
}
public CustomCodeSnippets(): string[] {
if (this.calculatedTags === undefined) {
return []
return [];
}
return this.calculatedTags.map(code => code[1]);
return this.calculatedTags.map((code) => code[1]);
}
public AddRoamingRenderings(addAll: {
tagRenderings: TagRenderingConfig[],
titleIcons: TagRenderingConfig[],
iconOverlays: { "if": TagsFilter, then: TagRenderingConfig, badge: boolean }[]
tagRenderings: TagRenderingConfig[];
titleIcons: TagRenderingConfig[];
iconOverlays: {
if: TagsFilter;
then: TagRenderingConfig;
badge: boolean;
}[];
}): LayerConfig {
let insertionPoint = this.tagRenderings.map(tr => tr.IsQuestionBoxElement()).indexOf(true)
let insertionPoint = this.tagRenderings
.map((tr) => tr.IsQuestionBoxElement())
.indexOf(true);
if (insertionPoint < 0) {
// No 'questions' defined - we just add them all to the end
insertionPoint = this.tagRenderings.length;
}
this.tagRenderings.splice(insertionPoint, 0, ...addAll.tagRenderings);
this.iconOverlays.push(...addAll.iconOverlays);
for (const icon of addAll.titleIcons) {
this.titleIcons.splice(0, 0, icon);
@ -289,40 +351,42 @@ export default class LayerConfig {
}
public GetRoamingRenderings(): {
tagRenderings: TagRenderingConfig[],
titleIcons: TagRenderingConfig[],
iconOverlays: { "if": TagsFilter, then: TagRenderingConfig, badge: boolean }[]
tagRenderings: TagRenderingConfig[];
titleIcons: TagRenderingConfig[];
iconOverlays: {
if: TagsFilter;
then: TagRenderingConfig;
badge: boolean;
}[];
} {
const tagRenderings = this.tagRenderings.filter(tr => tr.roaming);
const titleIcons = this.titleIcons.filter(tr => tr.roaming);
const iconOverlays = this.iconOverlays.filter(io => io.then.roaming)
const tagRenderings = this.tagRenderings.filter((tr) => tr.roaming);
const titleIcons = this.titleIcons.filter((tr) => tr.roaming);
const iconOverlays = this.iconOverlays.filter((io) => io.then.roaming);
return {
tagRenderings: tagRenderings,
titleIcons: titleIcons,
iconOverlays: iconOverlays
}
iconOverlays: iconOverlays,
};
}
public GenerateLeafletStyle(tags: UIEventSource<any>, clickable: boolean, widthHeight= "100%"):
{
icon:
{
html: BaseUIElement,
iconSize: [number, number],
iconAnchor: [number, number],
popupAnchor: [number, number],
iconUrl: string,
className: string
},
color: string,
weight: number,
dashArray: number[]
} {
public GenerateLeafletStyle(
tags: UIEventSource<any>,
clickable: boolean,
widthHeight = "100%"
): {
icon: {
html: BaseUIElement;
iconSize: [number, number];
iconAnchor: [number, number];
popupAnchor: [number, number];
iconUrl: string;
className: string;
};
color: string;
weight: number;
dashArray: number[];
} {
function num(str, deflt = 40) {
const n = Number(str);
if (isNaN(n)) {
@ -341,7 +405,7 @@ export default class LayerConfig {
}
function render(tr: TagRenderingConfig, deflt?: string) {
const str = (tr?.GetRenderValue(tags.data)?.txt ?? deflt);
const str = tr?.GetRenderValue(tags.data)?.txt ?? deflt;
return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, "");
}
@ -350,14 +414,16 @@ export default class LayerConfig {
let color = render(this.color, "#00f");
if (color.startsWith("--")) {
color = getComputedStyle(document.body).getPropertyValue("--catch-detail-color")
color = getComputedStyle(document.body).getPropertyValue(
"--catch-detail-color"
);
}
const weight = rendernum(this.width, 5);
const iconW = num(iconSize[0]);
let iconH = num(iconSize[1]);
const mode = iconSize[2]?.trim()?.toLowerCase() ?? "center"
const mode = iconSize[2]?.trim()?.toLowerCase() ?? "center";
let anchorW = iconW / 2;
let anchorH = iconH / 2;
@ -377,31 +443,35 @@ export default class LayerConfig {
const iconUrlStatic = render(this.icon);
const self = this;
const mappedHtml = tags.map(tgs => {
const mappedHtml = tags.map((tgs) => {
function genHtmlFromString(sourcePart: string): BaseUIElement {
const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`;
let html: BaseUIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`);
const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/)
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([
(Svg.All[match[1] + ".svg"] as string)
.replace(/#000000/g, match[2])
(Svg.All[match[1] + ".svg"] as string).replace(
/#000000/g,
match[2]
),
]).SetStyle(style);
}
return html;
}
// What do you mean, 'tgs' is never read?
// It is read implicitly in the 'render' method
const iconUrl = render(self.icon);
const rotation = render(self.rotation, "0deg");
let htmlParts: BaseUIElement[] = [];
let sourceParts = Utils.NoNull(iconUrl.split(";").filter(prt => prt != ""));
let sourceParts = Utils.NoNull(
iconUrl.split(";").filter((prt) => prt != "")
);
for (const sourcePart of sourceParts) {
htmlParts.push(genHtmlFromString(sourcePart))
htmlParts.push(genHtmlFromString(sourcePart));
}
let badges = [];
@ -411,79 +481,88 @@ export default class LayerConfig {
}
if (iconOverlay.badge) {
const badgeParts: BaseUIElement[] = [];
const partDefs = iconOverlay.then.GetRenderValue(tgs).txt.split(";").filter(prt => prt != "");
const partDefs = iconOverlay.then
.GetRenderValue(tgs)
.txt.split(";")
.filter((prt) => prt != "");
for (const badgePartStr of partDefs) {
badgeParts.push(genHtmlFromString(badgePartStr))
badgeParts.push(genHtmlFromString(badgePartStr));
}
const badgeCompound = new Combine(badgeParts)
.SetStyle("display:flex;position:relative;width:100%;height:100%;");
badges.push(badgeCompound)
const badgeCompound = new Combine(badgeParts).SetStyle(
"display:flex;position:relative;width:100%;height:100%;"
);
badges.push(badgeCompound);
} else {
htmlParts.push(genHtmlFromString(
iconOverlay.then.GetRenderValue(tgs).txt));
htmlParts.push(
genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt)
);
}
}
if (badges.length > 0) {
const badgesComponent = new Combine(badges)
.SetStyle("display:flex;height:50%;width:100%;position:absolute;top:50%;left:50%;");
htmlParts.push(badgesComponent)
const badgesComponent = new Combine(badges).SetStyle(
"display:flex;height:50%;width:100%;position:absolute;top:50%;left:50%;"
);
htmlParts.push(badgesComponent);
}
if (sourceParts.length == 0) {
iconH = 0
iconH = 0;
}
try {
const label = self.label?.GetRenderValue(tgs)?.Subs(tgs)
const label = self.label
?.GetRenderValue(tgs)
?.Subs(tgs)
?.SetClass("block text-center")
?.SetStyle("margin-top: " + (iconH + 2) + "px")
?.SetStyle("margin-top: " + (iconH + 2) + "px");
if (label !== undefined) {
htmlParts.push(new Combine([label]).SetClass("flex flex-col items-center"))
htmlParts.push(
new Combine([label]).SetClass("flex flex-col items-center")
);
}
} catch (e) {
console.error(e, tgs)
console.error(e, tgs);
}
return new Combine(htmlParts);
})
});
return {
icon:
{
html: new VariableUiElement(mappedHtml),
iconSize: [iconW, iconH],
iconAnchor: [anchorW, anchorH],
popupAnchor: [0, 3 - anchorH],
iconUrl: iconUrlStatic,
className: clickable ? "leaflet-div-icon" : "leaflet-div-icon unclickable"
},
icon: {
html: new VariableUiElement(mappedHtml),
iconSize: [iconW, iconH],
iconAnchor: [anchorW, anchorH],
popupAnchor: [0, 3 - anchorH],
iconUrl: iconUrlStatic,
className: clickable
? "leaflet-div-icon"
: "leaflet-div-icon unclickable",
},
color: color,
weight: weight,
dashArray: dashArray
dashArray: dashArray,
};
}
public ExtractImages(): Set<string> {
const parts: Set<string>[] = []
parts.push(...this.tagRenderings?.map(tr => tr.ExtractImages(false)))
parts.push(...this.titleIcons?.map(tr => tr.ExtractImages(true)))
parts.push(this.icon?.ExtractImages(true))
parts.push(...this.iconOverlays?.map(overlay => overlay.then.ExtractImages(true)))
const parts: Set<string>[] = [];
parts.push(...this.tagRenderings?.map((tr) => tr.ExtractImages(false)));
parts.push(...this.titleIcons?.map((tr) => tr.ExtractImages(true)));
parts.push(this.icon?.ExtractImages(true));
parts.push(
...this.iconOverlays?.map((overlay) => overlay.then.ExtractImages(true))
);
for (const preset of this.presets) {
parts.push(new Set<string>(preset.description?.ExtractImages(false)))
parts.push(new Set<string>(preset.description?.ExtractImages(false)));
}
const allIcons = new Set<string>();
for (const part of parts) {
part?.forEach(allIcons.add, allIcons)
part?.forEach(allIcons.add, allIcons);
}
return allIcons;
}
}
}

View file

@ -1,6 +1,7 @@
import {TagRenderingConfigJson} from "./TagRenderingConfigJson";
import {AndOrTagConfigJson} from "./TagConfigJson";
import {DeleteConfigJson} from "./DeleteConfigJson";
import FilterConfigJson from "./FilterConfigJson";
/**
* Configuration for a single layer
@ -217,6 +218,16 @@ export interface LayerConfigJson {
* (The first sentence is until the first '.'-character in the description)
*/
description?: string | any,
/**
* If set, the user will prompted to confirm the location before actually adding the data.
* THis will be with a 'drag crosshair'-method.
*
* If 'preferredBackgroundCategory' is set, the element will attempt to pick a background layer of that category.
*/
preciseInput?: true | {
preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string
}
}[],
/**
@ -233,6 +244,12 @@ export interface LayerConfigJson {
*/
tagRenderings?: (string | TagRenderingConfigJson) [],
/**
* All the extra questions for filtering
*/
filter?: (FilterConfigJson) [],
/**
* This block defines under what circumstances the delete dialog is shown for objects of this layer.
* If set, a dialog is shown to the user to (soft) delete the point.

View file

@ -42,6 +42,7 @@ export default class LayoutConfig {
public readonly enableGeolocation: boolean;
public readonly enableBackgroundLayerSelection: boolean;
public readonly enableShowAllQuestions: boolean;
public readonly enableExportButton: boolean;
public readonly customCss?: string;
/*
How long is the cache valid, in seconds?
@ -152,6 +153,7 @@ export default class LayoutConfig {
this.enableAddNewPoints = json.enableAddNewPoints ?? true;
this.enableBackgroundLayerSelection = json.enableBackgroundLayerSelection ?? true;
this.enableShowAllQuestions = json.enableShowAllQuestions ?? false;
this.enableExportButton = json.enableExportButton ?? false;
this.customCss = json.customCss;
this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60)

View file

@ -15,6 +15,7 @@ import UnitConfigJson from "./UnitConfigJson";
* General remark: a type (string | any) indicates either a fixed or a translatable string.
*/
export interface LayoutConfigJson {
/**
* The id of this layout.
*
@ -225,6 +226,10 @@ export interface LayoutConfigJson {
*
* Not only do we want to write consistent data to OSM, we also want to present this consistently to the user.
* This is handled by defining units.
*
* # Rendering
*
* To render a value with long (human) denomination, use {canonical(key)}
*
* # Usage
*
@ -331,4 +336,5 @@ export interface LayoutConfigJson {
enableGeolocation?: boolean;
enableBackgroundLayerSelection?: boolean;
enableShowAllQuestions?: boolean;
enableExportButton?: boolean;
}

View file

@ -26,6 +26,9 @@ export default class TagRenderingConfig {
readonly key: string,
readonly type: string,
readonly addExtraTags: TagsFilter[];
readonly inline: boolean,
readonly default?: string,
readonly helperArgs?: (string | number | boolean)[]
};
readonly multiAnswer: boolean;
@ -73,7 +76,9 @@ export default class TagRenderingConfig {
type: json.freeform.type ?? "string",
addExtraTags: json.freeform.addExtraTags?.map((tg, i) =>
FromJSON.Tag(tg, `${context}.extratag[${i}]`)) ?? [],
inline: json.freeform.inline ?? false,
default: json.freeform.default,
helperArgs: json.freeform.helperArgs
}
if (json.freeform["extraTags"] !== undefined) {
@ -332,20 +337,20 @@ export default class TagRenderingConfig {
* Note: this might be hidden by conditions
*/
public hasMinimap(): boolean {
const translations : Translation[]= Utils.NoNull([this.render, ...(this.mappings ?? []).map(m => m.then)]);
const translations: Translation[] = Utils.NoNull([this.render, ...(this.mappings ?? []).map(m => m.then)]);
for (const translation of translations) {
for (const key in translation.translations) {
if(!translation.translations.hasOwnProperty(key)){
if (!translation.translations.hasOwnProperty(key)) {
continue
}
const template = translation.translations[key]
const parts = SubstitutedTranslation.ExtractSpecialComponents(template)
const hasMiniMap = parts.filter(part =>part.special !== undefined ).some(special => special.special.func.funcName === "minimap")
if(hasMiniMap){
const hasMiniMap = parts.filter(part => part.special !== undefined).some(special => special.special.func.funcName === "minimap")
if (hasMiniMap) {
return true;
}
}
}
return false;
}
}
}

View file

@ -30,6 +30,7 @@ export interface TagRenderingConfigJson {
* Allow freeform text input from the user
*/
freeform?: {
/**
* If this key is present, then 'render' is used to display the value.
* If this is undefined, the rendering is _always_ shown
@ -40,13 +41,30 @@ export interface TagRenderingConfigJson {
* See Docs/SpecialInputElements.md and UI/Input/ValidatedTextField.ts for supported values
*/
type?: string,
/**
* Extra parameters to initialize the input helper arguments.
* For semantics, see the 'SpecialInputElements.md'
*/
helperArgs?: (string | number | boolean)[];
/**
* If a value is added with the textfield, these extra tag is addded.
* Useful to add a 'fixme=freeform textfield used - to be checked'
**/
addExtraTags?: string[];
/**
* When set, influences the way a question is asked.
* Instead of showing a full-widht text field, the text field will be shown within the rendering of the question.
*
* This combines badly with special input elements, as it'll distort the layout.
*/
inline?: boolean
/**
* default value to enter if no previous tagging is present.
* Normally undefined (aka do not enter anything)
*/
default?: string
},
/**

View file

@ -18,9 +18,9 @@
Development
-----------
**Windows users**: All scripts are made for linux devices. Use the Ubuntu terminal for Windows (or even better - make the switch ;) ). If you are using Visual Studio, open everything in a 'new WSL Window'.
**Windows users**: All scripts are made for linux devices. Use the Ubuntu terminal for Windows (or even better - make the switch ;) ). If you are using Visual Studio Code you can use a [WSL Remote](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) window, or use the Devcontainer (see more details later).
To develop and build MapComplete, yo
To develop and build MapComplete, you
0. Make sure you have a recent version of nodejs - at least 12.0, preferably 15
0. Make a fork and clone the repository.
@ -29,6 +29,30 @@
4. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
Development using Windows
------------------------
For Windows you can use the devcontainer, or the WSL subsystem.
To use the devcontainer in Visual Studio Code:
0. Make sure you have installed the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension and it's dependencies.
1. Make a fork and clone the repository.
2. After cloning, Visual Studio Code will ask you if you want to use the devcontainer.
3. Then you can either clone it again in a volume (for better performance), or open the current folder in a container.
4. By now, you should be able to run `npm run start` to host a local testversion at http://localhost:1234/index.html
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
To use the WSL in Visual Studio Code:
0. Make sure you have installed the [Remote - WSL]() extension and it's dependencies.
1. Open a remote WSL window using the button in the bottom left.
2. Make a fork and clone the repository.
3. Install `npm` using `sudo apt install npm`.
4. Run `npm run init` and generate some additional dependencies and generated files. Note that it'll install the dependencies too
5. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
6. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
Automatic deployment
--------------------

View file

@ -20,126 +20,158 @@ the URL-parameters are stated in the part between the `?` and the `#`. There are
Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case.
layer-control-toggle
----------------------
Whether or not the layer control is shown The default value is _false_
tab
-----
The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
z
---
The initial/current zoom level The default value is _0_
lat
-----
The initial/current latitude The default value is _0_
lon
-----
The initial/current longitude of the app The default value is _0_
fs-userbadge
--------------
Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
fs-search
-----------
Disables/Enables the search bar The default value is _true_
fs-layers
-----------
Disables/Enables the layer control The default value is _true_
fs-add-new
------------
Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
fs-welcome-message
--------------------
Disables/enables the help menu or welcome message The default value is _true_
fs-iframe
-----------
Disables/Enables the iframe-popup The default value is _false_
fs-more-quests
----------------
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
fs-share-screen
-----------------
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
fs-geolocation
----------------
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
------------------
Always show all questions The default value is _false_
test
------
If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_
debug
-------
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
backend
backend
---------
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
custom-css
test
------
If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_
layout
--------
The layout to load into MapComplete The default value is __
userlayout
------------
If specified, the custom css from the given link will be loaded additionaly The default value is __
If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways:
- The hash of the URL contains a base64-encoded .json-file containing the theme definition
- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator
- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme The default value is _false_
background
layer-control-toggle
----------------------
Whether or not the layer control is shown The default value is _false_
tab
-----
The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
z
---
The initial/current zoom level The default value is _14_
lat
-----
The initial/current latitude The default value is _51.2095_
lon
-----
The initial/current longitude of the app The default value is _3.2228_
fs-userbadge
--------------
Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
fs-search
-----------
Disables/Enables the search bar The default value is _true_
fs-layers
-----------
Disables/Enables the layer control The default value is _true_
fs-add-new
------------
The id of the background layer to start with The default value is _osm_
Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
fs-welcome-message
--------------------
Disables/enables the help menu or welcome message The default value is _true_
fs-iframe
-----------
Disables/Enables the iframe-popup The default value is _false_
fs-more-quests
----------------
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
fs-share-screen
-----------------
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
fs-geolocation
----------------
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
------------------
Always show all questions The default value is _false_
fs-export
-----------
If set, enables the 'download'-button to download everything as geojson The default value is _false_
fake-user
-----------
If true, 'dryrun' mode is activated and a fake user account is loaded The default value is _false_
debug
-------
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
custom-css
------------
If specified, the custom css from the given link will be loaded additionaly The default value is __
background
------------
The id of the background layer to start with The default value is _osm_
oauth_token
-------------
Used to complete the login No default value set
layer-<layer-id>
------------------

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,12 @@
import * as editorlayerindex from "../../assets/editor-layer-index.json"
import BaseLayer from "../../Models/BaseLayer";
import * as L from "leaflet";
import {TileLayer} from "leaflet";
import * as X from "leaflet-providers";
import {UIEventSource} from "../UIEventSource";
import {GeoOperations} from "../GeoOperations";
import {TileLayer} from "leaflet";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
/**
* Calculates which layers are available at the current location
@ -24,45 +25,87 @@ export default class AvailableBaseLayers {
false, false),
feature: null,
max_zoom: 19,
min_zoom: 0
min_zoom: 0,
isBest: false, // This is a lie! Of course OSM is the best map! (But not in this context)
category: "osmbasedmap"
}
public static layerOverview = AvailableBaseLayers.LoadRasterIndex().concat(AvailableBaseLayers.LoadProviderIndex());
public availableEditorLayers: UIEventSource<BaseLayer[]>;
constructor(location: UIEventSource<{ lat: number, lon: number, zoom: number }>) {
const self = this;
this.availableEditorLayers =
location.map(
(currentLocation) => {
public static AvailableLayersAt(location: UIEventSource<Loc>): UIEventSource<BaseLayer[]> {
const source = location.map(
(currentLocation) => {
if (currentLocation === undefined) {
return AvailableBaseLayers.layerOverview;
}
if (currentLocation === undefined) {
return AvailableBaseLayers.layerOverview;
}
const currentLayers = self.availableEditorLayers?.data;
const newLayers = AvailableBaseLayers.AvailableLayersAt(currentLocation?.lon, currentLocation?.lat);
const currentLayers = source?.data; // A bit unorthodox - I know
const newLayers = AvailableBaseLayers.CalculateAvailableLayersAt(currentLocation?.lon, currentLocation?.lat);
if (currentLayers === undefined) {
if (currentLayers === undefined) {
return newLayers;
}
if (newLayers.length !== currentLayers.length) {
return newLayers;
}
for (let i = 0; i < newLayers.length; i++) {
if (newLayers[i].name !== currentLayers[i].name) {
return newLayers;
}
if (newLayers.length !== currentLayers.length) {
return newLayers;
}
for (let i = 0; i < newLayers.length; i++) {
if (newLayers[i].name !== currentLayers[i].name) {
return newLayers;
}
}
return currentLayers;
});
}
return currentLayers;
});
return source;
}
private static AvailableLayersAt(lon: number, lat: number): BaseLayer[] {
public static SelectBestLayerAccordingTo(location: UIEventSource<Loc>, preferedCategory: UIEventSource<string | string[]>): UIEventSource<BaseLayer> {
return AvailableBaseLayers.AvailableLayersAt(location).map(available => {
// First float all 'best layers' to the top
available.sort((a, b) => {
if (a.isBest && b.isBest) {
return 0;
}
if (!a.isBest) {
return 1
}
return -1;
}
)
if (preferedCategory.data === undefined) {
return available[0]
}
let prefered: string []
if (typeof preferedCategory.data === "string") {
prefered = [preferedCategory.data]
} else {
prefered = preferedCategory.data;
}
prefered.reverse();
for (const category of prefered) {
//Then sort all 'photo'-layers to the top. Stability of the sorting will force a 'best' photo layer on top
available.sort((a, b) => {
if (a.category === category && b.category === category) {
return 0;
}
if (a.category !== category) {
return 1
}
return -1;
}
)
}
return available[0]
})
}
private static CalculateAvailableLayersAt(lon: number, lat: number): BaseLayer[] {
const availableLayers = [AvailableBaseLayers.osmCarto]
const globalLayers = [];
for (const layerOverviewItem of AvailableBaseLayers.layerOverview) {
@ -140,7 +183,9 @@ export default class AvailableBaseLayers {
min_zoom: props.min_zoom ?? 1,
name: props.name,
layer: leafletLayer,
feature: layer
feature: layer,
isBest: props.best ?? false,
category: props.category
});
}
return layers;
@ -152,15 +197,16 @@ export default class AvailableBaseLayers {
function l(id: string, name: string): BaseLayer {
try {
const layer: any = () => L.tileLayer.provider(id, undefined);
const baseLayer: BaseLayer = {
return {
feature: null,
id: id,
name: name,
layer: layer,
min_zoom: layer.minzoom,
max_zoom: layer.maxzoom
max_zoom: layer.maxzoom,
category: "osmbasedmap",
isBest: false
}
return baseLayer
} catch (e) {
console.error("Could not find provided layer", name, e);
return null;

View file

@ -1,265 +1,271 @@
import * as L from "leaflet";
import { UIEventSource } from "../UIEventSource";
import { Utils } from "../../Utils";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
import { LocalStorageSource } from "../Web/LocalStorageSource";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import { VariableUiElement } from "../../UI/Base/VariableUIElement";
import { CenterFlexedElement } from "../../UI/Base/CenterFlexedElement";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
import {CenterFlexedElement} from "../../UI/Base/CenterFlexedElement";
export default class GeoLocationHandler extends VariableUiElement {
/**
* Wether or not the geolocation is active, aka the user requested the current location
* @private
*/
private readonly _isActive: UIEventSource<boolean>;
/**
* Wether or not the geolocation is active, aka the user requested the current location
* @private
*/
private readonly _isActive: UIEventSource<boolean>;
/**
* The callback over the permission API
* @private
*/
private readonly _permission: UIEventSource<string>;
/***
* The marker on the map, in order to update it
* @private
*/
private _marker: L.Marker;
/**
* Literally: _currentGPSLocation.data != undefined
* @private
*/
private readonly _hasLocation: UIEventSource<boolean>;
private readonly _currentGPSLocation: UIEventSource<{
latlng: any;
accuracy: number;
}>;
/**
* Kept in order to update the marker
* @private
*/
private readonly _leafletMap: UIEventSource<L.Map>;
/**
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
* @private
*/
private _lastUserRequest: Date;
/**
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
* On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions.
*
* Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately.
* If the user denies the geolocation this time, we unset this flag
* @private
*/
private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>
) {
const hasLocation = currentGPSLocation.map(
(location) => location !== undefined
);
const previousLocationGrant = LocalStorageSource.Get(
"geolocation-permissions"
);
const isActive = new UIEventSource<boolean>(false);
/**
* Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user
* @private
*/
private readonly _isLocked: UIEventSource<boolean>;
super(
hasLocation.map(
(hasLocation) => {
if (hasLocation) {
return new CenterFlexedElement(
Img.AsImageElement(Svg.location, "", "width:1.25rem;height:1.25rem")
); // crosshair_blue_ui()
}
if (isActive.data) {
return new CenterFlexedElement(
Img.AsImageElement(Svg.location, "", "width:1.25rem;height:1.25rem")
); // crosshair_blue_center_ui
}
return new CenterFlexedElement(
Img.AsImageElement(Svg.location, "", "width:1.25rem;height:1.25rem")
); //crosshair_ui
},
[isActive]
)
);
this._isActive = isActive;
this._permission = new UIEventSource<string>("");
this._previousLocationGrant = previousLocationGrant;
this._currentGPSLocation = currentGPSLocation;
this._leafletMap = leafletMap;
this._layoutToUse = layoutToUse;
this._hasLocation = hasLocation;
const self = this;
/**
* The callback over the permission API
* @private
*/
private readonly _permission: UIEventSource<string>;
const currentPointer = this._isActive.map(
(isActive) => {
if (isActive && !self._hasLocation.data) {
return "cursor-wait";
}
return "cursor-pointer";
},
[this._hasLocation]
);
currentPointer.addCallbackAndRun((pointerClass) => {
self.SetClass(pointerClass);
});
/***
* The marker on the map, in order to update it
* @private
*/
private _marker: L.Marker;
/**
* Literally: _currentGPSLocation.data != undefined
* @private
*/
private readonly _hasLocation: UIEventSource<boolean>;
private readonly _currentGPSLocation: UIEventSource<{
latlng: any;
accuracy: number;
}>;
/**
* Kept in order to update the marker
* @private
*/
private readonly _leafletMap: UIEventSource<L.Map>;
this.onClick(() => self.init(true));
this.init(false);
}
private init(askPermission: boolean) {
const self = this;
const map = this._leafletMap.data;
/**
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
* @private
*/
private _lastUserRequest: Date;
this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted");
const timeSinceRequest =
(new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
if (timeSinceRequest < 30) {
self.MoveToCurrentLoction(16);
}
/**
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
* On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions.
*
* Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately.
* If the user denies the geolocation this time, we unset this flag
* @private
*/
private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
let color = "#1111cc";
try {
color = getComputedStyle(document.body).getPropertyValue(
"--catch-detail-color"
);
} catch (e) {
console.error(e);
}
const icon = L.icon({
iconUrl: Img.AsData(Svg.crosshair.replace(/#000000/g, color)),
iconSize: [40, 40], // size of the icon
iconAnchor: [20, 20], // point of the icon which will correspond to marker's location
});
const newMarker = L.marker(location.latlng, { icon: icon });
newMarker.addTo(map);
if (self._marker !== undefined) {
map.removeLayer(self._marker);
}
self._marker = newMarker;
});
try {
navigator?.permissions
?.query({ name: "geolocation" })
?.then(function (status) {
console.log("Geolocation is already", status);
if (status.state === "granted") {
self.StartGeolocating(false);
}
self._permission.setData(status.state);
status.onchange = function () {
self._permission.setData(status.state);
};
});
} catch (e) {
console.error(e);
}
if (askPermission) {
self.StartGeolocating(true);
} else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(false);
}
}
private locate() {
const self = this;
const map: any = this._leafletMap.data;
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function (position) {
self._currentGPSLocation.setData({
latlng: [position.coords.latitude, position.coords.longitude],
accuracy: position.coords.accuracy,
});
},
function () {
console.warn("Could not get location with navigator.geolocation");
}
);
return;
} else {
map.findAccuratePosition({
maxWait: 10000, // defaults to 10000
desiredAccuracy: 50, // defaults to 20
});
}
}
private MoveToCurrentLoction(targetZoom = 16) {
const location = this._currentGPSLocation.data;
this._lastUserRequest = undefined;
if (
this._currentGPSLocation.data.latlng[0] === 0 &&
this._currentGPSLocation.data.latlng[1] === 0
constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>
) {
console.debug("Not moving to GPS-location: it is null island");
return;
const hasLocation = currentGPSLocation.map(
(location) => location !== undefined
);
const previousLocationGrant = LocalStorageSource.Get(
"geolocation-permissions"
);
const isActive = new UIEventSource<boolean>(false);
const isLocked = new UIEventSource<boolean>(false);
super(
hasLocation.map(
(hasLocationData) => {
let icon: string;
if (isLocked.data) {
icon = Svg.crosshair_locked;
} else if (hasLocationData) {
icon = Svg.crosshair_blue;
} else if (isActive.data) {
icon = Svg.crosshair_blue_center;
} else {
icon = Svg.crosshair;
}
return new CenterFlexedElement(
Img.AsImageElement(icon, "", "width:1.25rem;height:1.25rem")
);
},
[isActive, isLocked]
)
);
this._isActive = isActive;
this._isLocked = isLocked;
this._permission = new UIEventSource<string>("");
this._previousLocationGrant = previousLocationGrant;
this._currentGPSLocation = currentGPSLocation;
this._leafletMap = leafletMap;
this._layoutToUse = layoutToUse;
this._hasLocation = hasLocation;
const self = this;
const currentPointer = this._isActive.map(
(isActive) => {
if (isActive && !self._hasLocation.data) {
return "cursor-wait";
}
return "cursor-pointer";
},
[this._hasLocation]
);
currentPointer.addCallbackAndRun((pointerClass) => {
self.SetClass(pointerClass);
});
this.onClick(() => {
if (self._hasLocation.data) {
self._isLocked.setData(!self._isLocked.data);
}
self.init(true);
});
this.init(false);
this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted");
const timeSinceRequest =
(new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
if (timeSinceRequest < 30) {
self.MoveToCurrentLoction(16);
} else if (self._isLocked.data) {
self.MoveToCurrentLoction();
}
let color = "#1111cc";
try {
color = getComputedStyle(document.body).getPropertyValue(
"--catch-detail-color"
);
} catch (e) {
console.error(e);
}
const icon = L.icon({
iconUrl: Img.AsData(Svg.crosshair.replace(/#000000/g, color)),
iconSize: [40, 40], // size of the icon
iconAnchor: [20, 20], // point of the icon which will correspond to marker's location
});
const map = self._leafletMap.data;
const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map);
if (self._marker !== undefined) {
map.removeLayer(self._marker);
}
self._marker = newMarker;
});
}
// We check that the GPS location is not out of bounds
const b = this._layoutToUse.data.lockLocation;
let inRange = true;
if (b) {
if (b !== true) {
// B is an array with our locklocation
inRange =
b[0][0] <= location.latlng[0] &&
location.latlng[0] <= b[1][0] &&
b[0][1] <= location.latlng[1] &&
location.latlng[1] <= b[1][1];
}
}
if (!inRange) {
console.log(
"Not zooming to GPS location: out of bounds",
b,
location.latlng
);
} else {
this._leafletMap.data.setView(location.latlng, targetZoom);
}
}
private StartGeolocating(zoomToGPS = true) {
const self = this;
console.log("Starting geolocation");
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
if (self._permission.data === "denied") {
self._previousLocationGrant.setData("");
return "";
}
if (this._currentGPSLocation.data !== undefined) {
this.MoveToCurrentLoction(16);
}
console.log("Searching location using GPS");
this.locate();
if (!self._isActive.data) {
self._isActive.setData(true);
Utils.DoEvery(60000, () => {
if (document.visibilityState !== "visible") {
console.log("Not starting gps: document not visible");
return;
private init(askPermission: boolean) {
const self = this;
if (self._isActive.data) {
self.MoveToCurrentLoction(16);
return;
}
try {
navigator?.permissions
?.query({name: "geolocation"})
?.then(function (status) {
console.log("Geolocation is already", status);
if (status.state === "granted") {
self.StartGeolocating(false);
}
self._permission.setData(status.state);
status.onchange = function () {
self._permission.setData(status.state);
};
});
} catch (e) {
console.error(e);
}
if (askPermission) {
self.StartGeolocating(true);
} else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(false);
}
this.locate();
});
}
}
private MoveToCurrentLoction(targetZoom = 16) {
const location = this._currentGPSLocation.data;
this._lastUserRequest = undefined;
if (
this._currentGPSLocation.data.latlng[0] === 0 &&
this._currentGPSLocation.data.latlng[1] === 0
) {
console.debug("Not moving to GPS-location: it is null island");
return;
}
// We check that the GPS location is not out of bounds
const b = this._layoutToUse.data.lockLocation;
let inRange = true;
if (b) {
if (b !== true) {
// B is an array with our locklocation
inRange =
b[0][0] <= location.latlng[0] &&
location.latlng[0] <= b[1][0] &&
b[0][1] <= location.latlng[1] &&
location.latlng[1] <= b[1][1];
}
}
if (!inRange) {
console.log(
"Not zooming to GPS location: out of bounds",
b,
location.latlng
);
} else {
this._leafletMap.data.setView(location.latlng, targetZoom);
}
}
private StartGeolocating(zoomToGPS = true) {
const self = this;
console.log("Starting geolocation");
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
if (self._permission.data === "denied") {
self._previousLocationGrant.setData("");
return "";
}
if (this._currentGPSLocation.data !== undefined) {
this.MoveToCurrentLoction(16);
}
console.log("Searching location using GPS");
if (self._isActive.data) {
return;
}
self._isActive.setData(true);
navigator.geolocation.watchPosition(
function (position) {
self._currentGPSLocation.setData({
latlng: [position.coords.latitude, position.coords.longitude],
accuracy: position.coords.accuracy,
});
},
function () {
console.warn("Could not get location with navigator.geolocation");
}
);
}
}

View file

@ -47,7 +47,12 @@ export default class StrayClickHandler {
popupAnchor: [0, -45]
})
});
const popup = L.popup().setContent("<div id='strayclick'></div>");
const popup = L.popup({
autoPan: true,
autoPanPaddingTopLeft: [15,15],
closeOnEscapeKey: true,
autoClose: true
}).setContent("<div id='strayclick' style='height: 65vh'></div>");
self._lastMarker.addTo(leafletMap.data);
self._lastMarker.bindPopup(popup);

View file

@ -16,7 +16,7 @@ import RegisteringFeatureSource from "./RegisteringFeatureSource";
export default class FeaturePipeline implements FeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> ;
public features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name = "FeaturePipeline"
@ -29,7 +29,7 @@ export default class FeaturePipeline implements FeatureSource {
selectedElement: UIEventSource<any>) {
const allLoadedFeatures = new UIEventSource<{ feature: any; freshness: Date }[]>([])
// first we metatag, then we save to get the metatags into storage too
// Note that we need to register before we do metatagging (as it expects the event sources)
@ -46,8 +46,11 @@ export default class FeaturePipeline implements FeatureSource {
const geojsonSources: FeatureSource [] = GeoJsonSource
.ConstructMultiSource(flayers.data, locationControl)
.map(geojsonSource => {
let source = new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, geojsonSource));
if(!geojsonSource.isOsmCache){
let source = new RegisteringFeatureSource(
new FeatureDuplicatorPerLayer(flayers,
geojsonSource
));
if (!geojsonSource.isOsmCache) {
source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features);
}
return source

View file

@ -1,9 +1,45 @@
import {UIEventSource} from "../UIEventSource";
import {Utils} from "../../Utils";
export default interface FeatureSource {
features: UIEventSource<{feature: any, freshness: Date}[]>;
features: UIEventSource<{ feature: any, freshness: Date }[]>;
/**
* Mainly used for debuging
*/
name: string;
}
export class FeatureSourceUtils {
/**
* Exports given featurePipeline as a geojson FeatureLists (downloads as a json)
* @param featurePipeline The FeaturePipeline you want to export
* @param options The options object
* @param options.metadata True if you want to include the MapComplete metadata, false otherwise
*/
public static extractGeoJson(featurePipeline: FeatureSource, options: { metadata?: boolean } = {}) {
let defaults = {
metadata: false,
}
options = Utils.setDefaults(options, defaults);
// Select all features, ignore the freshness and other data
let featureList: any[] = featurePipeline.features.data.map((feature) => feature.feature);
if (!options.metadata) {
for (let i = 0; i < featureList.length; i++) {
let feature = featureList[i];
for (let property in feature.properties) {
if (property[0] == "_") {
delete featureList[i]["properties"][property];
}
}
}
}
return {type: "FeatureCollection", features: featureList}
}
}

View file

@ -175,7 +175,7 @@ export default class GeoJsonSource implements FeatureSource {
let freshness: Date = time;
if (feature.properties["_last_edit:timestamp"] !== undefined) {
freshness = new Date(feature["_last_edit:timestamp"])
freshness = new Date(feature.properties["_last_edit:timestamp"])
}
newFeatures.push({feature: feature, freshness: freshness})

View file

@ -6,11 +6,14 @@ export class GeoOperations {
return turf.area(feature);
}
/**
* Converts a GeoJSon feature to a point feature
* @param feature
*/
static centerpoint(feature: any) {
const newFeature = turf.center(feature);
newFeature.properties = feature.properties;
newFeature.id = feature.id;
return newFeature;
}
@ -273,6 +276,14 @@ export class GeoOperations {
}
return undefined;
}
/**
* Generates the closest point on a way from a given point
* @param way The road on which you want to find a point
* @param point Point defined as [lon, lat]
*/
public static nearestPoint(way, point: [number, number]){
return turf.nearestPointOnLine(way, point, {units: "kilometers"});
}
}

View file

@ -6,31 +6,38 @@ import Constants from "../../Models/Constants";
import FeatureSource from "../FeatureSource/FeatureSource";
import {TagsFilter} from "../Tags/TagsFilter";
import {Tag} from "../Tags/Tag";
import {OsmConnection} from "./OsmConnection";
import {LocalStorageSource} from "../Web/LocalStorageSource";
/**
* Handles all changes made to OSM.
* Needs an authenticator via OsmConnection
*/
export class Changes implements FeatureSource{
export class Changes implements FeatureSource {
private static _nextId = -1; // Newly assigned ID's are negative
public readonly name = "Newly added features"
/**
* The newly created points, as a FeatureSource
*/
public features = new UIEventSource<{feature: any, freshness: Date}[]>([]);
private static _nextId = -1; // Newly assigned ID's are negative
public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
/**
* All the pending changes
*/
public readonly pending: UIEventSource<{ elementId: string, key: string, value: string }[]> =
new UIEventSource<{elementId: string; key: string; value: string}[]>([]);
public readonly pending = LocalStorageSource.GetParsed<{ elementId: string, key: string, value: string }[]>("pending-changes", [])
/**
* All the pending new objects to upload
*/
private readonly newObjects = LocalStorageSource.GetParsed<{ id: number, lat: number, lon: number }[]>("newObjects", [])
private readonly isUploading = new UIEventSource(false);
/**
* Adds a change to the pending changes
*/
private static checkChange(kv: {k: string, v: string}): { k: string, v: string } {
private static checkChange(kv: { k: string, v: string }): { k: string, v: string } {
const key = kv.k;
const value = kv.v;
if (key === undefined || key === null) {
@ -49,8 +56,7 @@ export class Changes implements FeatureSource{
return {k: key.trim(), v: value.trim()};
}
addTag(elementId: string, tagsFilter: TagsFilter,
tags?: UIEventSource<any>) {
const eventSource = tags ?? State.state?.allElements.getEventSourceById(elementId);
@ -59,7 +65,7 @@ export class Changes implements FeatureSource{
if (changes.length == 0) {
return;
}
for (const change of changes) {
if (elementTags[change.k] !== change.v) {
elementTags[change.k] = change.v;
@ -76,16 +82,16 @@ export class Changes implements FeatureSource{
* Uploads all the pending changes in one go.
* Triggered by the 'PendingChangeUploader'-actor in Actors
*/
public flushChanges(flushreason: string = undefined){
if(this.pending.data.length === 0){
public flushChanges(flushreason: string = undefined) {
if (this.pending.data.length === 0) {
return;
}
if(flushreason !== undefined){
if (flushreason !== undefined) {
console.log(flushreason)
}
this.uploadAll([], this.pending.data);
this.pending.setData([]);
this.uploadAll();
}
/**
* Create a new node element at the given lat/long.
* An internal OsmObject is created to upload later on, a geojson represention is returned.
@ -93,12 +99,12 @@ export class Changes implements FeatureSource{
*/
public createElement(basicTags: Tag[], lat: number, lon: number) {
console.log("Creating a new element with ", basicTags)
const osmNode = new OsmNode(Changes._nextId);
const newId = Changes._nextId;
Changes._nextId--;
const id = "node/" + osmNode.id;
osmNode.lat = lat;
osmNode.lon = lon;
const id = "node/" + newId;
const properties = {id: id};
const geojson = {
@ -118,35 +124,49 @@ export class Changes implements FeatureSource{
// The tags are not yet written into the OsmObject, but this is applied onto a
const changes = [];
for (const kv of basicTags) {
properties[kv.key] = kv.value;
if (typeof kv.value !== "string") {
throw "Invalid value: don't use a regex in a preset"
}
properties[kv.key] = kv.value;
changes.push({elementId: id, key: kv.key, value: kv.value})
}
console.log("New feature added and pinged")
this.features.data.push({feature:geojson, freshness: new Date()});
this.features.data.push({feature: geojson, freshness: new Date()});
this.features.ping();
State.state.allElements.addOrGetElement(geojson).ping();
this.uploadAll([osmNode], changes);
if (State.state.osmConnection.userDetails.data.backend !== OsmConnection.oauth_configs.osm.url) {
properties["_backend"] = State.state.osmConnection.userDetails.data.backend
}
this.newObjects.data.push({id: newId, lat: lat, lon: lon})
this.pending.data.push(...changes)
this.pending.ping();
this.newObjects.ping();
return geojson;
}
private uploadChangesWithLatestVersions(
knownElements: OsmObject[], newElements: OsmObject[], pending: { elementId: string; key: string; value: string }[]) {
knownElements: OsmObject[]) {
const knownById = new Map<string, OsmObject>();
knownElements.forEach(knownElement => {
knownById.set(knownElement.type + "/" + knownElement.id, knownElement)
})
const newElements: OsmNode [] = this.newObjects.data.map(spec => {
const newElement = new OsmNode(spec.id);
newElement.lat = spec.lat;
newElement.lon = spec.lon;
return newElement
})
// Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements', which maps the ids onto the elements
// We apply the changes on them
for (const change of pending) {
for (const change of this.pending.data) {
if (parseInt(change.elementId.split("/")[1]) < 0) {
// This is a new element - we should apply this on one of the new elements
for (const newElement of newElements) {
@ -168,9 +188,17 @@ export class Changes implements FeatureSource{
}
}
if (changedElements.length == 0 && newElements.length == 0) {
console.log("No changes in any object");
console.log("No changes in any object - clearing");
this.pending.setData([])
this.newObjects.setData([])
return;
}
const self = this;
if (this.isUploading.data) {
return;
}
this.isUploading.setData(true)
console.log("Beginning upload...");
// At last, we build the changeset and upload
@ -213,17 +241,22 @@ export class Changes implements FeatureSource{
changes += "</osmChange>";
return changes;
});
},
() => {
console.log("Upload successfull!")
self.newObjects.setData([])
self.pending.setData([]);
self.isUploading.setData(false)
},
() => self.isUploading.setData(false)
);
};
private uploadAll(
newElements: OsmObject[],
pending: { elementId: string; key: string; value: string }[]
) {
private uploadAll() {
const self = this;
const pending = this.pending.data;
let neededIds: string[] = [];
for (const change of pending) {
const id = change.elementId;
@ -236,8 +269,7 @@ export class Changes implements FeatureSource{
neededIds = Utils.Dedup(neededIds);
OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => {
console.log("KnownElements:", knownElements)
self.uploadChangesWithLatestVersions(knownElements, newElements, pending)
self.uploadChangesWithLatestVersions(knownElements)
})
}

View file

@ -27,7 +27,7 @@ export class ChangesetHandler {
}
}
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) {
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage): void {
const nodes = response.getElementsByTagName("node");
// @ts-ignore
for (const node of nodes) {
@ -69,7 +69,9 @@ export class ChangesetHandler {
public UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string) {
generateChangeXML: (csid: string) => string,
whenDone: (csId: string) => void,
onFail: () => void) {
if (this.userDetails.data.csCount == 0) {
// The user became a contributor!
@ -80,6 +82,7 @@ export class ChangesetHandler {
if (this._dryRun) {
const changesetXML = generateChangeXML("123456");
console.log(changesetXML);
whenDone("123456")
return;
}
@ -93,12 +96,14 @@ export class ChangesetHandler {
console.log(changeset);
self.AddChange(csId, changeset,
allElements,
() => {
},
whenDone,
(e) => {
console.error("UPLOADING FAILED!", e)
onFail()
}
)
}, {
onFail: onFail
})
} else {
// There still exists an open changeset (or at least we hope so)
@ -107,15 +112,13 @@ export class ChangesetHandler {
csId,
generateChangeXML(csId),
allElements,
() => {
},
whenDone,
(e) => {
console.warn("Could not upload, changeset is probably closed: ", e);
// Mark the CS as closed...
this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist
self.UploadChangeset(layout, allElements, generateChangeXML);
self.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
}
)
@ -161,18 +164,22 @@ export class ChangesetHandler {
const self = this;
this.OpenChangeset(layout, (csId: string) => {
// The cs is open - let us actually upload!
const changes = generateChangeXML(csId)
// The cs is open - let us actually upload!
const changes = generateChangeXML(csId)
self.AddChange(csId, changes, allElements, (csId) => {
console.log("Successfully deleted ", object.id)
self.CloseChangeset(csId, continuation)
}, (csId) => {
alert("Deletion failed... Should not happend")
// FAILED
self.CloseChangeset(csId, continuation)
})
}, true, reason)
self.AddChange(csId, changes, allElements, (csId) => {
console.log("Successfully deleted ", object.id)
self.CloseChangeset(csId, continuation)
}, (csId) => {
alert("Deletion failed... Should not happend")
// FAILED
self.CloseChangeset(csId, continuation)
})
}, {
isDeletionCS: true,
deletionReason: reason
}
)
}
private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
@ -204,15 +211,20 @@ export class ChangesetHandler {
private OpenChangeset(
layout: LayoutConfig,
continuation: (changesetId: string) => void,
isDeletionCS: boolean = false,
deletionReason: string = undefined) {
options?: {
isDeletionCS?: boolean,
deletionReason?: string,
onFail?: () => void
}
) {
options = options ?? {}
options.isDeletionCS = options.isDeletionCS ?? false
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`
if (isDeletionCS) {
if (options.isDeletionCS) {
comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}`
if (deletionReason) {
comment += ": " + deletionReason;
if (options.deletionReason) {
comment += ": " + options.deletionReason;
}
}
@ -221,7 +233,7 @@ export class ChangesetHandler {
const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`],
["comment", comment],
["deletion", isDeletionCS ? "yes" : undefined],
["deletion", options.isDeletionCS ? "yes" : undefined],
["theme", layout.id],
["language", Locale.language.data],
["host", window.location.host],
@ -244,7 +256,9 @@ export class ChangesetHandler {
}, function (err, response) {
if (response === undefined) {
console.log("err", err);
alert("Could not upload change (opening failed). Please file a bug report")
if(options.onFail){
options.onFail()
}
return;
} else {
continuation(response);
@ -265,7 +279,7 @@ export class ChangesetHandler {
private AddChange(changesetId: string,
changesetXML: string,
allElements: ElementStorage,
continuation: ((changesetId: string, idMapping: any) => void),
continuation: ((changesetId: string) => void),
onFail: ((changesetId: string, reason: string) => void) = undefined) {
this.auth.xhr({
method: 'POST',
@ -280,9 +294,9 @@ export class ChangesetHandler {
}
return;
}
const mapping = ChangesetHandler.parseUploadChangesetResponse(response, allElements);
ChangesetHandler.parseUploadChangesetResponse(response, allElements);
console.log("Uploaded changeset ", changesetId);
continuation(changesetId, mapping);
continuation(changesetId);
});
}

View file

@ -30,7 +30,7 @@ export default class UserDetails {
export class OsmConnection {
public static readonly _oauth_configs = {
public static readonly oauth_configs = {
"osm": {
oauth_consumer_key: 'hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem',
oauth_secret: 'wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI',
@ -47,6 +47,7 @@ export class OsmConnection {
public auth;
public userDetails: UIEventSource<UserDetails>;
public isLoggedIn: UIEventSource<boolean>
private fakeUser: boolean;
_dryRun: boolean;
public preferencesHandler: OsmPreferences;
public changesetHandler: ChangesetHandler;
@ -59,20 +60,31 @@ export class OsmConnection {
url: string
};
constructor(dryRun: boolean, oauth_token: UIEventSource<string>,
constructor(dryRun: boolean,
fakeUser: boolean,
oauth_token: UIEventSource<string>,
// Used to keep multiple changesets open and to write to the correct changeset
layoutName: string,
singlePage: boolean = true,
osmConfiguration: "osm" | "osm-test" = 'osm'
) {
this.fakeUser = fakeUser;
this._singlePage = singlePage;
this._oauth_config = OsmConnection._oauth_configs[osmConfiguration] ?? OsmConnection._oauth_configs.osm;
this._oauth_config = OsmConnection.oauth_configs[osmConfiguration] ?? OsmConnection.oauth_configs.osm;
console.debug("Using backend", this._oauth_config.url)
OsmObject.SetBackendUrl(this._oauth_config.url + "/")
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails.data.dryRun = dryRun;
this.userDetails.data.dryRun = dryRun || fakeUser;
if(fakeUser){
const ud = this.userDetails.data;
ud.csCount = 5678
ud.loggedIn= true;
ud.unreadMessages = 0
ud.name = "Fake user"
ud.totalMessages = 42;
}
const self =this;
this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => {
if(self.userDetails.data.loggedIn == false && isLoggedIn == true){
@ -110,8 +122,10 @@ export class OsmConnection {
public UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML);
generateChangeXML: (csid: string) => string,
whenDone: (csId: string) => void,
onFail: () => {}) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
}
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
@ -136,6 +150,10 @@ export class OsmConnection {
}
public AttemptLogin() {
if(this.fakeUser){
console.log("AttemptLogin called, but ignored as fakeUser is set")
return;
}
const self = this;
console.log("Trying to log in...");
this.updateAuthObject();

View file

@ -5,7 +5,8 @@ import {UIEventSource} from "../UIEventSource";
export abstract class OsmObject {
protected static backendURL = "https://www.openstreetmap.org/"
private static defaultBackend = "https://www.openstreetmap.org/"
protected static backendURL = OsmObject.defaultBackend;
private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>();
@ -37,15 +38,15 @@ export abstract class OsmObject {
}
static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
let src : UIEventSource<OsmObject>;
let src: UIEventSource<OsmObject>;
if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id)
if(forceRefresh){
if (forceRefresh) {
src.setData(undefined)
}else{
} else {
return src;
}
}else{
} else {
src = new UIEventSource<OsmObject>(undefined)
}
const splitted = id.split("/");
@ -157,7 +158,7 @@ export abstract class OsmObject {
const minlat = bounds[1][0]
const maxlat = bounds[0][0];
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
Utils.downloadJson(url).then( data => {
Utils.downloadJson(url).then(data => {
const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements)
callback(objects);
@ -291,6 +292,7 @@ export abstract class OsmObject {
self.LoadData(element)
self.SaveExtraData(element, nodes);
const meta = {
"_last_edit:contributor": element.user,
"_last_edit:contributor:uid": element.uid,
@ -299,6 +301,11 @@ export abstract class OsmObject {
"_version_number": element.version
}
if (OsmObject.backendURL !== OsmObject.defaultBackend) {
self.tags["_backend"] = OsmObject.backendURL
meta["_backend"] = OsmObject.backendURL;
}
continuation(self, meta);
}
);

View file

@ -83,7 +83,8 @@ export default class SimpleMetaTagger {
},
(feature => {
const units = State.state.layoutToUse.data.units ?? [];
const units = State.state?.layoutToUse?.data?.units ?? [];
let rewritten = false;
for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) {
continue;
@ -95,16 +96,23 @@ export default class SimpleMetaTagger {
const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value)
let canonical = denomination?.canonicalValue(value) ?? undefined;
console.log("Rewritten ", key, " from", value, "into", canonical)
if(canonical === undefined && !unit.eraseInvalid) {
if(canonical === value){
break;
}
console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`)
if (canonical === undefined && !unit.eraseInvalid) {
break;
}
feature.properties[key] = canonical;
rewritten = true;
break;
}
}
if(rewritten){
State.state.allElements.getEventSourceById(feature.id).ping();
}
})
)

View file

@ -4,6 +4,22 @@ import {UIEventSource} from "../UIEventSource";
* UIEventsource-wrapper around localStorage
*/
export class LocalStorageSource {
static GetParsed<T>(key: string, defaultValue : T) : UIEventSource<T>{
return LocalStorageSource.Get(key).map(
str => {
if(str === undefined){
return defaultValue
}
try{
return JSON.parse(str)
}catch{
return defaultValue
}
}, [],
value => JSON.stringify(value)
)
}
static Get(key: string, defaultValue: string = undefined): UIEventSource<string> {
try {

View file

@ -7,4 +7,6 @@ export default interface BaseLayer {
max_zoom: number,
min_zoom: number;
feature: any,
isBest?: boolean,
category?: "map" | "osmbasedmap" | "photo" | "historicphoto" | string
}

View file

@ -2,7 +2,7 @@ import { Utils } from "../Utils";
export default class Constants {
public static vNumber = "0.8.3f";
public static vNumber = "0.8.4-rc3";
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {

8
Models/TileRange.ts Normal file
View file

@ -0,0 +1,8 @@
export interface TileRange {
xstart: number,
ystart: number,
xend: number,
yend: number,
total: number,
zoomlevel: number
}

773
State.ts
View file

@ -1,13 +1,13 @@
import { Utils } from "./Utils";
import { ElementStorage } from "./Logic/ElementStorage";
import { Changes } from "./Logic/Osm/Changes";
import { OsmConnection } from "./Logic/Osm/OsmConnection";
import {Utils} from "./Utils";
import {ElementStorage} from "./Logic/ElementStorage";
import {Changes} from "./Logic/Osm/Changes";
import {OsmConnection} from "./Logic/Osm/OsmConnection";
import Locale from "./UI/i18n/Locale";
import { UIEventSource } from "./Logic/UIEventSource";
import { LocalStorageSource } from "./Logic/Web/LocalStorageSource";
import { QueryParameters } from "./Logic/Web/QueryParameters";
import {UIEventSource} from "./Logic/UIEventSource";
import {LocalStorageSource} from "./Logic/Web/LocalStorageSource";
import {QueryParameters} from "./Logic/Web/QueryParameters";
import LayoutConfig from "./Customizations/JSON/LayoutConfig";
import { MangroveIdentity } from "./Logic/Web/MangroveReviews";
import {MangroveIdentity} from "./Logic/Web/MangroveReviews";
import InstalledThemes from "./Logic/Actors/InstalledThemes";
import BaseLayer from "./Models/BaseLayer";
import Loc from "./Models/Loc";
@ -17,410 +17,423 @@ import OverpassFeatureSource from "./Logic/Actors/OverpassFeatureSource";
import LayerConfig from "./Customizations/JSON/LayerConfig";
import TitleHandler from "./Logic/Actors/TitleHandler";
import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader";
import { Relation } from "./Logic/Osm/ExtractRelations";
import {Relation} from "./Logic/Osm/ExtractRelations";
import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource";
import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
/**
* Contains the global state: a bunch of UI-event sources
*/
export default class State {
// The singleton of the global state
public static state: State;
// The singleton of the global state
public static state: State;
public readonly layoutToUse = new UIEventSource<LayoutConfig>(undefined);
public readonly layoutToUse = new UIEventSource<LayoutConfig>(undefined);
/**
/**
The mapping from id -> UIEventSource<properties>
*/
public allElements: ElementStorage;
/**
public allElements: ElementStorage;
/**
THe change handler
*/
public changes: Changes;
/**
public changes: Changes;
/**
The leaflet instance of the big basemap
*/
public leafletMap = new UIEventSource<L.Map>(undefined);
/**
* Background layer id
*/
public availableBackgroundLayers: UIEventSource<BaseLayer[]>;
/**
public leafletMap = new UIEventSource<L.Map>(undefined);
/**
* Background layer id
*/
public availableBackgroundLayers: UIEventSource<BaseLayer[]>;
/**
The user credentials
*/
public osmConnection: OsmConnection;
public osmConnection: OsmConnection;
public mangroveIdentity: MangroveIdentity;
public mangroveIdentity: MangroveIdentity;
public favouriteLayers: UIEventSource<string[]>;
public favouriteLayers: UIEventSource<string[]>;
public layerUpdater: OverpassFeatureSource;
public layerUpdater: OverpassFeatureSource;
public osmApiFeatureSource: OsmApiFeatureSource;
public osmApiFeatureSource: OsmApiFeatureSource;
public filteredLayers: UIEventSource<
{
readonly isDisplayed: UIEventSource<boolean>;
readonly layerDef: LayerConfig;
}[]
> = new UIEventSource<
{
readonly isDisplayed: UIEventSource<boolean>;
readonly layerDef: LayerConfig;
}[]
>([]);
public filteredLayers: UIEventSource<{
readonly isDisplayed: UIEventSource<boolean>;
readonly layerDef: LayerConfig;
}[]> = new UIEventSource<{
readonly isDisplayed: UIEventSource<boolean>;
readonly layerDef: LayerConfig;
}[]>([]);
/**
/**
The latest element that was selected
*/
public readonly selectedElement = new UIEventSource<any>(
undefined,
"Selected element"
);
/**
* Keeps track of relations: which way is part of which other way?
* Set by the overpass-updater; used in the metatagging
*/
public readonly knownRelations = new UIEventSource<
Map<string, { role: string; relation: Relation }[]>
>(undefined, "Relation memberships");
public readonly featureSwitchUserbadge: UIEventSource<boolean>;
public readonly featureSwitchSearch: UIEventSource<boolean>;
public readonly featureSwitchLayers: UIEventSource<boolean>;
public readonly featureSwitchAddNew: UIEventSource<boolean>;
public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>;
public readonly featureSwitchIframe: UIEventSource<boolean>;
public readonly featureSwitchMoreQuests: UIEventSource<boolean>;
public readonly featureSwitchShareScreen: UIEventSource<boolean>;
public readonly featureSwitchGeolocation: UIEventSource<boolean>;
public readonly featureSwitchIsTesting: UIEventSource<boolean>;
public readonly featureSwitchIsDebugging: UIEventSource<boolean>;
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>;
public readonly featureSwitchApiURL: UIEventSource<string>;
public readonly featureSwitchFilter: UIEventSource<boolean>;
/**
* The map location: currently centered lat, lon and zoom
*/
public readonly locationControl = new UIEventSource<Loc>(undefined);
public backgroundLayer;
public readonly backgroundLayerId: UIEventSource<string>;
/* Last location where a click was registered
*/
public readonly LastClickLocation: UIEventSource<{
lat: number;
lon: number;
}> = new UIEventSource<{ lat: number; lon: number }>(undefined);
/**
* The location as delivered by the GPS
*/
public currentGPSLocation: UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}> = new UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}>(undefined);
public layoutDefinition: string;
public installedThemes: UIEventSource<
{ layout: LayoutConfig; definition: string }[]
>;
public layerControlIsOpened: UIEventSource<boolean> =
QueryParameters.GetQueryParameter(
"layer-control-toggle",
"false",
"Whether or not the layer control is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => "" + b
);
public FilterIsOpened: UIEventSource<boolean> =
QueryParameters.GetQueryParameter(
"filter-toggle",
"false",
"Whether or not the filter is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => "" + b
);
public welcomeMessageOpenedTab = QueryParameters.GetQueryParameter(
"tab",
"0",
`The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`
).map<number>(
(str) => (isNaN(Number(str)) ? 0 : Number(str)),
[],
(n) => "" + n
);
constructor(layoutToUse: LayoutConfig) {
const self = this;
this.layoutToUse.setData(layoutToUse);
// -- Location control initialization
{
const zoom = State.asFloat(
QueryParameters.GetQueryParameter(
"z",
"" + (layoutToUse?.startZoom ?? 1),
"The initial/current zoom level"
).syncWith(LocalStorageSource.Get("zoom"))
);
const lat = State.asFloat(
QueryParameters.GetQueryParameter(
"lat",
"" + (layoutToUse?.startLat ?? 0),
"The initial/current latitude"
).syncWith(LocalStorageSource.Get("lat"))
);
const lon = State.asFloat(
QueryParameters.GetQueryParameter(
"lon",
"" + (layoutToUse?.startLon ?? 0),
"The initial/current longitude of the app"
).syncWith(LocalStorageSource.Get("lon"))
);
this.locationControl = new UIEventSource<Loc>({
zoom: Utils.asFloat(zoom.data),
lat: Utils.asFloat(lat.data),
lon: Utils.asFloat(lon.data),
}).addCallback((latlonz) => {
zoom.setData(latlonz.zoom);
lat.setData(latlonz.lat);
lon.setData(latlonz.lon);
});
this.layoutToUse.addCallback((layoutToUse) => {
const lcd = self.locationControl.data;
lcd.zoom = lcd.zoom ?? layoutToUse?.startZoom;
lcd.lat = lcd.lat ?? layoutToUse?.startLat;
lcd.lon = lcd.lon ?? layoutToUse?.startLon;
self.locationControl.ping();
});
}
// Helper function to initialize feature switches
function featSw(
key: string,
deflt: (layout: LayoutConfig) => boolean,
documentation: string
): UIEventSource<boolean> {
const queryParameterSource = QueryParameters.GetQueryParameter(
key,
public readonly selectedElement = new UIEventSource<any>(
undefined,
documentation
);
// I'm so sorry about someone trying to decipher this
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
return UIEventSource.flatten(
self.layoutToUse.map((layout) => {
const defaultValue = deflt(layout);
const queryParam = QueryParameters.GetQueryParameter(
key,
"" + defaultValue,
documentation
);
return queryParam.map((str) =>
str === undefined ? defaultValue : str !== "false"
);
}),
[queryParameterSource]
);
}
// Feature switch initialization - not as a function as the UIEventSources are readonly
{
this.featureSwitchUserbadge = featSw(
"fs-userbadge",
(layoutToUse) => layoutToUse?.enableUserBadge ?? true,
"Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode."
);
this.featureSwitchSearch = featSw(
"fs-search",
(layoutToUse) => layoutToUse?.enableSearch ?? true,
"Disables/Enables the search bar"
);
this.featureSwitchLayers = featSw(
"fs-layers",
(layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the layer control"
);
this.featureSwitchFilter = featSw(
"fs-filter",
(layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the filter"
);
this.featureSwitchAddNew = featSw(
"fs-add-new",
(layoutToUse) => layoutToUse?.enableAddNewPoints ?? true,
"Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)"
);
this.featureSwitchWelcomeMessage = featSw(
"fs-welcome-message",
() => true,
"Disables/enables the help menu or welcome message"
);
this.featureSwitchIframe = featSw(
"fs-iframe",
() => false,
"Disables/Enables the iframe-popup"
);
this.featureSwitchMoreQuests = featSw(
"fs-more-quests",
(layoutToUse) => layoutToUse?.enableMoreQuests ?? true,
"Disables/Enables the 'More Quests'-tab in the welcome message"
);
this.featureSwitchShareScreen = featSw(
"fs-share-screen",
(layoutToUse) => layoutToUse?.enableShareScreen ?? true,
"Disables/Enables the 'Share-screen'-tab in the welcome message"
);
this.featureSwitchGeolocation = featSw(
"fs-geolocation",
(layoutToUse) => layoutToUse?.enableGeolocation ?? true,
"Disables/Enables the geolocation button"
);
this.featureSwitchShowAllQuestions = featSw(
"fs-all-questions",
(layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false,
"Always show all questions"
);
this.featureSwitchIsTesting = QueryParameters.GetQueryParameter(
"test",
"false",
"If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org"
).map(
(str) => str === "true",
[],
(b) => "" + b
);
this.featureSwitchIsDebugging = QueryParameters.GetQueryParameter(
"debug",
"false",
"If true, shows some extra debugging help such as all the available tags on every object"
).map(
(str) => str === "true",
[],
(b) => "" + b
);
this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend",
"osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
);
}
{
// Some other feature switches
const customCssQP = QueryParameters.GetQueryParameter(
"custom-css",
"",
"If specified, the custom css from the given link will be loaded additionaly"
);
if (customCssQP.data !== undefined && customCssQP.data !== "") {
Utils.LoadCustomCss(customCssQP.data);
}
this.backgroundLayerId = QueryParameters.GetQueryParameter(
"background",
layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with"
);
}
if (Utils.runningFromConsole) {
return;
}
this.osmConnection = new OsmConnection(
this.featureSwitchIsTesting.data,
QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
layoutToUse?.id,
true,
// @ts-ignore
this.featureSwitchApiURL.data
"Selected element"
);
this.allElements = new ElementStorage();
this.changes = new Changes();
this.osmApiFeatureSource = new OsmApiFeatureSource();
/**
* Keeps track of relations: which way is part of which other way?
* Set by the overpass-updater; used in the metatagging
*/
public readonly knownRelations = new UIEventSource<Map<string, { role: string; relation: Relation }[]>>(undefined, "Relation memberships");
new PendingChangesUploader(this.changes, this.selectedElement);
public readonly featureSwitchUserbadge: UIEventSource<boolean>;
public readonly featureSwitchSearch: UIEventSource<boolean>;
public readonly featureSwitchLayers: UIEventSource<boolean>;
public readonly featureSwitchAddNew: UIEventSource<boolean>;
public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>;
public readonly featureSwitchIframe: UIEventSource<boolean>;
public readonly featureSwitchMoreQuests: UIEventSource<boolean>;
public readonly featureSwitchShareScreen: UIEventSource<boolean>;
public readonly featureSwitchGeolocation: UIEventSource<boolean>;
public readonly featureSwitchIsTesting: UIEventSource<boolean>;
public readonly featureSwitchIsDebugging: UIEventSource<boolean>;
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>;
public readonly featureSwitchApiURL: UIEventSource<string>;
public readonly featureSwitchFilter: UIEventSource<boolean>;
public readonly featureSwitchEnableExport: UIEventSource<boolean>;
public readonly featureSwitchFakeUser: UIEventSource<boolean>;
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
);
this.installedThemes = new InstalledThemes(
this.osmConnection
).installedThemes;
public featurePipeline: FeaturePipeline;
// Important: the favourite layers are initialized _after_ the installed themes, as these might contain an installedTheme
this.favouriteLayers = LocalStorageSource.Get("favouriteLayers")
.syncWith(this.osmConnection.GetLongPreference("favouriteLayers"))
.map(
(str) => Utils.Dedup(str?.split(";")) ?? [],
/**
* The map location: currently centered lat, lon and zoom
*/
public readonly locationControl = new UIEventSource<Loc>(undefined);
public backgroundLayer;
public readonly backgroundLayerId: UIEventSource<string>;
/* Last location where a click was registered
*/
public readonly LastClickLocation: UIEventSource<{
lat: number;
lon: number;
}> = new UIEventSource<{ lat: number; lon: number }>(undefined);
/**
* The location as delivered by the GPS
*/
public currentGPSLocation: UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}> = new UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}>(undefined);
public layoutDefinition: string;
public installedThemes: UIEventSource<{ layout: LayoutConfig; definition: string }[]>;
public layerControlIsOpened: UIEventSource<boolean> =
QueryParameters.GetQueryParameter(
"layer-control-toggle",
"false",
"Whether or not the layer control is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => "" + b
);
public FilterIsOpened: UIEventSource<boolean> =
QueryParameters.GetQueryParameter(
"filter-toggle",
"false",
"Whether or not the filter is shown"
).map<boolean>(
(str) => str !== "false",
[],
(b) => "" + b
);
public welcomeMessageOpenedTab = QueryParameters.GetQueryParameter(
"tab",
"0",
`The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`
).map<number>(
(str) => (isNaN(Number(str)) ? 0 : Number(str)),
[],
(layers) => Utils.Dedup(layers)?.join(";")
);
Locale.language.syncWith(this.osmConnection.GetPreference("language"));
Locale.language
.addCallback((currentLanguage) => {
const layoutToUse = self.layoutToUse.data;
if (layoutToUse === undefined) {
return;
}
if (this.layoutToUse.data.language.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],
"as",
currentLanguage,
" is unsupported"
);
// The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0]);
}
})
.ping();
new TitleHandler(this.layoutToUse, this.selectedElement, this.allElements);
}
private static asFloat(source: UIEventSource<string>): UIEventSource<number> {
return source.map(
(str) => {
let parsed = parseFloat(str);
return isNaN(parsed) ? undefined : parsed;
},
[],
(fl) => {
if (fl === undefined || isNaN(fl)) {
return undefined;
}
return ("" + fl).substr(0, 8);
}
(n) => "" + n
);
}
constructor(layoutToUse: LayoutConfig) {
const self = this;
this.layoutToUse.setData(layoutToUse);
// -- Location control initialization
{
const zoom = State.asFloat(
QueryParameters.GetQueryParameter(
"z",
"" + (layoutToUse?.startZoom ?? 1),
"The initial/current zoom level"
).syncWith(LocalStorageSource.Get("zoom"))
);
const lat = State.asFloat(
QueryParameters.GetQueryParameter(
"lat",
"" + (layoutToUse?.startLat ?? 0),
"The initial/current latitude"
).syncWith(LocalStorageSource.Get("lat"))
);
const lon = State.asFloat(
QueryParameters.GetQueryParameter(
"lon",
"" + (layoutToUse?.startLon ?? 0),
"The initial/current longitude of the app"
).syncWith(LocalStorageSource.Get("lon"))
);
this.locationControl = new UIEventSource<Loc>({
zoom: Utils.asFloat(zoom.data),
lat: Utils.asFloat(lat.data),
lon: Utils.asFloat(lon.data),
}).addCallback((latlonz) => {
zoom.setData(latlonz.zoom);
lat.setData(latlonz.lat);
lon.setData(latlonz.lon);
});
this.layoutToUse.addCallback((layoutToUse) => {
const lcd = self.locationControl.data;
lcd.zoom = lcd.zoom ?? layoutToUse?.startZoom;
lcd.lat = lcd.lat ?? layoutToUse?.startLat;
lcd.lon = lcd.lon ?? layoutToUse?.startLon;
self.locationControl.ping();
});
}
// Helper function to initialize feature switches
function featSw(
key: string,
deflt: (layout: LayoutConfig) => boolean,
documentation: string
): UIEventSource<boolean> {
const queryParameterSource = QueryParameters.GetQueryParameter(
key,
undefined,
documentation
);
// I'm so sorry about someone trying to decipher this
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
return UIEventSource.flatten(
self.layoutToUse.map((layout) => {
const defaultValue = deflt(layout);
const queryParam = QueryParameters.GetQueryParameter(
key,
"" + defaultValue,
documentation
);
return queryParam.map((str) =>
str === undefined ? defaultValue : str !== "false"
);
}),
[queryParameterSource]
);
}
// Feature switch initialization - not as a function as the UIEventSources are readonly
{
this.featureSwitchUserbadge = featSw(
"fs-userbadge",
(layoutToUse) => layoutToUse?.enableUserBadge ?? true,
"Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode."
);
this.featureSwitchSearch = featSw(
"fs-search",
(layoutToUse) => layoutToUse?.enableSearch ?? true,
"Disables/Enables the search bar"
);
this.featureSwitchLayers = featSw(
"fs-layers",
(layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the layer control"
);
this.featureSwitchFilter = featSw(
"fs-filter",
(layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the filter"
);
this.featureSwitchAddNew = featSw(
"fs-add-new",
(layoutToUse) => layoutToUse?.enableAddNewPoints ?? true,
"Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)"
);
this.featureSwitchWelcomeMessage = featSw(
"fs-welcome-message",
() => true,
"Disables/enables the help menu or welcome message"
);
this.featureSwitchIframe = featSw(
"fs-iframe",
() => false,
"Disables/Enables the iframe-popup"
);
this.featureSwitchMoreQuests = featSw(
"fs-more-quests",
(layoutToUse) => layoutToUse?.enableMoreQuests ?? true,
"Disables/Enables the 'More Quests'-tab in the welcome message"
);
this.featureSwitchShareScreen = featSw(
"fs-share-screen",
(layoutToUse) => layoutToUse?.enableShareScreen ?? true,
"Disables/Enables the 'Share-screen'-tab in the welcome message"
);
this.featureSwitchGeolocation = featSw(
"fs-geolocation",
(layoutToUse) => layoutToUse?.enableGeolocation ?? true,
"Disables/Enables the geolocation button"
);
this.featureSwitchShowAllQuestions = featSw(
"fs-all-questions",
(layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false,
"Always show all questions"
);
this.featureSwitchIsTesting = QueryParameters.GetQueryParameter(
"test",
"false",
"If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org"
).map(
(str) => str === "true",
[],
(b) => "" + b
);
this.featureSwitchIsDebugging = QueryParameters.GetQueryParameter(
"debug",
"false",
"If true, shows some extra debugging help such as all the available tags on every object"
).map(
(str) => str === "true",
[],
(b) => "" + b
);
this.featureSwitchFakeUser = QueryParameters.GetQueryParameter("fake-user", "false",
"If true, 'dryrun' mode is activated and a fake user account is loaded")
.map(str => str === "true", [], b => "" + b);
this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend",
"osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
);
this.featureSwitchUserbadge.addCallbackAndRun(userbadge => {
if (!userbadge) {
this.featureSwitchAddNew.setData(false)
}
})
}
{
// Some other feature switches
const customCssQP = QueryParameters.GetQueryParameter(
"custom-css",
"",
"If specified, the custom css from the given link will be loaded additionaly"
);
if (customCssQP.data !== undefined && customCssQP.data !== "") {
Utils.LoadCustomCss(customCssQP.data);
}
this.backgroundLayerId = QueryParameters.GetQueryParameter(
"background",
layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with"
);
}
if (Utils.runningFromConsole) {
return;
}
this.osmConnection = new OsmConnection(
this.featureSwitchIsTesting.data,
this.featureSwitchFakeUser.data,
QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
layoutToUse?.id,
true,
// @ts-ignore
this.featureSwitchApiURL.data
);
this.allElements = new ElementStorage();
this.changes = new Changes();
this.osmApiFeatureSource = new OsmApiFeatureSource();
new PendingChangesUploader(this.changes, this.selectedElement);
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
);
this.installedThemes = new InstalledThemes(
this.osmConnection
).installedThemes;
// Important: the favourite layers are initialized _after_ the installed themes, as these might contain an installedTheme
this.favouriteLayers = LocalStorageSource.Get("favouriteLayers")
.syncWith(this.osmConnection.GetLongPreference("favouriteLayers"))
.map(
(str) => Utils.Dedup(str?.split(";")) ?? [],
[],
(layers) => Utils.Dedup(layers)?.join(";")
);
Locale.language.syncWith(this.osmConnection.GetPreference("language"));
Locale.language
.addCallback((currentLanguage) => {
const layoutToUse = self.layoutToUse.data;
if (layoutToUse === undefined) {
return;
}
if (this.layoutToUse.data.language.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],
"as",
currentLanguage,
" is unsupported"
);
// The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0]);
}
})
.ping();
new TitleHandler(this.layoutToUse, this.selectedElement, this.allElements);
}
private static asFloat(source: UIEventSource<string>): UIEventSource<number> {
return source.map(
(str) => {
let parsed = parseFloat(str);
return isNaN(parsed) ? undefined : parsed;
},
[],
(fl) => {
if (fl === undefined || isNaN(fl)) {
return undefined;
}
return ("" + fl).substr(0, 8);
}
);
}
}

32
Svg.ts

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {Map} from "leaflet";
import {Utils} from "../../Utils";
export default class Minimap extends BaseUIElement {
@ -15,11 +16,13 @@ export default class Minimap extends BaseUIElement {
private readonly _location: UIEventSource<Loc>;
private _isInited = false;
private _allowMoving: boolean;
private readonly _leafletoptions: any;
constructor(options?: {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
allowMoving?: boolean
allowMoving?: boolean,
leafletOptions?: any
}
) {
super()
@ -28,10 +31,11 @@ export default class Minimap extends BaseUIElement {
this._location = options?.location ?? new UIEventSource<Loc>(undefined)
this._id = "minimap" + Minimap._nextId;
this._allowMoving = options.allowMoving ?? true;
this._leafletoptions = options.leafletOptions ?? {}
Minimap._nextId++
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;
@ -44,7 +48,6 @@ export default class Minimap extends BaseUIElement {
const self = this;
// @ts-ignore
const resizeObserver = new ResizeObserver(_ => {
console.log("Change in size detected!")
self.InitMap();
self.leafletMap?.data?.invalidateSize()
});
@ -72,8 +75,8 @@ export default class Minimap extends BaseUIElement {
const location = this._location;
let currentLayer = this._background.data.layer()
const map = L.map(this._id, {
center: [location.data?.lat ?? 0, location.data?.lon ?? 0],
const options = {
center: <[number, number]> [location.data?.lat ?? 0, location.data?.lon ?? 0],
zoom: location.data?.zoom ?? 2,
layers: [currentLayer],
zoomControl: false,
@ -82,8 +85,14 @@ export default class Minimap extends BaseUIElement {
scrollWheelZoom: this._allowMoving,
doubleClickZoom: this._allowMoving,
keyboard: this._allowMoving,
touchZoom: this._allowMoving
});
touchZoom: this._allowMoving,
// Disabling this breaks the geojson layer - don't ask me why! zoomAnimation: this._allowMoving,
fadeAnimation: this._allowMoving
}
Utils.Merge(this._leafletoptions, options)
const map = L.map(this._id, options);
map.setMaxBounds(
[[-100, -200], [100, 200]]

View file

@ -3,6 +3,7 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
export class Basemap {
@ -35,9 +36,8 @@ export class Basemap {
);
this.map.attributionControl.setPrefix(
"<span id='leaflet-attribution'></span> | <a href='https://osm.org'>OpenStreetMap</a>");
"<span id='leaflet-attribution'>A</span>");
extraAttribution.AttachTo('leaflet-attribution')
const self = this;
currentLayer.addCallbackAndRun(layer => {
@ -77,6 +77,7 @@ export class Basemap {
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng});
});
extraAttribution.AttachTo('leaflet-attribution')
}

View file

@ -0,0 +1,21 @@
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import State from "../../State";
import {FeatureSourceUtils} from "../../Logic/FeatureSource/FeatureSource";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
export class ExportDataButton extends Combine {
constructor() {
const t = Translations.t.general.download
const button = new SubtleButton(Svg.floppy_ui(), t.downloadGeojson.Clone().SetClass("font-bold"))
.onClick(() => {
const geojson = FeatureSourceUtils.extractGeoJson(State.state.featurePipeline)
const name = State.state.layoutToUse.data.id;
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson), `MapComplete_${name}_export_${new Date().toISOString().substr(0,19)}.geojson`);
})
super([button, t.licenseInfo.Clone().SetClass("link-underline")])
}
}

View file

@ -10,6 +10,7 @@ import LayerConfig from "../../Customizations/JSON/LayerConfig";
import BaseUIElement from "../BaseUIElement";
import { Translation } from "../i18n/Translation";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Svg from "../../Svg";
/**
* Shows the filter
@ -26,14 +27,63 @@ export default class FilterView extends ScrollableFullScreen {
}
private static Generatecontent(): BaseUIElement {
let filterPanel: BaseUIElement = new FixedUiElement("more stuff");
let filterPanel: BaseUIElement = new FixedUiElement("");
if (State.state.filteredLayers.data.length > 1) {
let layers = State.state.filteredLayers;
console.log(layers);
filterPanel = new Combine(["layerssss", "<br/>", filterPanel]);
}
let activeLayers = State.state.filteredLayers;
return filterPanel;
if (activeLayers === undefined) {
throw "ActiveLayers should be defined...";
}
const checkboxes: BaseUIElement[] = [];
for (const layer of activeLayers.data) {
const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem";
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle);
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(
iconStyle
);
if (layer.layerDef.name === undefined) {
continue;
}
const style = "display:flex;align-items:center;color:#007759";
const name: Translation = Translations.WT(layer.layerDef.name)?.Clone();
const styledNameChecked = name
.Clone()
.SetStyle("font-size:large;padding-left:1.25rem");
const styledNameUnChecked = name
.Clone()
.SetStyle("font-size:large;padding-left:1.25rem");
const layerChecked = new Combine([icon, styledNameChecked]).SetStyle(
style
);
const layerNotChecked = new Combine([
iconUnselected,
styledNameUnChecked,
]).SetStyle(style);
checkboxes.push(
new Toggle(layerChecked, layerNotChecked, layer.isDisplayed)
.ToggleOnClick()
.SetStyle("margin:0.3em;")
);
}
let combinedCheckboxes = new Combine(checkboxes);
combinedCheckboxes.SetStyle("display:flex;flex-direction:column;");
filterPanel = new Combine([combinedCheckboxes]);
return filterPanel;
}
}
}

View file

@ -2,11 +2,12 @@ import State from "../../State";
import BackgroundSelector from "./BackgroundSelector";
import LayerSelection from "./LayerSelection";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
import {ExportDataButton} from "./ExportDataButton";
export default class LayerControlPanel extends ScrollableFullScreen {
@ -14,27 +15,34 @@ export default class LayerControlPanel extends ScrollableFullScreen {
super(LayerControlPanel.GenTitle, LayerControlPanel.GeneratePanel, "layers", isShown);
}
private static GenTitle():BaseUIElement {
private static GenTitle(): BaseUIElement {
return Translations.t.general.layerSelection.title.Clone().SetClass("text-2xl break-words font-bold p-2")
}
private static GeneratePanel() : BaseUIElement {
let layerControlPanel: BaseUIElement = new FixedUiElement("");
private static GeneratePanel(): BaseUIElement {
const elements: BaseUIElement[] = []
if (State.state.layoutToUse.data.enableBackgroundLayerSelection) {
layerControlPanel = new BackgroundSelector();
layerControlPanel.SetStyle("margin:1em");
layerControlPanel.onClick(() => {
const backgroundSelector = new BackgroundSelector();
backgroundSelector.SetStyle("margin:1em");
backgroundSelector.onClick(() => {
});
elements.push(backgroundSelector)
}
if (State.state.filteredLayers.data.length > 1) {
const layerSelection = new LayerSelection(State.state.filteredLayers);
layerSelection.onClick(() => {
});
layerControlPanel = new Combine([layerSelection, "<br/>", layerControlPanel]);
}
elements.push(new Toggle(
new LayerSelection(State.state.filteredLayers),
undefined,
State.state.filteredLayers.map(layers => layers.length > 1)
))
return layerControlPanel;
elements.push(new Toggle(
new ExportDataButton(),
undefined,
State.state.featureSwitchEnableExport
))
return new Combine(elements).SetClass("flex flex-col")
}
}

View file

@ -74,7 +74,6 @@ export default class LayerSelection extends Combine {
);
}
super(checkboxes)
this.SetStyle("display:flex;flex-direction:column;")

View file

@ -62,6 +62,10 @@ export default class MoreScreen extends Combine {
let officialThemes = AllKnownLayouts.layoutsList
let buttons = officialThemes.map((layout) => {
if(layout === undefined){
console.trace("Layout is undefined")
return undefined
}
const button = MoreScreen.createLinkButton(layout)?.SetClass(buttonClass);
if(layout.id === personal.id){
return new VariableUiElement(

View file

@ -16,6 +16,10 @@ import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {Translation} from "../i18n/Translation";
import LocationInput from "../Input/LocationInput";
import {InputElement} from "../Input/InputElement";
import Loc from "../../Models/Loc";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -25,14 +29,18 @@ import {Translation} from "../i18n/Translation";
* - A 'read your unread messages before adding a point'
*/
/*private*/
interface PresetInfo {
description: string | Translation,
name: string | BaseUIElement,
icon: BaseUIElement,
icon: () => BaseUIElement,
tags: Tag[],
layerToAddTo: {
layerDef: LayerConfig,
isDisplayed: UIEventSource<boolean>
},
preciseInput?: {
preferredBackground?: string
}
}
@ -48,18 +56,16 @@ export default class SimpleAddUI extends Toggle {
new SubtleButton(Svg.envelope_ui(),
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false})
]);
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
function createNewPoint(tags: any[]){
const loc = State.state.LastClickLocation.data;
let feature = State.state.changes.createElement(tags, loc.lat, loc.lon);
function createNewPoint(tags: any[], location: { lat: number, lon: number }) {
let feature = State.state.changes.createElement(tags, location.lat, location.lon);
State.state.selectedElement.setData(feature);
}
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
const addUi = new VariableUiElement(
@ -68,8 +74,8 @@ export default class SimpleAddUI extends Toggle {
return presetsOverview
}
return SimpleAddUI.CreateConfirmButton(preset,
tags => {
createNewPoint(tags)
(tags, location) => {
createNewPoint(tags, location)
selectedPreset.setData(undefined)
}, () => {
selectedPreset.setData(undefined)
@ -86,7 +92,7 @@ export default class SimpleAddUI extends Toggle {
addUi,
State.state.layerUpdater.runningQuery
),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert") ,
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"),
State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
),
readYourMessages,
@ -103,22 +109,48 @@ export default class SimpleAddUI extends Toggle {
}
private static CreateConfirmButton(preset: PresetInfo,
confirm: (tags: any[]) => void,
confirm: (tags: any[], location: { lat: number, lon: number }) => void,
cancel: () => void): BaseUIElement {
let location = State.state.LastClickLocation;
let preciseInput: InputElement<Loc> = undefined
if (preset.preciseInput !== undefined) {
const locationSrc = new UIEventSource({
lat: location.data.lat,
lon: location.data.lon,
zoom: 19
});
let backgroundLayer = undefined;
if(preset.preciseInput.preferredBackground){
backgroundLayer= AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation:locationSrc
})
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
}
const confirmButton = new SubtleButton(preset.icon,
let confirmButton: BaseUIElement = new SubtleButton(preset.icon(),
new Combine([
Translations.t.general.add.addNew.Subs({category: preset.name}),
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col")
).SetClass("font-bold break-words")
.onClick(() => confirm(preset.tags));
.onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue() ?? location).data);
});
if (preciseInput !== undefined) {
confirmButton = new Combine([preciseInput, confirmButton])
}
const openLayerControl =
const openLayerControl =
new SubtleButton(
Svg.layers_ui(),
new Combine([
@ -128,9 +160,9 @@ export default class SimpleAddUI extends Toggle {
Translations.t.general.add.openLayerControl
])
)
.onClick(() => State.state.layerControlIsOpened.setData(true))
.onClick(() => State.state.layerControlIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
@ -140,12 +172,12 @@ export default class SimpleAddUI extends Toggle {
const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(cancel )
).onClick(cancel)
return new Combine([
Translations.t.general.add.confirmIntro.Subs({title: preset.name}),
State.state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined ,
State.state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined,
openLayerOrConfirm,
cancelButton,
preset.description,
@ -180,11 +212,11 @@ export default class SimpleAddUI extends Toggle {
}
private static CreatePresetSelectButton(preset: PresetInfo){
private static CreatePresetSelectButton(preset: PresetInfo) {
const tagInfo =SimpleAddUI.CreateTagInfoFor(preset, false);
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, false);
return new SubtleButton(
preset.icon,
preset.icon(),
new Combine([
Translations.t.general.add.addNew.Subs({
category: preset.name
@ -194,29 +226,30 @@ export default class SimpleAddUI extends Toggle {
]).SetClass("flex flex-col")
)
}
/*
* Generates the list with all the buttons.*/
/*
* Generates the list with all the buttons.*/
private static CreatePresetButtons(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement {
const allButtons = [];
for (const layer of State.state.filteredLayers.data) {
if(layer.isDisplayed.data === false && State.state.featureSwitchLayers){
if (layer.isDisplayed.data === false && State.state.featureSwitchLayers) {
continue;
}
const presets = layer.layerDef.presets;
for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: BaseUIElement = layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
let icon:() => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
.SetClass("w-12 h-12 block relative");
const presetInfo: PresetInfo = {
tags: preset.tags,
layerToAddTo: layer,
name: preset.title,
description: preset.description,
icon: icon
icon: icon,
preciseInput: preset.preciseInput
}
const button = SimpleAddUI.CreatePresetSelectButton(presetInfo);

View file

@ -66,6 +66,7 @@ export default class DirectionInput extends InputElement<string> {
})
this.RegisterTriggers(element)
element.style.overflow = "hidden"
return element;
}

View file

@ -0,0 +1,35 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {Translation} from "../i18n/Translation";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
export default class InputElementWrapper<T> extends InputElement<T> {
public readonly IsSelected: UIEventSource<boolean>;
private readonly _inputElement: InputElement<T>;
private readonly _renderElement: BaseUIElement
constructor(inputElement: InputElement<T>, translation: Translation, key: string, tags: UIEventSource<any>) {
super()
this._inputElement = inputElement;
this.IsSelected = inputElement.IsSelected
const mapping = new Map<string, BaseUIElement>()
mapping.set(key, inputElement)
this._renderElement = new SubstitutedTranslation(translation, tags, mapping)
}
GetValue(): UIEventSource<T> {
return this._inputElement.GetValue();
}
IsValid(t: T): boolean {
return this._inputElement.IsValid(t);
}
protected InnerConstructElement(): HTMLElement {
return this._renderElement.ConstructElement();
}
}

185
UI/Input/LengthInput.ts Normal file
View file

@ -0,0 +1,185 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import {GeoOperations} from "../../Logic/GeoOperations";
import DirectionInput from "./DirectionInput";
import {RadioButton} from "./RadioButton";
import {FixedInputElement} from "./FixedInputElement";
/**
* Selects a length after clicking on the minimap, in meters
*/
export default class LengthInput extends InputElement<string> {
private readonly _location: UIEventSource<Loc>;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<string>;
private background;
constructor(mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>) {
super();
this._location = location;
this.value = value ?? new UIEventSource<string>(undefined);
this.background = mapBackground;
this.SetClass("block")
}
GetValue(): UIEventSource<string> {
return this.value;
}
IsValid(str: string): boolean {
const t = Number(str)
return !isNaN(t) && t >= 0 && t <= 360;
}
protected InnerConstructElement(): HTMLElement {
const modeElement = new RadioButton([
new FixedInputElement("Measure", "measure"),
new FixedInputElement("Move", "move")
])
// @ts-ignore
let map = undefined
if (!Utils.runningFromConsole) {
map = DirectionInput.constructMinimap({
background: this.background,
allowMoving: false,
location: this._location,
leafletOptions: {
tap: true
}
})
}
const element = new Combine([
new Combine([Svg.length_crosshair_svg().SetStyle(
`position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`)
])
.SetClass("block length-crosshair-svg relative")
.SetStyle("z-index: 1000; visibility: hidden"),
map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"),
])
.SetClass("relative block bg-white border border-black rounded-3xl overflow-hidden")
.ConstructElement()
this.RegisterTriggers(element, map?.leafletMap)
element.style.overflow = "hidden"
element.style.display = "block"
return element
}
private RegisterTriggers(htmlElement: HTMLElement, leafletMap: UIEventSource<L.Map>) {
let firstClickXY: [number, number] = undefined
let lastClickXY: [number, number] = undefined
const self = this;
function onPosChange(x: number, y: number, isDown: boolean, isUp?: boolean) {
if (x === undefined || y === undefined) {
// Touch end
firstClickXY = undefined;
lastClickXY = undefined;
return;
}
const rect = htmlElement.getBoundingClientRect();
// From the central part of location
const dx = x - rect.left;
const dy = y - rect.top;
if (isDown) {
if (lastClickXY === undefined && firstClickXY === undefined) {
firstClickXY = [dx, dy];
} else if (firstClickXY !== undefined && lastClickXY === undefined) {
lastClickXY = [dx, dy]
} else if (firstClickXY !== undefined && lastClickXY !== undefined) {
// we measure again
firstClickXY = [dx, dy]
lastClickXY = undefined;
}
}
if (isUp) {
const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0]))
if (distance > 15) {
lastClickXY = [dx, dy]
}
} else if (lastClickXY !== undefined) {
return;
}
const measurementCrosshair = htmlElement.getElementsByClassName("length-crosshair-svg")[0] as HTMLElement
const measurementCrosshairInner: HTMLElement = <HTMLElement>measurementCrosshair.firstChild
if (firstClickXY === undefined) {
measurementCrosshair.style.visibility = "hidden"
} else {
measurementCrosshair.style.visibility = "unset"
measurementCrosshair.style.left = firstClickXY[0] + "px";
measurementCrosshair.style.top = firstClickXY[1] + "px"
const angle = 180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx) / Math.PI;
const angleGeo = (angle + 270) % 360
measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)`;
const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0]))
measurementCrosshairInner.style.width = (distance * 2) + "px"
measurementCrosshairInner.style.marginLeft = -distance + "px"
measurementCrosshairInner.style.marginTop = -distance + "px"
const leaflet = leafletMap?.data
if (leaflet) {
const first = leaflet.layerPointToLatLng(firstClickXY)
const last = leaflet.layerPointToLatLng([dx, dy])
const geoDist = Math.floor(GeoOperations.distanceBetween([first.lng, first.lat], [last.lng, last.lat]) * 100000) / 100
self.value.setData("" + geoDist)
}
}
}
htmlElement.ontouchstart = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true);
ev.preventDefault();
}
htmlElement.ontouchmove = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false);
ev.preventDefault();
}
htmlElement.ontouchend = (ev: TouchEvent) => {
onPosChange(undefined, undefined, false, true);
ev.preventDefault();
}
htmlElement.onmousedown = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, true);
ev.preventDefault();
}
htmlElement.onmouseup = (ev) => {
onPosChange(ev.clientX, ev.clientY, false, true);
ev.preventDefault();
}
htmlElement.onmousemove = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, false);
ev.preventDefault();
}
}
}

76
UI/Input/LocationInput.ts Normal file
View file

@ -0,0 +1,76 @@
import {InputElement} from "./InputElement";
import Loc from "../../Models/Loc";
import {UIEventSource} from "../../Logic/UIEventSource";
import Minimap from "../Base/Minimap";
import BaseLayer from "../../Models/BaseLayer";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
export default class LocationInput extends InputElement<Loc> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private _centerLocation: UIEventSource<Loc>;
private readonly mapBackground : UIEventSource<BaseLayer>;
constructor(options?: {
mapBackground?: UIEventSource<BaseLayer>,
centerLocation?: UIEventSource<Loc>,
}) {
super();
options = options ?? {}
options.centerLocation = options.centerLocation ?? new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1})
this._centerLocation = options.centerLocation;
this.mapBackground = options.mapBackground ?? State.state.backgroundLayer
this.SetClass("block h-full")
}
GetValue(): UIEventSource<Loc> {
return this._centerLocation;
}
IsValid(t: Loc): boolean {
return t !== undefined;
}
protected InnerConstructElement(): HTMLElement {
const map = new Minimap(
{
location: this._centerLocation,
background: this.mapBackground
}
)
map.leafletMap.addCallbackAndRunD(leaflet => {
console.log(leaflet.getBounds(), leaflet.getBounds().pad(0.15))
leaflet.setMaxBounds(
leaflet.getBounds().pad(0.15)
)
})
this.mapBackground.map(layer => {
const leaflet = map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
return;
}
leaflet.setMaxZoom(layer.max_zoom)
leaflet.setMinZoom(layer.max_zoom - 3)
leaflet.setZoom(layer.max_zoom - 1)
}, [map.leafletMap])
return new Combine([
new Combine([
Svg.crosshair_empty_ui()
.SetClass("block relative")
.SetStyle("left: -1.25rem; top: -1.25rem; width: 2.5rem; height: 2.5rem")
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"),
map
.SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement();
}
}

View file

@ -103,7 +103,7 @@ export class RadioButton<T> extends InputElement<T> {
const block = document.createElement("div")
block.appendChild(input)
block.appendChild(label)
block.classList.add("flex","w-full","border", "rounded-full", "border-gray-400","m-1")
block.classList.add("flex","w-full","border", "rounded-3xl", "border-gray-400","m-1")
wrappers.push(block)
form.appendChild(block)

View file

@ -36,11 +36,11 @@ export class TextField extends InputElement<string> {
this.SetClass("form-text-field")
let inputEl: HTMLElement
if (options.htmlType === "area") {
this.SetClass("w-full box-border max-w-full")
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")

View file

@ -13,6 +13,8 @@ import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import {Unit} from "../../Customizations/JSON/Denomination";
import BaseUIElement from "../BaseUIElement";
import LengthInput from "./LengthInput";
import {GeoOperations} from "../../Logic/GeoOperations";
interface TextFieldDef {
name: string,
@ -21,14 +23,16 @@ interface TextFieldDef {
reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number],
mapBackgroundLayer?: UIEventSource<any>
mapBackgroundLayer?: UIEventSource<any>,
args: (string | number | boolean)[]
feature?: any
}) => InputElement<string>,
inputmode?: string
}
export default class ValidatedTextField {
public static bestLayerAt: (location: UIEventSource<Loc>, preferences: UIEventSource<string[]>) => any
public static tpList: TextFieldDef[] = [
ValidatedTextField.tp(
@ -63,6 +67,83 @@ export default class ValidatedTextField {
return [year, month, day].join('-');
},
(value) => new SimpleDatePicker(value)),
ValidatedTextField.tp(
"direction",
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
}, str => str,
(value, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
options.mapBackgroundLayer = ValidatedTextField.bestLayerAt(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const di = new DirectionInput(options.mapBackgroundLayer, location, value)
di.SetStyle("height: 20rem;");
return di;
},
"numeric"
),
ValidatedTextField.tp(
"length",
"A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma seperated) ], e.g. `[\"21\", \"map,photo\"]",
(str) => {
const t = Number(str)
return !isNaN(t)
},
str => str,
(value, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
// Bit of a hack: we project the centerpoint to the closes point on the road - if available
if(options.feature){
const lonlat: [number, number] = [...options.location]
lonlat.reverse()
options.location = <[number,number]> GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates
options.location.reverse()
}
options.feature
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
options.mapBackgroundLayer = ValidatedTextField.bestLayerAt(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const li = new LengthInput(options.mapBackgroundLayer, location, value)
li.SetStyle("height: 20rem;")
return li;
}
),
ValidatedTextField.tp(
"wikidata",
"A wikidata identifier, e.g. Q42",
@ -113,22 +194,6 @@ export default class ValidatedTextField {
undefined,
undefined,
"numeric"),
ValidatedTextField.tp(
"direction",
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
}, str => str,
(value, options) => {
return new DirectionInput(options.mapBackgroundLayer , new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: 19
}),value);
},
"numeric"
),
ValidatedTextField.tp(
"float",
"A decimal",
@ -222,6 +287,7 @@ export default class ValidatedTextField {
* {string (typename) --> TextFieldDef}
*/
public static AllTypes = ValidatedTextField.allTypesDict();
public static InputForType(type: string, options?: {
placeholder?: string | BaseUIElement,
value?: UIEventSource<string>,
@ -233,7 +299,9 @@ export default class ValidatedTextField {
country?: () => string,
location?: [number /*lat*/, number /*lon*/],
mapBackgroundLayer?: UIEventSource<any>,
unit?: Unit
unit?: Unit,
args?: (string | number | boolean)[] // Extra arguments for the inputHelper,
feature?: any
}): InputElement<string> {
options = options ?? {};
options.placeholder = options.placeholder ?? type;
@ -247,7 +315,7 @@ export default class ValidatedTextField {
if (str === undefined) {
return false;
}
if(options.unit) {
if (options.unit) {
str = options.unit.stripUnitParts(str)
}
return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country);
@ -268,7 +336,7 @@ export default class ValidatedTextField {
})
}
if(options.unit) {
if (options.unit) {
// We need to apply a unit.
// This implies:
// We have to create a dropdown with applicable denominations, and fuse those values
@ -282,23 +350,22 @@ export default class ValidatedTextField {
})
)
unitDropDown.GetValue().setData(unit.defaultDenom)
unitDropDown.SetStyle("width: min-content")
unitDropDown.SetClass("w-min")
input = new CombinedInputElement(
input,
unitDropDown,
// combine the value from the textfield and the dropdown into the resulting value that should go into OSM
(text, denom) => denom?.canonicalValue(text, true) ?? undefined,
(text, denom) => denom?.canonicalValue(text, true) ?? undefined,
(valueWithDenom: string) => {
// Take the value from OSM and feed it into the textfield and the dropdown
const withDenom = unit.findDenomination(valueWithDenom);
if(withDenom === undefined)
{
if (withDenom === undefined) {
// Not a valid value at all - we give it undefined and leave the details up to the other elements
return [undefined, undefined]
}
const [strippedText, denom] = withDenom
if(strippedText === undefined){
if (strippedText === undefined) {
return [undefined, undefined]
}
return [strippedText, denom]
@ -306,18 +373,20 @@ export default class ValidatedTextField {
).SetClass("flex")
}
if (tp.inputHelper) {
const helper = tp.inputHelper(input.GetValue(), {
const helper = tp.inputHelper(input.GetValue(), {
location: options.location,
mapBackgroundLayer: options.mapBackgroundLayer
mapBackgroundLayer: options.mapBackgroundLayer,
args: options.args,
feature: options.feature
})
input = new CombinedInputElement(input, helper,
(a, _) => a, // We can ignore b, as they are linked earlier
a => [a, a]
);
);
}
return input;
}
public static HelpText(): string {
const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n")
return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations
@ -329,7 +398,9 @@ export default class ValidatedTextField {
reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number],
mapBackgroundLayer: UIEventSource<any>
mapBackgroundLayer: UIEventSource<any>,
args: string[],
feature: any
}) => InputElement<string>,
inputmode?: string): TextFieldDef {

View file

@ -36,7 +36,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
.SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2");
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")
"block w-8 h-8 align-baseline box-content sm:p-0.5", "width: 2rem;")
))
.SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2")

View file

@ -16,31 +16,31 @@ export default class TagRenderingAnswer extends VariableUiElement {
throw "Trying to generate a tagRenderingAnswer without configuration..."
}
super(tagsSource.map(tags => {
if(tags === undefined){
if (tags === undefined) {
return undefined;
}
if(configuration.condition){
if(!configuration.condition.matchesProperties(tags)){
if (configuration.condition) {
if (!configuration.condition.matchesProperties(tags)) {
return undefined;
}
}
const trs = Utils.NoNull(configuration.GetRenderValues(tags));
if(trs.length === 0){
return undefined;
}
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource))
if(valuesToRender.length === 1){
return valuesToRender[0];
}else if(valuesToRender.length > 1){
return new List(valuesToRender)
}
return undefined;
}).map((element : BaseUIElement) => element?.SetClass(contentClasses)?.SetStyle(contentStyle)))
this.SetClass("flex items-center flex-row text-lg link-underline tag-renering-answer")
const trs = Utils.NoNull(configuration.GetRenderValues(tags));
if (trs.length === 0) {
return undefined;
}
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource))
if (valuesToRender.length === 1) {
return valuesToRender[0];
} else if (valuesToRender.length > 1) {
return new List(valuesToRender)
}
return undefined;
}).map((element: BaseUIElement) => element?.SetClass(contentClasses)?.SetStyle(contentStyle)))
this.SetClass("flex items-center flex-row text-lg link-underline")
this.SetStyle("word-wrap: anywhere;");
}

View file

@ -24,6 +24,7 @@ import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement";
import {DropDown} from "../Input/DropDown";
import {Unit} from "../../Customizations/JSON/Denomination";
import InputElementWrapper from "../Input/InputElementWrapper";
/**
* Shows the question element.
@ -128,7 +129,7 @@ export default class TagRenderingQuestion extends Combine {
}
return Utils.NoNull(configuration.mappings?.map((m,i) => excludeIndex === i ? undefined: m.ifnot))
}
const ff = TagRenderingQuestion.GenerateFreeform(configuration, applicableUnit, tagsSource.data);
const ff = TagRenderingQuestion.GenerateFreeform(configuration, applicableUnit, tagsSource);
const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0
if (mappings.length < 8 || configuration.multiAnswer || hasImages) {
@ -289,7 +290,7 @@ export default class TagRenderingQuestion extends Combine {
(t0, t1) => t1.isEquivalent(t0));
}
private static GenerateFreeform(configuration: TagRenderingConfig, applicableUnit: Unit, tagsData: any): InputElement<TagsFilter> {
private static GenerateFreeform(configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource<any>): InputElement<TagsFilter> {
const freeform = configuration.freeform;
if (freeform === undefined) {
return undefined;
@ -328,20 +329,34 @@ export default class TagRenderingQuestion extends Combine {
return undefined;
}
let input: InputElement<string> = ValidatedTextField.InputForType(configuration.freeform.type, {
const tagsData = tags.data;
const feature = State.state.allElements.ContainingFeatures.get(tagsData.id)
const input: InputElement<string> = ValidatedTextField.InputForType(configuration.freeform.type, {
isValid: (str) => (str.length <= 255),
country: () => tagsData._country,
location: [tagsData._lat, tagsData._lon],
mapBackgroundLayer: State.state.backgroundLayer,
unit: applicableUnit
unit: applicableUnit,
args: configuration.freeform.helperArgs,
feature: feature
});
input.GetValue().setData(tagsData[configuration.freeform.key]);
input.GetValue().setData(tagsData[freeform.key] ?? freeform.default);
return new InputElementMap(
let inputTagsFilter : InputElement<TagsFilter> = new InputElementMap(
input, (a, b) => a === b || (a?.isEquivalent(b) ?? false),
pickString, toString
);
if(freeform.inline){
inputTagsFilter.SetClass("w-16-imp")
inputTagsFilter = new InputElementWrapper(inputTagsFilter, configuration.render, freeform.key, tags)
inputTagsFilter.SetClass("block")
}
return inputTagsFilter;
}

View file

@ -80,9 +80,7 @@ export default class ShowDataLayer {
if (zoomToFeatures) {
try {
mp.fitBounds(geoLayer.getBounds())
mp.fitBounds(geoLayer.getBounds(), {animate: false})
} catch (e) {
console.error(e)
}
@ -148,7 +146,9 @@ export default class ShowDataLayer {
const popup = L.popup({
autoPan: true,
closeOnEscapeKey: true,
closeButton: false
closeButton: false,
autoPanPaddingTopLeft: [15,15],
}, leafletLayer);
leafletLayer.bindPopup(popup);

View file

@ -39,7 +39,8 @@ export default class SpecialVisualizations {
static constructMiniMap: (options?: {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
allowMoving?: boolean
allowMoving?: boolean,
leafletOptions?: any
}) => BaseUIElement;
static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource<any>, layoutToUse: UIEventSource<any>, enablePopups?: boolean, zoomToFeatures?: boolean) => any;
public static specialVisualizations: SpecialVisualization[] =
@ -369,7 +370,6 @@ export default class SpecialVisualizations {
if (unit === undefined) {
return value;
}
return unit.asHumanLongValue(value);
},
@ -379,6 +379,7 @@ export default class SpecialVisualizations {
}
]
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
private static GenHelpMessage() {

View file

@ -7,19 +7,43 @@ import SpecialVisualizations, {SpecialVisualization} from "./SpecialVisualizatio
import {Utils} from "../Utils";
import {VariableUiElement} from "./Base/VariableUIElement";
import Combine from "./Base/Combine";
import BaseUIElement from "./BaseUIElement";
export class SubstitutedTranslation extends VariableUiElement {
public constructor(
translation: Translation,
tagsSource: UIEventSource<any>) {
tagsSource: UIEventSource<any>,
mapping: Map<string, BaseUIElement> = undefined) {
const extraMappings: SpecialVisualization[] = [];
mapping?.forEach((value, key) => {
console.log("KV:", key, value)
extraMappings.push(
{
funcName: key,
constr: (() => {
return value
}),
docs: "Dynamically injected input element",
args: [],
example: ""
}
)
})
super(
Locale.language.map(language => {
const txt = translation.textFor(language)
let txt = translation.textFor(language);
if (txt === undefined) {
return undefined
}
return new Combine(SubstitutedTranslation.ExtractSpecialComponents(txt).map(
mapping?.forEach((_, key) => {
txt = txt.replace(new RegExp(`{${key}}`, "g"), `{${key}()}`)
})
return new Combine(SubstitutedTranslation.ExtractSpecialComponents(txt, extraMappings).map(
proto => {
if (proto.fixed !== undefined) {
return new VariableUiElement(tagsSource.map(tags => Utils.SubstituteKeys(proto.fixed, tags)));
@ -36,30 +60,35 @@ export class SubstitutedTranslation extends VariableUiElement {
})
)
this.SetClass("w-full")
}
public static ExtractSpecialComponents(template: string): {
fixed?: string, special?: {
public static ExtractSpecialComponents(template: string, extraMappings: SpecialVisualization[] = []): {
fixed?: string,
special?: {
func: SpecialVisualization,
args: string[],
style: string
}
}[] {
for (const knownSpecial of SpecialVisualizations.specialVisualizations) {
if (extraMappings.length > 0) {
console.log("Extra mappings are", extraMappings)
}
for (const knownSpecial of SpecialVisualizations.specialVisualizations.concat(extraMappings)) {
// Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way'
const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`);
if (matched != null) {
// We found a special component that should be brought to live
const partBefore = SubstitutedTranslation.ExtractSpecialComponents(matched[1]);
const partBefore = SubstitutedTranslation.ExtractSpecialComponents(matched[1], extraMappings);
const argument = matched[2].trim();
const style = matched[3]?.substring(1) ?? ""
const partAfter = SubstitutedTranslation.ExtractSpecialComponents(matched[4]);
const partAfter = SubstitutedTranslation.ExtractSpecialComponents(matched[4], extraMappings);
const args = knownSpecial.args.map(arg => arg.defaultValue ?? "");
if (argument.length > 0) {
const realArgs = argument.split(",").map(str => str.trim());
@ -73,11 +102,13 @@ export class SubstitutedTranslation extends VariableUiElement {
}
let element;
element = {special:{
args: args,
style: style,
func: knownSpecial
}}
element = {
special: {
args: args,
style: style,
func: knownSpecial
}
}
return [...partBefore, element, ...partAfter]
}
}

View file

@ -1,4 +1,5 @@
import * as colors from "./assets/colors.json"
import {TileRange} from "./Models/TileRange";
export class Utils {
@ -134,7 +135,7 @@ export class Utils {
}
return newArr;
}
public static MergeTags(a: any, b: any) {
const t = {};
for (const k in a) {
@ -358,9 +359,12 @@ export class Utils {
* @param contents
* @param fileName
*/
public static offerContentsAsDownloadableFile(contents: string, fileName: string = "download.txt") {
public static offerContentsAsDownloadableFile(contents: string | Blob, fileName: string = "download.txt") {
const element = document.createElement("a");
const file = new Blob([contents], {type: 'text/plain'});
let file;
if(typeof(contents) === "string"){
file = new Blob([contents], {type: 'text/plain'});
}else {file = contents;}
element.href = URL.createObjectURL(file);
element.download = fileName;
document.body.appendChild(element); // Required for this to work in FireFox
@ -447,14 +451,12 @@ export class Utils {
b: parseInt(hex.substr(5, 2), 16),
}
}
public static setDefaults(options, defaults){
for (let key in defaults){
if (!(key in options)) options[key] = defaults[key];
}
return options;
}
}
export interface TileRange {
xstart: number,
ystart: number,
xend: number,
yend: number,
total: number,
zoomlevel: number
}

View file

@ -1,7 +1,7 @@
{
"id": "parking",
"name": {
"nl": "parking"
"nl": "Parking"
},
"minzoom": 12,
"source": {
@ -25,13 +25,13 @@
{
"if": "amenity=parking",
"then": {
"nl": "{name:nl}"
"nl": "Auto Parking"
}
},
{
"if": "amenity=motorcycle_parking",
"then": {
"nl": "{name}"
"nl": "Motorfiets Parking"
}
},
{

View file

@ -73,7 +73,10 @@
},
"tags": [
"amenity=public_bookcase"
]
],
"preciseInput": {
"preferredBackground": "photo"
}
}
],
"tagRenderings": [
@ -139,7 +142,8 @@
},
"freeform": {
"key": "capacity",
"type": "nat"
"type": "nat",
"inline": true
}
},
{

View file

@ -1,7 +1,7 @@
{
"id": "watermill",
"name": {
"nl": "watermolens"
"nl": "Watermolens"
},
"minzoom": 12,
"source": {

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="16" height="16" rx="3" stroke="#007759" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View file

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="16" height="16" rx="3" fill="#007759" stroke="#007759" stroke-width="2"/>
<path d="M3.5 8L8 13L14 5" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100"
height="100"
viewBox="0 0 26.458333 26.458334"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="crosshair-empty.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6568542"
inkscape:cx="22.669779"
inkscape:cy="52.573519"
inkscape:document-units="px"
inkscape:current-layer="g848"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="999"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1">
<sodipodi:guide
position="13.229167,23.859748"
orientation="1,0"
id="guide815"
inkscape:locked="false" />
<sodipodi:guide
position="14.944824,13.229167"
orientation="0,1"
id="guide817"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-270.54165)">
<g
id="g848">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#5555ec;fill-opacity:0.98823529;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.26458333;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 13.162109,273.57617 c -5.6145729,0 -10.1933596,4.58074 -10.193359,10.19531 -6e-7,5.61458 4.5787861,10.19336 10.193359,10.19336 5.614574,0 10.195313,-4.57878 10.195313,-10.19336 0,-5.61457 -4.580739,-10.19531 -10.195313,-10.19531 z m 0,2.64649 c 4.184659,0 7.548829,3.36417 7.548829,7.54882 0,4.18466 -3.36417,7.54883 -7.548829,7.54883 -4.1846584,0 -7.546875,-3.36417 -7.5468746,-7.54883 -4e-7,-4.18465 3.3622162,-7.54882 7.5468746,-7.54882 z"
id="path815"
inkscape:connector-curvature="0" />
<path
id="path839"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#0055ec;fill-opacity:0.98823529;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.26458333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 13.212891,286.88672 a 1.0487243,1.0487243 0 0 0 -1.033203,1.06445 v 7.94922 a 1.048828,1.048828 0 1 0 2.097656,0 v -7.94922 a 1.0487243,1.0487243 0 0 0 -1.064453,-1.06445 z m 0,-16.36914 a 1.0487243,1.0487243 0 0 0 -1.033203,1.0625 v 7.94922 a 1.048828,1.048828 0 1 0 2.097656,0 v -7.94922 a 1.0487243,1.0487243 0 0 0 -1.064453,-1.0625 z m 4.246093,12.20508 a 1.048825,1.048825 0 1 0 0,2.09765 h 7.949219 a 1.048825,1.048825 0 1 0 0,-2.09765 z m -16.4179684,0 a 1.048825,1.048825 0 1 0 0,2.09765 h 7.9492188 a 1.048825,1.048825 0 1 0 0,-2.09765 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100"
height="100"
viewBox="0 0 26.458333 26.458334"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="crosshair-locked.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6568542"
inkscape:cx="27.044982"
inkscape:cy="77.667126"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="999"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="false">
<sodipodi:guide
position="13.229167,23.859748"
orientation="1,0"
id="guide815"
inkscape:locked="false" />
<sodipodi:guide
position="14.944824,13.229167"
orientation="0,1"
id="guide817"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-270.54165)">
<g
id="g827">
<circle
r="8.8715391"
cy="283.77081"
cx="13.16302"
id="path815"
style="fill:none;fill-opacity:1;stroke:#5555ec;stroke-width:2.64583335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817"
d="M 3.2841366,283.77082 H 1.0418969"
style="fill:none;stroke:#5555ec;stroke-width:2.09723878;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3"
d="M 25.405696,283.77082 H 23.286471"
style="fill:none;stroke:#5555ec;stroke-width:2.11666679;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3-6"
d="m 13.229167,295.9489 v -2.11763"
style="fill:none;stroke:#5555ec;stroke-width:2.11666679;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3-6-7"
d="m 13.229167,275.05759 v -3.44507"
style="fill:none;stroke:#5555ec;stroke-width:2.11666668;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
</g>
<path
style="fill:#5555ec;fill-opacity:0.98823529;stroke-width:0.6151033"
inkscape:connector-curvature="0"
d="m 16.850267,281.91543 h -0.65616 v -1.85094 c 0,0 0,-3.08489 -3.066169,-3.08489 -3.066169,0 -3.066169,3.08489 -3.066169,3.08489 v 1.85094 H 9.4056091 a 1.1835412,1.1907685 0 0 0 -1.1835412,1.19077 v 5.02838 a 1.1835412,1.1907685 0 0 0 1.1835412,1.1846 h 7.4446579 a 1.1835412,1.1907685 0 0 0 1.183541,-1.19078 v -5.0222 a 1.1835412,1.1907685 0 0 0 -1.183541,-1.19077 z m -3.722329,4.93583 a 1.2264675,1.233957 0 1 1 1.226468,-1.23395 1.2264675,1.233957 0 0 1 -1.226468,1.23395 z m 1.839702,-4.93583 h -3.679403 v -1.54245 c 0,-0.92546 0,-2.15942 1.839701,-2.15942 1.839702,0 1.839702,1.23396 1.839702,2.15942 z"
id="path822" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

3
assets/svg/download.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.08 8.86C8.13 8.53 8.24 8.24 8.38 7.99C8.52 7.74 8.72 7.53 8.97 7.37C9.21 7.22 9.51 7.15 9.88 7.14C10.11 7.15 10.32 7.19 10.51 7.27C10.71 7.36 10.89 7.48 11.03 7.63C11.17 7.78 11.28 7.96 11.37 8.16C11.46 8.36 11.5 8.58 11.51 8.8H13.3C13.28 8.33 13.19 7.9 13.02 7.51C12.85 7.12 12.62 6.78 12.32 6.5C12.02 6.22 11.66 6 11.24 5.84C10.82 5.68 10.36 5.61 9.85 5.61C9.2 5.61 8.63 5.72 8.15 5.95C7.67 6.18 7.27 6.48 6.95 6.87C6.63 7.26 6.39 7.71 6.24 8.23C6.09 8.75 6 9.29 6 9.87V10.14C6 10.72 6.08 11.26 6.23 11.78C6.38 12.3 6.62 12.75 6.94 13.13C7.26 13.51 7.66 13.82 8.14 14.04C8.62 14.26 9.19 14.38 9.84 14.38C10.31 14.38 10.75 14.3 11.16 14.15C11.57 14 11.93 13.79 12.24 13.52C12.55 13.25 12.8 12.94 12.98 12.58C13.16 12.22 13.27 11.84 13.28 11.43H11.49C11.48 11.64 11.43 11.83 11.34 12.01C11.25 12.19 11.13 12.34 10.98 12.47C10.83 12.6 10.66 12.7 10.46 12.77C10.27 12.84 10.07 12.86 9.86 12.87C9.5 12.86 9.2 12.79 8.97 12.64C8.72 12.48 8.52 12.27 8.38 12.02C8.24 11.77 8.13 11.47 8.08 11.14C8.03 10.81 8 10.47 8 10.14V9.87C8 9.52 8.03 9.19 8.08 8.86V8.86ZM10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM10 18C5.59 18 2 14.41 2 10C2 5.59 5.59 2 10 2C14.41 2 18 5.59 18 10C18 14.41 14.41 18 10 18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="859.53607pt"
height="858.4754pt"
viewBox="0 0 859.53607 858.4754"
preserveAspectRatio="xMidYMid meet"
id="svg14"
sodipodi:docname="length-crosshair.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<defs
id="defs18" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="999"
id="namedview16"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:zoom="0.5"
inkscape:cx="307.56567"
inkscape:cy="-35.669379"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg14"
inkscape:snap-smooth-nodes="true" />
<metadata
id="metadata2">
Created by potrace 1.15, written by Peter Selinger 2001-2017
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:2.99999994;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:71.99999853,71.99999853;stroke-dashoffset:0;stroke-opacity:1"
id="path816"
transform="rotate(-89.47199)"
sodipodi:type="arc"
sodipodi:cx="-425.24921"
sodipodi:cy="433.71375"
sodipodi:rx="428.34982"
sodipodi:ry="427.81949"
sodipodi:start="0"
sodipodi:end="4.7117019"
sodipodi:open="true"
d="M 3.1006165,433.71375 A 428.34982,427.81949 0 0 1 -425.1511,861.53322 428.34982,427.81949 0 0 1 -853.59898,433.90971 428.34982,427.81949 0 0 1 -425.54352,5.8943576" />
<path
style="fill:none;stroke:#000000;stroke-width:4.49999991;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 429.76804,430.08754 0,-429.19968"
id="path820"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.49999997;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:35.99999926,35.99999926;stroke-dashoffset:0"
d="m 857.58749,429.23771 -855.6389371,0 v 0"
id="path822"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path814"
d="M 429.76804,857.30628 V 428.78674"
style="fill:none;stroke:#000000;stroke-width:1.49999997;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:35.99999926,35.99999926;stroke-dashoffset:0" />
<path
inkscape:connector-curvature="0"
id="path826"
d="M 857.32232,1.0332137 H 1.6833879 v 0"
style="fill:none;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:17.99999963, 17.99999963;stroke-dashoffset:0;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path828"
d="M 857.58749,858.2377 H 1.9485529 v 0"
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:8.99999982, 8.99999982;stroke-dashoffset:0;stroke-opacity:1" />
<path
cx="-429.2377"
cy="429.76804"
rx="428.34982"
ry="427.81949"
transform="rotate(-90)"
id="path825"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:11.99999975;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -5.3639221,-424.71887 A 428.34982,427.81949 0 0 1 -429.83855,3.0831087 428.34982,427.81949 0 0 1 -861.99345,-416.97839"
sodipodi:open="true"
sodipodi:end="3.1234988"
sodipodi:start="0"
sodipodi:ry="427.81949"
sodipodi:rx="428.34982"
sodipodi:cy="-424.71887"
sodipodi:cx="-433.71375"
sodipodi:type="arc"
transform="rotate(-179.47199)"
id="path827"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -49,6 +49,14 @@
"license": "CC0",
"sources": []
},
{
"authors": [
"Hannah"
],
"path": "download.svg",
"license": "CC0",
"sources": []
},
{
"authors": [],
"path": "ampersand.svg",
@ -614,5 +622,635 @@
"path": "filter.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Hannah Declerck"
],
"path": "checkbox-empty.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Hannah Declerck"
],
"path": "checkbox-filled.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Hannah Declerck"
],
"path": "arrow-left-thin.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "direction_masked.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "direction_outline.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "direction_stroke.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "SocialImageForeground.svg",
"license": "CC-BY-SA",
"sources": [
"https://mapcomplete.osm.be"
]
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "add.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "addSmall.svg",
"license": "CC0",
"sources": []
},
{
"authors": [],
"path": "ampersand.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "arrow-left-smooth.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "arrow-right-smooth.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "back.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Github"
],
"path": "bug.svg",
"license": "MIT",
"sources": [
"https://commons.wikimedia.org/wiki/File:Octicons-bug.svg",
" https://github.com/primer/octicons"
]
},
{
"path": "camera-plus.svg",
"license": "CC-BY-SA 3.0",
"authors": [
"Dave Gandy",
"Pieter Vander Vennet"
],
"sources": [
"https://fontawesome.com/",
"https://commons.wikimedia.org/wiki/File:Camera_font_awesome.svg"
]
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "checkmark.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "circle.svg",
"license": "CC0",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet"
],
"path": "clock.svg",
"license": "CC0",
"sources": []
},
{
"authors": [],
"path": "close.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "compass.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "cross_bottom_right.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair-blue-center.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair-blue.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair-empty.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair-locked.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"Dave Gandy"
],
"path": "delete_icon.svg",
"license": "CC-BY-SA",
"sources": [
"https://commons.wikimedia.org/wiki/File:Trash_font_awesome.svg\rT"
]
},
{
"authors": [],
"path": "direction.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "direction_gradient.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "down.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "envelope.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"The Tango Desktop Project"
],
"path": "floppy.svg",
"license": "CC0",
"sources": [
"https://commons.wikimedia.org/wiki/File:Media-floppy.svg",
"http://tango.freedesktop.org/Tango_Desktop_Project"
]
},
{
"authors": [],
"path": "gear.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "help.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"Timothy Miller"
],
"path": "home.svg",
"license": "CC-BY-SA 3.0",
"sources": [
"https://commons.wikimedia.org/wiki/File:Home-icon.svg"
]
},
{
"authors": [
"Timothy Miller"
],
"path": "home_white_bg.svg",
"license": "CC-BY-SA 3.0",
"sources": [
"https://commons.wikimedia.org/wiki/File:Home-icon.svg"
]
},
{
"authors": [
"JOSM Team"
],
"path": "josm_logo.svg",
"license": "CC0",
"sources": [
"https://wiki.openstreetmap.org/wiki/File:JOSM_Logotype_2019.svg",
" https://josm.openstreetmap.de/"
]
},
{
"authors": [],
"path": "layers.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "layersAdd.svg",
"license": "CC0; trivial",
"sources": []
},
{
"path": "Ornament-Horiz-0.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-1.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-2.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-3.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-4.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-5.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"path": "Ornament-Horiz-6.svg",
"license": "CC-BY",
"authors": [
"Nightwolfdezines"
],
"sources": [
"https://www.vecteezy.com/vector-art/226361-ornaments-and-flourishes"
]
},
{
"authors": [],
"path": "star.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "star_outline.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "star_half.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "star_outline_half.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "logo.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "logout.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"Pieter Vander Vennet",
" OSM"
],
"path": "mapcomplete_logo.svg",
"license": "Logo; CC-BY-SA",
"sources": [
"https://mapcomplete.osm.be"
]
},
{
"authors": [
"Mapillary"
],
"path": "mapillary.svg",
"license": "Logo; All rights reserved",
"sources": [
"https://mapillary.com/"
]
},
{
"authors": [],
"path": "min.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "no_checkmark.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "or.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "osm-copyright.svg",
"license": "logo; all rights reserved",
"sources": [
"https://www.OpenStreetMap.org"
]
},
{
"authors": [
"OpenStreetMap U.S. Chapter"
],
"path": "osm-logo-us.svg",
"license": "Logo",
"sources": [
"https://www.openstreetmap.us/"
]
},
{
"authors": [],
"path": "osm-logo.svg",
"license": "logo; all rights reserved",
"sources": [
"https://www.OpenStreetMap.org"
]
},
{
"authors": [
"GitHub Octicons"
],
"path": "pencil.svg",
"license": "MIT",
"sources": [
"https://github.com/primer/octicons",
" https://commons.wikimedia.org/wiki/File:Octicons-pencil.svg"
]
},
{
"authors": [
"@ tyskrat"
],
"path": "phone.svg",
"license": "CC-BY 3.0",
"sources": [
"https://www.onlinewebfonts.com/icon/1059"
]
},
{
"authors": [],
"path": "pin.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "plus.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"@fatih"
],
"path": "pop-out.svg",
"license": "CC-BY 3.0",
"sources": [
"https://www.onlinewebfonts.com/icon/2151"
]
},
{
"authors": [],
"path": "reload.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "ring.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"OOjs UI Team and other contributors"
],
"path": "search.svg",
"license": "MIT",
"sources": [
"https://commons.wikimedia.org/wiki/File:OOjs_UI_indicator_search-rtl.svg",
"https://phabricator.wikimedia.org/diffusion/GOJU/browse/master/AUTHORS.txt"
]
},
{
"authors": [],
"path": "send_email.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "share.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "square.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"@felpgrc"
],
"path": "statistics.svg",
"license": "CC-BY 3.0",
"sources": [
"https://www.onlinewebfonts.com/icon/197818"
]
},
{
"authors": [
"MGalloway (WMF)"
],
"path": "translate.svg",
"license": "CC-BY-SA 3.0",
"sources": [
"https://commons.wikimedia.org/wiki/File:OOjs_UI_icon_language-ltr.svg"
]
},
{
"authors": [],
"path": "up.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [
"Wikidata"
],
"path": "wikidata.svg",
"license": "Logo; All rights reserved",
"sources": [
"https://www.wikidata.org"
]
},
{
"authors": [
"Wikimedia"
],
"path": "wikimedia-commons-white.svg",
"license": "Logo; All rights reserved",
"sources": [
"https://commons.wikimedia.org"
]
},
{
"authors": [
"Wikipedia"
],
"path": "wikipedia.svg",
"license": "Logo; All rights reserved",
"sources": [
"https://www.wikipedia.org/"
]
},
{
"authors": [
"Mapillary"
],
"path": "mapillary_black.svg",
"license": "Logo; All rights reserved",
"sources": [
"https://www.mapillary.com/"
]
},
{
"authors": [
"The Tango! Desktop Project"
],
"path": "floppy.svg",
"license": "CC0",
"sources": [
"https://commons.wikimedia.org/wiki/File:Media-floppy.svg"
]
}
]

View file

@ -1,15 +1,15 @@
{
"wikipedialink": {
"render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank'><img src='./assets/svg/wikipedia.svg' alt='WP'/></a>",
"condition": "wikipedia~*",
"condition": {
"or": [
"wikipedia~*",
"wikidata~*"
]
},
"mappings": [
{
"if": {
"and": [
"wikipedia=",
"wikidata~*"
]
},
"if": "wikipedia=",
"then": "<a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'><img src='./assets/svg/wikidata.svg' alt='WD'/></a>"
}
]
@ -59,8 +59,12 @@
"render": "<a href='https://openstreetmap.org/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'/></a>",
"mappings": [
{
"if": "id~=-",
"then": "<span class='alert'>Uploading...</alert>"
"if": "id~.*/-.*",
"then": ""
},
{
"if": "_backend~*",
"then": "<a href='{_backend}/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'/></a>"
}
],
"condition": "id~(node|way|relation)/[0-9]*"

Binary file not shown.

View file

@ -736,7 +736,7 @@
"_contained_climbing_route_ids=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p.id)",
"_difficulty_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:grade:french'])",
"_length_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:length'])",
"_contained_climbing_routes_count=JSON.parse(_contained_climbing_routes).length"
"_contained_climbing_routes_count=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').length"
]
},
{
@ -1412,8 +1412,8 @@
"_embedding_feature_properties=feat.overlapWith('climbing').map(f => f.feat.properties).filter(p => p !== undefined).map(p => {return{access: p.access, id: p.id, name: p.name, climbing: p.climbing, 'access:description': p['access:description']}})",
"_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]",
"_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0] ?? '{}'",
"_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock)?.rock",
"_embedding_features_with_rock:id=JSON.parse(_embedding_feature_with_rock)?.id",
"_embedding_features_with_rock:rock=JSON.parse(feat.properties._embedding_feature_with_rock ?? '{}')?.rock",
"_embedding_features_with_rock:id=JSON.parse(feat.properties._embedding_feature_with_rock ?? '{}')?.id",
"_embedding_feature:access=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').access",
"_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']",
"_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id"

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 20.28"><defs><style>.cls-1,.cls-3{fill:#fff;}.cls-2{fill:none;stroke-width:1.2px;}.cls-2,.cls-3{stroke:#fff;stroke-miterlimit:10;}.cls-3{stroke-width:0.5px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M18.77,10.71h0l-.1-.15-2.54-3.8h0a2.1,2.1,0,0,0-4,.89,2.22,2.22,0,0,0,0,.45h0v.26l0-.37-.48.3V8.7H11V8.29L10.49,8l0,.37V8.1h0a2.22,2.22,0,0,0,0-.45,2.09,2.09,0,0,0-4-.89h0L4,10.56a1.3,1.3,0,0,0-.1.15h0a3.56,3.56,0,0,0-.56,1.91,3.6,3.6,0,0,0,7.18.29v-1.6l.59-.53,0-.72h.41l0,.72.6.53v1.13c0,.06,0,.12,0,.18s0,.06,0,.09v.22l0,0a3.59,3.59,0,1,0,6.62-2.2Zm-11.85,4a2.14,2.14,0,1,1,2.14-2.14A2.14,2.14,0,0,1,6.92,14.76Zm8.81,0a2.14,2.14,0,1,1,2.14-2.14A2.14,2.14,0,0,1,15.73,14.76Z"/><path class="cls-2" d="M21.61,17.74V6.56a.35.35,0,0,0-.19-.3L11.58.67a.53.53,0,0,0-.52,0L1.23,6.26a.35.35,0,0,0-.19.3V17.74"/><path class="cls-3" d="M22.35,20H.65a.4.4,0,0,1,0-.8h21.7a.4.4,0,0,1,0,.8Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1,021 B

View file

@ -116,11 +116,5 @@
"path": "birdhide.svg",
"license": "CC0",
"sources": []
},
{
"authors": [],
"path": "birdshelter.svg",
"license": "CC0",
"sources": []
}
]

View file

@ -24,36 +24,58 @@
"startZoom": 15,
"widenFactor": 0.05,
"socialImage": "",
"defaultBackgroundId": "CartoDB.Positron",
"layers": [
{
"builtin": [
"nature_reserve"
],
"#": "Nature reserve with geometry, z>=13",
"builtin": "nature_reserve",
"override": {
"source": {
"osmTags": {
"+and": [
"operator~.*[nN]atuurpunt.*"
]
}
},
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"minzoom": "10",
"minzoom": "13",
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/nature_reserve.svg"
}
}
},
{
"builtin": [
"visitor_information_centre"
],
"#": "Nature reserve overview from cache, points only, z < 13",
"builtin": "nature_reserve",
"override": {
"source": {
"osmTags": {
"+and": [
"operator~.*[nN]atuurpunt.*"
]
}
},
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_nature_reserve_points.geojson"
},
"minzoom": "0",
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/nature_reserve.svg"
}
}
},
{
"builtin": "visitor_information_centre",
"override": {
"source": {
"osmTags": {
"+and": [
"operator~.*[nN]atuurpunt.*"
]
},
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"minzoom": "10",
"icon": {
@ -62,16 +84,17 @@
}
},
{
"builtin": [
"trail"
],
"builtin": "trail",
"override": {
"source": {
"osmTags": {
"+and": [
"operator~.*[nN]atuurpunt.*"
]
}
},
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"minzoom": "13",
"icon": {
@ -90,11 +113,14 @@
}
},
{
"builtin": [
"toilet"
],
"builtin": "toilet",
"override": {
"minzoom": "15",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/toilets.svg",
"mappings": [
@ -111,42 +137,49 @@
}
},
{
"builtin": [
"birdhide"
],
"builtin": "birdhide",
"override": {
"minzoom": "15",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/birdhide.svg"
}
}
},
{
"builtin": [
"picnic_table"
],
"builtin": "picnic_table",
"override": {
"minzoom": "16",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/picnic_table.svg"
}
}
},
{
"builtin": [
"drinking_water"
],
"builtin": "drinking_water",
"override": {
"minzoom": "16",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/drips.svg"
}
}
},
{
"builtin": [
"parking"
],
"builtin": "parking",
"override": {
"minzoom": "16",
"icon": {
@ -173,33 +206,42 @@
}
},
{
"builtin": [
"information_board"
],
"builtin": "information_board",
"override": {
"minzoom": "16",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/information_board.svg"
}
}
},
{
"builtin": [
"bench"
],
"builtin": "bench",
"override": {
"minzoom": "18",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/bench.svg"
}
}
},
{
"builtin": [
"watermill"
],
"builtin": "watermill",
"override": {
"minzoom": "18",
"source": {
"geoJson": "https://pietervdvn.github.io/natuurpunt_cache/natuurpunt_{layer}_{z}_{x}_{y}.geojson",
"geoJsonZoomLevel": 12,
"isOsmCache": true
},
"icon": {
"render": "circle:#FE6F32;./assets/themes/natuurpunt/watermill.svg"
}

View file

@ -62,7 +62,8 @@
"en": "What is the power output of this wind turbine? (e.g. 2.3 MW)"
},
"freeform": {
"key": "generator:output:electricity"
"key": "generator:output:electricity",
"type": "pfloat"
}
},
{
@ -85,7 +86,7 @@
},
"freeform": {
"key": "height",
"type": "float"
"type": "pfloat"
}
},
{
@ -179,6 +180,24 @@
}
],
"eraseInvalidValues": true
},
{
"appliesToKey": [
"height",
"rotor:diameter"
],
"applicableUnits": [
{
"canonicalDenomination": "m",
"alternativeDenomination": [
"meter"
],
"human": {
"en": " meter",
"nl": " meter"
}
}
]
}
],
"defaultBackgroundId": "CartoDB.Voyager"

View file

@ -105,11 +105,31 @@
{
"builtin": "slow_roads",
"override": {
"+tagRenderings": [
{
"question": "Is dit een publiek toegankelijk pad?",
"mappings": [
{
"if": "access=private",
"then": "Dit is een privaat pad"
},
{
"if": "access=no",
"then": "Dit is een privaat pad",
"hideInAnswer": true
},
{
"if": "access=permissive",
"then": "Dit pad is duidelijk in private eigendom, maar er hangen geen verbodsborden dus mag men erover"
}
]
}
],
"calculatedTags": [
"_part_of_walking_routes=Array.from(new Set(feat.memberships().map(r => \"<a href='#relation/\"+r.relation.id+\"'>\" + r.relation.tags.name + \"</a>\"))).join(', ')",
"_is_shadowed=feat.overlapWith('shadow').length > 0 ? 'yes': ''"
],
"minzoom": 9,
"minzoom": 18,
"source": {
"geoJsonLocal": "http://127.0.0.1:8080/speelplekken_{layer}_{z}_{x}_{y}.geojson",
"geoJson": "https://pietervdvn.github.io/speelplekken_cache/speelplekken_{layer}_{z}_{x}_{y}.geojson",

View file

@ -64,7 +64,13 @@
},
"tagRenderings": [
{
"render": "Deze straat is <b>{width:carriageway}m</b> breed"
"render": "Deze straat is <b>{width:carriageway}m</b> breed",
"question": "Hoe breed is deze straat?",
"freeform": {
"key": "width:carriageway",
"type": "length",
"helperArgs": [21, "map"]
}
},
{
"render": "Deze straat heeft <span class='alert'>{_width:difference}m</span> te weinig:",

View file

@ -82,6 +82,10 @@ html, body {
box-sizing: initial !important;
}
.leaflet-control-attribution {
display: block ruby;
}
svg, img {
box-sizing: content-box;
width: 100%;
@ -101,6 +105,10 @@ a {
width: min-content;
}
.w-16-imp {
width: 4rem !important;
}
.space-between{
justify-content: space-between;
}

View file

@ -19,10 +19,13 @@ import DirectionInput from "./UI/Input/DirectionInput";
import SpecialVisualizations from "./UI/SpecialVisualizations";
import ShowDataLayer from "./UI/ShowDataLayer";
import * as L from "leaflet";
import ValidatedTextField from "./UI/Input/ValidatedTextField";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
// Workaround for a stupid crash: inject some functions which would give stupid circular dependencies or crash the other nodejs scripts
SimpleMetaTagger.coder = new CountryCoder("https://pietervdvn.github.io/latlon2country/");
DirectionInput.constructMinimap = options => new Minimap(options)
ValidatedTextField.bestLayerAt = (location, layerPref) => AvailableBaseLayers.SelectBestLayerAccordingTo(location, layerPref)
SpecialVisualizations.constructMiniMap = options => new Minimap(options)
SpecialVisualizations.constructShowDataLayer = (features: UIEventSource<{ feature: any, freshness: Date }[]>,
leafletMap: UIEventSource<L.Map>,

View file

@ -149,6 +149,10 @@
"zoomInToSeeThisLayer": "Zoom in to see this layer",
"title": "Select layers"
},
"download": {
"downloadGeojson": "Download visible data as geojson",
"licenseInfo": "<h3>Copyright notice</h3>The provided is available under ODbL. Reusing this data is free for any purpose, but <ul><li>the attribution <b>© OpenStreetMap contributors</b></li><li>Any change to this data must be republished under the same license</li></ul>. Please see the full <a href='https://www.openstreetmap.org/copyright' target='_blank'>copyright notice</a> for details"
},
"weekdays": {
"abbreviations": {
"monday": "Mon",

View file

@ -1185,9 +1185,6 @@
},
"1": {
"then": "{name}"
},
"2": {
"then": "Fietsenstalling"
}
}
},

View file

@ -487,6 +487,11 @@
}
}
}
},
"presets": {
"0": {
"title": "Обслуживание велосипедов/магазин"
}
}
},
"defibrillator": {
@ -1064,6 +1069,7 @@
"1": {
"question": "Вы хотите добавить описание?"
}
}
},
"name": "Смотровая площадка"
}
}
}

View file

@ -122,8 +122,10 @@
"thanksForSharing": "Obrigado por compartilhar!",
"copiedToClipboard": "Link copiado para a área de transferência",
"addToHomeScreen": "<h3>Adicionar à sua tela inicial</h3>Você pode adicionar facilmente este site à tela inicial do smartphone para uma sensação nativa. Clique no botão 'adicionar à tela inicial' na barra de URL para fazer isso.",
"intro": "<h3>Compartilhe este mapa</h3> Compartilhe este mapa copiando o link abaixo e enviando-o para amigos e familiares:"
}
"intro": "<h3>Compartilhe este mapa</h3> Compartilhe este mapa copiando o link abaixo e enviando-o para amigos e familiares:",
"embedIntro": "<h3>Incorpore em seu site</h3>Por favor, incorpore este mapa em seu site.<br>Nós o encorajamos a fazer isso - você nem precisa pedir permissão.<br>É gratuito e sempre será. Quanto mais pessoas usarem isso, mais valioso se tornará."
},
"aboutMapcomplete": "<h3>Sobre o MapComplete</h3><p>Com o MapComplete, você pode enriquecer o OpenStreetMap com informações sobre um<b>único tema.</b>Responda a algumas perguntas e, em minutos, suas contribuições estarão disponíveis em todo o mundo! O<b>mantenedor do tema</b>define elementos, questões e linguagens para o tema.</p><h3>Saiba mais</h3><p>MapComplete sempre<b>oferece a próxima etapa</b>para saber mais sobre o OpenStreetMap.</p><ul><li>Quando incorporado em um site, o iframe vincula-se a um MapComplete em tela inteira</li><li>A versão em tela inteira oferece informações sobre o OpenStreetMap</li><li>A visualização funciona sem login, mas a edição requer um login do OSM.</li><li>Se você não estiver conectado, será solicitado que você faça o login</li><li>Depois de responder a uma única pergunta, você pode adicionar novos aponta para o mapa </li><li> Depois de um tempo, as tags OSM reais são mostradas, posteriormente vinculadas ao wiki </li></ul><p></p><br><p>Você percebeu<b>um problema</b>? Você tem uma<b>solicitação de recurso </b>? Quer<b>ajudar a traduzir</b>? Acesse <a href=\"https://github.com/pietervdvn/MapComplete\" target=\"_blank\">o código-fonte</a>ou <a href=\"https: //github.com/pietervdvn/MapComplete / issues \" target=\" _ blank \">rastreador de problemas.</a></p><p>Quer ver<b>seu progresso</b>? Siga a contagem de edição em<a href=\"https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%222021-01-01%22%2C%22value%22%3A%222021-01-01%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D\" target=\"_blank\">OsmCha</a>.</p>"
},
"index": {
"pickTheme": "Escolha um tema abaixo para começar.",
@ -142,10 +144,13 @@
"no_reviews_yet": "Não há comentários ainda. Seja o primeiro a escrever um e ajude a abrir os dados e os negócios!",
"name_required": "É necessário um nome para exibir e criar comentários",
"title_singular": "Um comentário",
"title": "{count} comentários"
"title": "{count} comentários",
"tos": "Se você criar um comentário, você concorda com <a href=\"https://mangrove.reviews/terms\" target=\"_blank\"> o TOS e a política de privacidade de Mangrove.reviews </a>",
"affiliated_reviewer_warning": "(Revisão de afiliados)"
},
"favourite": {
"reload": "Recarregar dados",
"panelIntro": "<h3>Seu tema pessoal</h3>Ative suas camadas favoritas de todos os temas oficiais"
"panelIntro": "<h3>Seu tema pessoal</h3>Ative suas camadas favoritas de todos os temas oficiais",
"loginNeeded": "<h3>Entrar</h3> Um layout pessoal está disponível apenas para usuários do OpenStreetMap"
}
}

View file

@ -6,6 +6,27 @@
"opening_hours": {
"question": "Was sind die Öffnungszeiten von {name}?",
"render": "<h3>Öffnungszeiten</h3>{opening_hours_table(opening_hours)}"
},
"level": {
"mappings": {
"2": {
"then": "Ist im ersten Stock"
},
"1": {
"then": "Ist im Erdgeschoss"
}
},
"render": "Befindet sich im {level}ten Stock",
"question": "In welchem Stockwerk befindet sich dieses Objekt?"
},
"description": {
"question": "Gibt es noch etwas, das die vorhergehenden Fragen nicht abgedeckt haben? Hier wäre Platz dafür.<br/><span style='font-size: small'>Bitte keine bereits erhobenen Informationen.</span>"
},
"website": {
"question": "Was ist die Website von {name}?"
},
"email": {
"question": "Was ist die Mail-Adresse von {name}?"
}
}
}
}

View file

@ -1 +1,30 @@
{}
{
"undefined": {
"level": {
"render": "Localizado no {level}o andar",
"mappings": {
"2": {
"then": "Localizado no primeiro andar"
},
"1": {
"then": "Localizado no térreo"
},
"0": {
"then": "Localizado no subsolo"
}
}
},
"opening_hours": {
"question": "Qual o horário de funcionamento de {name}?"
},
"website": {
"question": "Qual o site de {name}?"
},
"email": {
"question": "Qual o endereço de e-mail de {name}?"
},
"phone": {
"question": "Qual o número de telefone de {name}?"
}
}
}

View file

@ -15,6 +15,20 @@
"opening_hours": {
"question": "Какое время работы у {name}?",
"render": "<h3>Часы работы</h3>{opening_hours_table(opening_hours)}"
},
"level": {
"mappings": {
"2": {
"then": "Расположено на первом этаже"
},
"1": {
"then": "Расположено на первом этаже"
},
"0": {
"then": "Расположено под землей"
}
},
"render": "Расположено на {level}ом этаже"
}
}
}
}

View file

@ -1148,6 +1148,13 @@
"human": " gigawatts"
}
}
},
"1": {
"applicableUnits": {
"0": {
"human": " meter"
}
}
}
}
},

View file

@ -956,6 +956,13 @@
"human": " gigawatt"
}
}
},
"1": {
"applicableUnits": {
"0": {
"human": " meter"
}
}
}
}
},

920
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
"main": "index.js",
"scripts": {
"increase-memory": "export NODE_OPTIONS=--max_old_space_size=4096",
"start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/** assets/**/** assets/**/**/** vendor/* vendor/*/*",
"start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*",
"test": "ts-node test/TestAll.ts",
"init": "npm ci && npm run generate && npm run generate:editor-layer-index && npm run generate:layouts && npm run clean",
"add-weblate-upstream": "git remote add weblate-layers https://hosted.weblate.org/git/mapcomplete/layer-translations/ ; git remote update weblate-layers",
@ -20,7 +20,7 @@
"generate:layouts": "ts-node scripts/generateLayouts.ts",
"generate:docs": "ts-node scripts/generateDocs.ts && ts-node scripts/generateTaginfoProjectFiles.ts",
"generate:cache:speelplekken": "npm run generate:layeroverview && ts-node scripts/generateCache.ts speelplekken 14 ../pietervdvn.github.io/speelplekken_cache/ 51.20 4.35 51.09 4.56",
"generate:cache:natuurpunt": "npm run generate:layeroverview && ts-node scripts/generateCache.ts natuurpunt 12 ../pietervdvn.github.io/natuurpunt_cache/ 50.40 2.1 51.54 6.4",
"generate:cache:natuurpunt": "npm run generate:layeroverview && ts-node scripts/generateCache.ts natuurpunt 12 ../pietervdvn.github.io/natuurpunt_cache/ 50.40 2.1 51.54 6.4 --generate-point-overview nature_reserve,visitor_information_centre",
"generate:layeroverview": "npm run generate:licenses && echo {\\\"layers\\\":[], \\\"themes\\\":[]} > ./assets/generated/known_layers_and_themes.json && ts-node scripts/generateLayerOverview.ts --no-fail",
"generate:licenses": "ts-node scripts/generateLicenseInfo.ts --no-fail",
"generate:report": "cd Docs/Tools && ./compileStats.sh && git commit . -m 'New statistics ands graphs' && git push",
@ -65,9 +65,11 @@
"escape-html": "^1.0.3",
"i18next-client": "^1.11.4",
"jquery": "^3.6.0",
"jspdf": "^2.3.1",
"latlon2country": "^1.1.3",
"leaflet": "^1.7.1",
"leaflet-providers": "^1.10.2",
"leaflet-simple-map-screenshoter": "^0.4.4",
"leaflet.markercluster": "^1.4.1",
"libphonenumber": "0.0.10",
"libphonenumber-js": "^1.7.55",
@ -81,7 +83,6 @@
"postcss": "^7.0.36",
"prompt-sync": "^4.2.0",
"sharp": "^0.27.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2",
"tslint": "^6.1.3"
},
"devDependencies": {
@ -91,6 +92,7 @@
"fs": "0.0.1-security",
"marked": "^2.0.0",
"read-file": "^0.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"ts-node": "^9.0.0",
"ts-node-dev": "^1.0.0-pre.63",
"tslint-no-circular-imports": "^0.7.0",

View file

@ -12,7 +12,7 @@ import BaseUIElement from "./UI/BaseUIElement";
import Table from "./UI/Base/Table";
const connection = new OsmConnection(false, new UIEventSource<string>(undefined), "");
const connection = new OsmConnection(false, false, new UIEventSource<string>(undefined), "");
let rendered = false;

View file

@ -1,7 +1,7 @@
/**
* Generates a collection of geojson files based on an overpass query for a given theme
*/
import {TileRange, Utils} from "../Utils";
import {Utils} from "../Utils";
Utils.runningFromConsole = true
import {Overpass} from "../Logic/Osm/Overpass";
@ -17,6 +17,8 @@ import MetaTagging from "../Logic/MetaTagging";
import LayerConfig from "../Customizations/JSON/LayerConfig";
import {GeoOperations} from "../Logic/GeoOperations";
import {UIEventSource} from "../Logic/UIEventSource";
import * as fs from "fs";
import {TileRange} from "../Models/TileRange";
function createOverpassObject(theme: LayoutConfig) {
@ -139,7 +141,7 @@ async function downloadExtraData(theme: LayoutConfig)/* : any[] */ {
return allFeatures;
}
async function postProcess(targetdir: string, r: TileRange, theme: LayoutConfig, extraFeatures: any[]) {
function postProcess(targetdir: string, r: TileRange, theme: LayoutConfig, extraFeatures: any[]) {
let processed = 0;
const layerIndex = theme.LayerIndex();
for (let x = r.xstart; x <= r.xend; x++) {
@ -211,8 +213,9 @@ async function postProcess(targetdir: string, r: TileRange, theme: LayoutConfig,
}
}
async function splitPerLayer(targetdir: string, r: TileRange, theme: LayoutConfig) {
function splitPerLayer(targetdir: string, r: TileRange, theme: LayoutConfig) {
const z = r.zoomlevel;
const generated = {} // layer --> x --> y[]
for (let x = r.xstart; x <= r.xend; x++) {
for (let y = r.ystart; y <= r.yend; y++) {
const file = readFileSync(geoJsonName(targetdir + ".unfiltered", x, y, z), "UTF8")
@ -227,10 +230,8 @@ async function splitPerLayer(targetdir: string, r: TileRange, theme: LayoutConfi
.filter(f => f._matching_layer_id === layer.id)
.filter(f => {
const isShown = layer.isShown.GetRenderValue(f.properties).txt
if (isShown === "no") {
return false;
}
return true;
return isShown !== "no";
})
const new_path = geoJsonName(targetdir + "_" + layer.id, x, y, z);
console.log(new_path, " has ", geojson.features.length, " features after filtering (dropped ", oldLength - geojson.features.length, ")")
@ -239,18 +240,66 @@ async function splitPerLayer(targetdir: string, r: TileRange, theme: LayoutConfi
continue;
}
writeFileSync(new_path, JSON.stringify(geojson, null, " "))
if (generated[layer.id] === undefined) {
generated[layer.id] = {}
}
if (generated[layer.id][x] === undefined) {
generated[layer.id][x] = []
}
generated[layer.id][x].push(y)
}
}
}
for (const layer of theme.layers) {
const id = layer.id
const loaded = generated[id]
if(loaded === undefined){
console.log("No features loaded for layer ",id)
continue;
}
writeFileSync(targetdir + "_" + id + "_overview.json", JSON.stringify(loaded))
}
}
async function createOverview(targetdir: string, r: TileRange, z: number, layername: string) {
const allFeatures = []
for (let x = r.xstart; x <= r.xend; x++) {
for (let y = r.ystart; y <= r.yend; y++) {
const read_path = geoJsonName(targetdir + "_" + layername, x, y, z);
if (!fs.existsSync(read_path)) {
continue;
}
const features = JSON.parse(fs.readFileSync(read_path, "UTF-8")).features
const pointsOnly = features.map(f => {
f.properties["_last_edit:timestamp"] = "1970-01-01"
if (f.geometry.type === "Point") {
return f
} else {
return GeoOperations.centerpoint(f)
}
})
allFeatures.push(...pointsOnly)
}
}
const geojson = {
"type": "FeatureCollection",
"features": allFeatures
}
writeFileSync(targetdir + "_" + layername + "_points.geojson", JSON.stringify(geojson, null, " "))
}
async function main(args: string[]) {
if (args.length == 0) {
console.error("Expected arguments are: theme zoomlevel targetdirectory lat0 lon0 lat1 lon1")
console.error("Expected arguments are: theme zoomlevel targetdirectory lat0 lon0 lat1 lon1 [--generate-point-overview layer-name]")
return;
}
const themeName = args[0]
@ -285,8 +334,18 @@ async function main(args: string[]) {
} while (failed > 0)
const extraFeatures = await downloadExtraData(theme);
await postProcess(targetdir, tileRange, theme, extraFeatures)
await splitPerLayer(targetdir, tileRange, theme)
postProcess(targetdir, tileRange, theme, extraFeatures)
splitPerLayer(targetdir, tileRange, theme)
if (args[7] === "--generate-point-overview") {
const targetLayers = args[8].split(",")
for (const targetLayer of targetLayers) {
if (!theme.layers.some(l => l.id === targetLayer)) {
throw "Target layer " + targetLayer + " not found, did you mistype the name? Found layers are: " + theme.layers.map(l => l.id).join(",")
}
createOverview(targetdir, tileRange, zoomlevel, targetLayer)
}
}
}

View file

@ -4,7 +4,6 @@ import LayerConfig from "../Customizations/JSON/LayerConfig";
import * as licenses from "../assets/generated/license_info.json"
import LayoutConfig from "../Customizations/JSON/LayoutConfig";
import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson";
import {Translation} from "../UI/i18n/Translation";
import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson";
import AllKnownLayers from "../Customizations/AllKnownLayers";
@ -77,63 +76,6 @@ class LayerOverviewUtils {
return errorCount
}
validateTranslationCompletenessOfObject(object: any, expectedLanguages: string[], context: string) {
const missingTranlations = []
const translations: { tr: Translation, context: string }[] = [];
const queue: { object: any, context: string }[] = [{object: object, context: context}]
while (queue.length > 0) {
const item = queue.pop();
const o = item.object
for (const key in o) {
const v = o[key];
if (v === undefined) {
continue;
}
if (v instanceof Translation || v?.translations !== undefined) {
translations.push({tr: v, context: item.context});
} else if (
["string", "function", "boolean", "number"].indexOf(typeof (v)) < 0) {
queue.push({object: v, context: item.context + "." + key})
}
}
}
const missing = {}
const present = {}
for (const ln of expectedLanguages) {
missing[ln] = 0;
present[ln] = 0;
for (const translation of translations) {
if (translation.tr.translations["*"] !== undefined) {
continue;
}
const txt = translation.tr.translations[ln];
const isMissing = txt === undefined || txt === "" || txt.toLowerCase().indexOf("todo") >= 0;
if (isMissing) {
missingTranlations.push(`${translation.context},${ln},${translation.tr.txt}`)
missing[ln]++
} else {
present[ln]++;
}
}
}
let message = `Translation completeness for ${context}`
let isComplete = true;
for (const ln of expectedLanguages) {
const amiss = missing[ln];
const ok = present[ln];
const total = amiss + ok;
message += ` ${ln}: ${ok}/${total}`
if (ok !== total) {
isComplete = false;
}
}
return missingTranlations
}
main(args: string[]) {
const lt = this.loadThemesAndLayers();
@ -160,7 +102,6 @@ class LayerOverviewUtils {
}
let themeErrorCount = []
let missingTranslations = []
for (const themeFile of themeFiles) {
if (typeof themeFile.language === "string") {
themeErrorCount.push("The theme " + themeFile.id + " has a string as language. Please use a list of strings")
@ -169,10 +110,6 @@ class LayerOverviewUtils {
if (typeof layer === "string") {
if (!knownLayerIds.has(layer)) {
themeErrorCount.push(`Unknown layer id: ${layer} in theme ${themeFile.id}`)
} else {
const layerConfig = knownLayerIds.get(layer);
missingTranslations.push(...this.validateTranslationCompletenessOfObject(layerConfig, themeFile.language, "Layer " + layer))
}
} else if (layer.builtin !== undefined) {
let names = layer.builtin;
@ -197,7 +134,6 @@ class LayerOverviewUtils {
.filter(l => typeof l != "string") // We remove all the builtin layer references as they don't work with ts-node for some weird reason
.filter(l => l.builtin === undefined)
missingTranslations.push(...this.validateTranslationCompletenessOfObject(themeFile, themeFile.language, "Theme " + themeFile.id))
try {
const theme = new LayoutConfig(themeFile, true, "test")
@ -209,11 +145,6 @@ class LayerOverviewUtils {
}
}
if (missingTranslations.length > 0) {
console.log(missingTranslations.length, "missing translations")
writeFileSync("missing_translations.txt", missingTranslations.join("\n"))
}
if (layerErrorCount.length + themeErrorCount.length == 0) {
console.log("All good!")

35
test.ts
View file

@ -7,6 +7,11 @@ import {UIEventSource} from "./Logic/UIEventSource";
import {Tag} from "./Logic/Tags/Tag";
import {QueryParameters} from "./Logic/Web/QueryParameters";
import {Translation} from "./UI/i18n/Translation";
import LocationInput from "./UI/Input/LocationInput";
import Loc from "./Models/Loc";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
import LengthInput from "./UI/Input/LengthInput";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
/*import ValidatedTextField from "./UI/Input/ValidatedTextField";
import Combine from "./UI/Base/Combine";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
@ -148,19 +153,17 @@ function TestMiniMap() {
featureSource.ping()
}
//*/
QueryParameters.GetQueryParameter("test", "true").setData("true")
State.state= new State(undefined)
const id = "node/5414688303"
State.state.allElements.addElementById(id, new UIEventSource<any>({id: id}))
new Combine([
new DeleteWizard(id, {
noDeleteOptions: [
{
if:[ new Tag("access","private")],
then: new Translation({
en: "Very private! Delete now or me send lawfull lawyer"
})
}
]
}),
]).AttachTo("maindiv")
const loc = new UIEventSource<Loc>({
zoom: 24,
lat: 51.21043,
lon: 3.21389
})
const li = new LengthInput(
AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource<string | string[]>("map","photo")),
loc
)
li.SetStyle("height: 30rem; background: aliceblue;")
.AttachTo("maindiv")
new VariableUiElement(li.GetValue().map(v => JSON.stringify(v, null, " "))).AttachTo("extradiv")

View file

@ -15,7 +15,7 @@ export default class OsmConnectionSpec extends T {
super("OsmConnectionSpec-test", [
["login on dev",
() => {
const osmConn = new OsmConnection(false,
const osmConn = new OsmConnection(false,false,
new UIEventSource<string>(undefined),
"Unit test",
true,

View file

@ -1,10 +0,0 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-no-circular-imports"
],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}