Finish importer, add applicable import layers to every theme by default

This commit is contained in:
Pieter Vander Vennet 2022-01-21 01:57:16 +01:00
parent 3402ac0954
commit ca1490902c
41 changed files with 1559 additions and 898 deletions

View file

@ -10,11 +10,12 @@ import {UIEventSource} from "./UIEventSource";
import {LocalStorageSource} from "./Web/LocalStorageSource";
import LZString from "lz-string";
import * as personal from "../assets/themes/personal/personal.json";
import {FixLegacyTheme, PrepareTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import SharedTagRenderings from "../Customizations/SharedTagRenderings";
import * as known_layers from "../assets/generated/known_layers.json"
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
export default class DetermineLayout {

View file

@ -29,11 +29,11 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
},
tileIndex,
upstream: FeatureSourceForLayer,
metataggingUpdated: UIEventSource<any>
metataggingUpdated?: UIEventSource<any>
) {
this.name = "FilteringFeatureSource(" + upstream.name + ")"
this.tileIndex = tileIndex
this.bbox = BBox.fromTileIndex(tileIndex)
this.bbox = tileIndex === undefined ? undefined : BBox.fromTileIndex(tileIndex)
this.upstream = upstream
this.state = state
@ -55,7 +55,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
}
})
metataggingUpdated.addCallback(_ => {
metataggingUpdated?.addCallback(_ => {
self._is_dirty.setData(true)
})
@ -63,6 +63,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
}
private update() {
console.log("FIltering", this.upstream.name)
const self = this;
const layer = this.upstream.layer;
const features: { feature: any; freshness: Date }[] = (this.upstream.features.data ?? []);

View file

@ -28,7 +28,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
private readonly featureIdBlacklist?: UIEventSource<Set<string>>
public constructor(flayer: FilteredLayer,
zxy?: [number, number, number],
zxy?: [number, number, number] | BBox,
options?: {
featureIdBlacklist?: UIEventSource<Set<string>>
}) {
@ -41,23 +41,32 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
this.featureIdBlacklist = options?.featureIdBlacklist
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
if (zxy !== undefined) {
const [z, x, y] = zxy;
let tile_bbox = BBox.fromTile(z, x, y)
let tile_bbox: BBox;
if (zxy instanceof BBox) {
tile_bbox = zxy;
} else {
const [z, x, y] = zxy;
tile_bbox = BBox.fromTile(z, x, y);
this.tileIndex = Tiles.tile_index(z, x, y)
this.bbox = BBox.fromTile(z, x, y)
url = url
.replace('{z}', "" + z)
.replace('{x}', "" + x)
.replace('{y}', "" + y)
}
let bounds: { minLat: number, maxLat: number, minLon: number, maxLon: number } = tile_bbox
if (this.layer.layerDef.source.mercatorCrs) {
bounds = tile_bbox.toMercator()
}
url = url
.replace('{z}', "" + z)
.replace('{x}', "" + x)
.replace('{y}', "" + y)
.replace('{y_min}', "" + bounds.minLat)
.replace('{y_max}', "" + bounds.maxLat)
.replace('{x_min}', "" + bounds.minLon)
.replace('{x_max}', "" + bounds.maxLon)
this.tileIndex = Tiles.tile_index(z, x, y)
this.bbox = BBox.fromTile(z, x, y)
} else {
this.tileIndex = Tiles.tile_index(0, 0, 0)
this.bbox = BBox.global;
@ -83,7 +92,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
if (self.layer.layerDef.source.mercatorCrs) {
json = GeoOperations.GeoJsonToWGS84(json)
}
const time = new Date();
const newFeatures: { feature: any, freshness: Date } [] = []
let i = 0;

View file

@ -683,6 +683,8 @@ export class GeoOperations {
throw "CalculateIntersection fallthrough: can not calculate an intersection between features"
}
}

View file

@ -18,13 +18,13 @@ export class ChangesetHandler {
private readonly allElements: ElementStorage;
private osmConnection: OsmConnection;
private readonly changes: Changes;
private readonly _dryRun: boolean;
private readonly _dryRun: UIEventSource<boolean>;
private readonly userDetails: UIEventSource<UserDetails>;
private readonly auth: any;
private readonly backend: string;
constructor(layoutName: string,
dryRun: boolean,
dryRun: UIEventSource<boolean>,
osmConnection: OsmConnection,
allElements: ElementStorage,
changes: Changes,
@ -67,7 +67,7 @@ export class ChangesetHandler {
this.userDetails.data.csCount = 1;
this.userDetails.ping();
}
if (this._dryRun) {
if (this._dryRun.data) {
const changesetXML = generateChangeXML(123456);
console.log("Metatags are", extraMetaTags)
console.log(changesetXML);

View file

@ -19,7 +19,6 @@ export default class UserDetails {
public img: string;
public unreadMessages = 0;
public totalMessages = 0;
public dryRun: boolean;
home: { lon: number; lat: number };
public backend: string;
@ -47,7 +46,6 @@ export class OsmConnection {
public auth;
public userDetails: UIEventSource<UserDetails>;
public isLoggedIn: UIEventSource<boolean>
_dryRun: boolean;
public preferencesHandler: OsmPreferences;
public changesetHandler: ChangesetHandler;
public readonly _oauth_config: {
@ -55,6 +53,7 @@ export class OsmConnection {
oauth_secret: string,
url: string
};
private readonly _dryRun: UIEventSource<boolean>;
private fakeUser: boolean;
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = [];
private readonly _iframeMode: Boolean | boolean;
@ -62,7 +61,7 @@ export class OsmConnection {
private isChecking = false;
constructor(options: {
dryRun?: false | boolean,
dryRun?: UIEventSource<boolean>,
fakeUser?: false | boolean,
allElements: ElementStorage,
changes: Changes,
@ -82,7 +81,6 @@ export class OsmConnection {
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails.data.dryRun = (options.dryRun ?? false) || (options.fakeUser ?? false);
if (options.fakeUser) {
const ud = this.userDetails.data;
ud.csCount = 5678
@ -99,13 +97,13 @@ export class OsmConnection {
self.AttemptLogin()
}
});
this._dryRun = options.dryRun;
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false);
this.updateAuthObject();
this.preferencesHandler = new OsmPreferences(this.auth, this);
this.changesetHandler = new ChangesetHandler(options.layoutName, options.dryRun, this, options.allElements, options.changes, this.auth);
this.changesetHandler = new ChangesetHandler(options.layoutName, this._dryRun, this, options.allElements, options.changes, this.auth);
if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
const self = this;
@ -223,7 +221,7 @@ export class OsmConnection {
if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text)
}
if (this._dryRun) {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text)
return new Promise((ok, error) => {
ok()
@ -246,7 +244,7 @@ export class OsmConnection {
}
public reopenNote(id: number | string, text?: string): Promise<any> {
if (this._dryRun) {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text)
return new Promise((ok, error) => {
ok()
@ -273,10 +271,10 @@ export class OsmConnection {
}
public openNote(lat: number, lon: number, text: string): Promise<{ id: number }> {
if (this._dryRun) {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually opening note with text ", text)
return new Promise((ok, error) => {
ok()
return new Promise<{ id: number }>((ok, error) => {
window.setTimeout(() => ok({id: Math.floor(Math.random() * 1000)}), Math.random() * 5000)
});
}
const auth = this.auth;
@ -285,15 +283,18 @@ export class OsmConnection {
auth.xhr({
method: 'POST',
path: `/api/0.6/notes.json`,
options: {header:
{'Content-Type': 'application/json'}},
options: {
header:
{'Content-Type': 'application/json'}
},
content: JSON.stringify(content)
}, function (err, response) {
if (err !== null) {
error(err)
} else {
const id = Number(response.children[0].children[0].children.item("id").innerHTML)
const id = response.properties.id
console.log("OPENED NOTE", id)
ok({id})
}
@ -304,7 +305,7 @@ export class OsmConnection {
}
public addCommentToNode(id: number | string, text: string): Promise<any> {
if (this._dryRun) {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id)
return new Promise((ok, error) => {
ok()
@ -317,7 +318,7 @@ export class OsmConnection {
return new Promise((ok, error) => {
this.auth.xhr({
method: 'POST',
path: `/api/0.6/notes.json/${id}/comment?text=${encodeURIComponent(text)}`
}, function (err, response) {
if (err !== null) {

View file

@ -43,7 +43,7 @@ export default class UserRelatedState extends ElementsState {
this.osmConnection = new OsmConnection({
changes: this.changes,
dryRun: this.featureSwitchIsTesting.data,
dryRun: this.featureSwitchIsTesting,
fakeUser: this.featureSwitchFakeUser.data,
allElements: this.allElements,
oauth_token: QueryParameters.GetQueryParameter(

View file

@ -10,6 +10,13 @@ export class RegexTag extends TagsFilter {
constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) {
super();
this.key = key;
if (typeof value === "string") {
if (value.indexOf("^") < 0 && value.indexOf("$") < 0) {
value = "^" + value + "$"
}
value = new RegExp(value)
}
this.value = value;
this.invert = invert;
this.matchesEmpty = RegexTag.doesMatch("", this.value);
@ -109,7 +116,7 @@ export class RegexTag extends TagsFilter {
console.error("Cannot export regex tag to asChange; ", this.key, this.value)
return []
}
AsJson() {
return this.asHumanString()
}

View file

@ -192,16 +192,16 @@ export class TagUtils {
}
const f = (value: string | undefined) => {
if(value === undefined){
if (value === undefined) {
return false;
}
let b = Number(value?.trim() )
let b = Number(value?.trim())
if (isNaN(b)) {
if(value.endsWith(" UTC")) {
if (value.endsWith(" UTC")) {
value = value.replace(" UTC", "+00")
}
b = new Date(value).getTime()
if(isNaN(b)){
if (isNaN(b)) {
return false
}
}
@ -218,7 +218,7 @@ export class TagUtils {
}
return new RegexTag(
split[0],
new RegExp("^" + split[1] + "$"),
split[1],
true
);
}
@ -228,8 +228,8 @@ export class TagUtils {
split[1] = "..*"
}
return new RegexTag(
new RegExp("^" + split[0] + "$"),
new RegExp("^" + split[1] + "$")
split[0],
split[1]
);
}
if (tag.indexOf("!:=") >= 0) {
@ -248,7 +248,7 @@ export class TagUtils {
}
return new RegexTag(
split[0],
new RegExp("^" + split[1] + "$"),
new RegExp("^" + split[1] + "$"),
true
);
}
@ -259,7 +259,7 @@ export class TagUtils {
}
return new RegexTag(
split[0],
new RegExp("^" + split[1] + "$"),
split[1],
true
);
}
@ -273,7 +273,7 @@ export class TagUtils {
}
return new RegexTag(
split[0],
new RegExp("^" + split[1] + "$")
split[1]
);
}
if (tag.indexOf("=") >= 0) {

View file

@ -0,0 +1,171 @@
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson";
import {LayerConfigJson} from "../Json/LayerConfigJson";
import {Utils} from "../../../Utils";
export interface DesugaringContext {
tagRenderings: Map<string, TagRenderingConfigJson>
sharedLayers: Map<string, LayerConfigJson>
}
export abstract class Conversion<TIn, TOut> {
public readonly modifiedAttributes: string[];
protected readonly doc: string;
constructor(doc: string, modifiedAttributes: string[] = []) {
this.modifiedAttributes = modifiedAttributes;
this.doc = doc + "\n\nModified attributes are\n" + modifiedAttributes.join(", ");
}
public static strict<T>(fixed: { errors: string[], warnings: string[], result?: T }): T {
if (fixed?.errors?.length > 0) {
throw fixed.errors.join("\n");
}
fixed.warnings?.forEach(w => console.warn(w))
return fixed.result;
}
public convertStrict(state: DesugaringContext, json: TIn, context: string): TOut {
const fixed = this.convert(state, json, context)
return DesugaringStep.strict(fixed)
}
abstract convert(state: DesugaringContext, json: TIn, context: string): { result: TOut, errors: string[], warnings: string[] }
public convertAll(state: DesugaringContext, jsons: TIn[], context: string): { result: TOut[], errors: string[], warnings: string[] } {
const result = []
const errors = []
const warnings = []
for (let i = 0; i < jsons.length; i++) {
const json = jsons[i];
const r = this.convert(state, json, context + "[" + i + "]")
result.push(r.result)
errors.push(...r.errors)
warnings.push(...r.warnings)
}
return {
result,
errors,
warnings
}
}
}
export abstract class DesugaringStep<T> extends Conversion<T, T> {
}
export class OnEvery<X, T> extends DesugaringStep<T> {
private readonly key: string;
private readonly step: DesugaringStep<X>;
constructor(key: string, step: DesugaringStep<X>) {
super("Applies " + step.constructor.name + " onto every object of the list `key`", [key]);
this.step = step;
this.key = key;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
json = {...json}
const step = this.step
const key = this.key;
const r = step.convertAll(state, (<X[]>json[key]), context + "." + key)
json[key] = r.result
return {
result: json,
errors: r.errors,
warnings: r.warnings
};
}
}
export class OnEveryConcat<X, T> extends DesugaringStep<T> {
private readonly key: string;
private readonly step: Conversion<X, X[]>;
constructor(key: string, step: Conversion<X, X[]>) {
super(`Applies ${step.constructor.name} onto every object of the list \`${key}\`. The results are concatenated and used as new list`, [key]);
this.step = step;
this.key = key;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
json = {...json}
const step = this.step
const key = this.key;
const values = json[key]
if (values === undefined) {
// Move on - nothing to see here!
return {
result: json,
errors: [],
warnings: []
}
}
const r = step.convertAll(state, (<X[]>values), context + "." + key)
const vals: X[][] = r.result
json[key] = [].concat(...vals)
return {
result: json,
errors: r.errors,
warnings: r.warnings
};
}
}
export class Fuse<T> extends DesugaringStep<T> {
private readonly steps: DesugaringStep<T>[];
constructor(doc: string, ...steps: DesugaringStep<T>[]) {
super((doc ?? "") + "This fused pipeline of the following steps: " + steps.map(s => s.constructor.name).join(", "),
Utils.Dedup([].concat(...steps.map(step => step.modifiedAttributes)))
);
this.steps = steps;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
let r = step.convert(state, json, context + "(fusion " + this.constructor.name + "." + i + ")")
errors.push(...r.errors)
warnings.push(...r.warnings)
json = r.result
if (errors.length > 0) {
break;
}
}
return {
result: json,
errors,
warnings
};
}
}
export class SetDefault<T> extends DesugaringStep<T> {
private readonly value: any;
private readonly key: string;
private readonly _overrideEmptyString: boolean;
constructor(key: string, value: any, overrideEmptyString = false) {
super("Sets " + key + " to a default value if undefined");
this.key = key;
this.value = value;
this._overrideEmptyString = overrideEmptyString;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
if (json[this.key] === undefined || (json[this.key] === "" && this._overrideEmptyString)) {
json = {...json}
json[this.key] = this.value
}
return {
errors: [], warnings: [],
result: json
};
}
}

View file

@ -1,29 +1,69 @@
import {Conversion, DesugaringContext} from "./LegacyJsonConvert";
import {Conversion, DesugaringContext} from "./Conversion";
import LayerConfig from "../LayerConfig";
import {LayerConfigJson} from "../Json/LayerConfigJson";
import Translations from "../../../UI/i18n/Translations";
import {TagsFilter} from "../../../Logic/Tags/TagsFilter";
import {And} from "../../../Logic/Tags/And";
import PointRenderingConfigJson from "../Json/PointRenderingConfigJson";
export default class CreateNoteImportLayer extends Conversion<LayerConfig, LayerConfigJson> {
export default class CreateNoteImportLayer extends Conversion<LayerConfigJson, LayerConfigJson> {
/**
* A closed note is included if it is less then 'n'-days closed
* @private
*/
private readonly _includeClosedNotesDays: number;
constructor() {
constructor(includeClosedNotesDays= 0) {
super([
"Advanced conversion which deducts a layer showing all notes that are 'importable' (i.e. a note that contains a link to some MapComplete theme, with hash '#import').",
"The import buttons and matches will be based on the presets of the given theme",
].join("\n\n"), [])
this._includeClosedNotesDays = includeClosedNotesDays;
}
convert(state: DesugaringContext, layer: LayerConfig, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } {
convert(state: DesugaringContext, layerJson: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
const t = Translations.t.importLayer;
const possibleTags: TagsFilter[] = layer.presets.map(p => new And(p.tags))
/**
* The note itself will contain `tags=k=v;k=v;k=v;...
* This must be matched with a regex.
* This is a simple JSON-object as how it'll be put into the layerConfigJson directly
*/
const isShownIfAny : any[] = []
const layer = new LayerConfig(layerJson, "while constructing a note-import layer")
for (const preset of layer.presets) {
const mustMatchAll = []
for (const tag of preset.tags) {
const key = tag.key
const value = tag.value
const condition = "_tags~(^|.*;)"+key+"\="+value+"($|;.*)"
mustMatchAll.push(condition)
}
isShownIfAny.push({and:mustMatchAll})
}
const pointRenderings = (layerJson.mapRendering??[]).filter(r => r!== null && r["location"] !== undefined);
const firstRender = <PointRenderingConfigJson>(pointRenderings [0])
const icon = firstRender.icon
const iconBadges = []
if(icon !== undefined){
iconBadges.push({
if: {and:[]},
then:icon
})
}
const importButton = {}
{
const translations = t.importButton.Subs({layerId: layer.id, title: layer.presets[0].title}).translations
for (const key in translations) {
importButton[key] = "{"+translations[key]+"}"
}
}
const result : LayerConfigJson = {
"id": "note_import_"+layer.id,
"name": t.layerName.Subs({title: layer.title.render}).translations,
// By disabling the name, the import-layers won't pollute the filter view "name": t.layerName.Subs({title: layer.title.render}).translations,
"description": t.description.Subs({title: layer.title.render}).translations,
"source": {
"osmTags": {
@ -31,27 +71,32 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfig, Layer
"id~*"
]
},
"geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?closed=0&bbox={x_min},{y_min},{x_max},{y_max}",
"geoJsonZoomLevel": 12,
"geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed="+this._includeClosedNotesDays+"&bbox={x_min},{y_min},{x_max},{y_max}",
"geoJsonZoomLevel": 10,
"maxCacheAge": 0
},
"minzoom": 10,
"minzoom": 12,
"title": {
"render": t.popupTitle.Subs({title: layer.presets[0].title}).translations
},
"calculatedTags": [
"_first_comment:=feat.get('comments')[0].text.toLowerCase()",
"_trigger_index:=(() => {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()",
"_intro:=(() => {const lines = feat.properties['_first_comment'].split('\\n'); lines.splice(feat.get('_trigger_index')-1, lines.length); return lines.map(l => l == '' ? '<br/>' : l).join('');})()",
"_tags:=(() => {let lines = feat.properties['_first_comment'].split('\\n').map(l => l.trim()); lines.splice(0, feat.get('_trigger_index') + 1); lines = lines.filter(l => l != ''); return lines.join(';');})()"
"_first_comment=feat.get('comments')[0].text.toLowerCase()",
"_trigger_index=(() => {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()",
"_comments_count=feat.get('comments').length",
"_intro=(() => {const lines = feat.properties['_first_comment'].split('\\n'); lines.splice(feat.get('_trigger_index')-1, lines.length); return lines.filter(l => l !== '').join('<br/>');})()",
"_tags=(() => {let lines = feat.properties['_first_comment'].split('\\n').map(l => l.trim()); lines.splice(0, feat.get('_trigger_index') + 1); lines = lines.filter(l => l != ''); return lines.join(';');})()"
],
"isShown": {
"render": "no",
"mappings": [
{
"if": "comments!~.*https://mapcomplete.osm.be.*",
"then":"no"
},
{
"if": {and:
["_trigger_index~*",
{or: possibleTags.map(tf => tf.AsJson())}
{or: isShownIfAny}
]},
"then": "yes"
}
@ -63,25 +108,34 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfig, Layer
}
],
"tagRenderings": [
{
"id": "conversation",
"render": "{visualize_note_comments(comments,1)}"
},
{
"id": "Intro",
"render": "{_intro}"
},
{
"id": "conversation",
"render": "{visualize_note_comments(comments,1)}",
condition: "_comments_count>1"
},
{
"id": "import",
"render": "{import_button(public_bookcase, _tags, There might be a public bookcase here,./assets/svg/addSmall.svg,,,id)}"
"render": importButton,
condition: "closed_at="
},
{
"id": "close_note_",
"render": "{close_note(Does not exist<br/>, ./assets/svg/close.svg, id, This feature does not exist)}"
"render": "{close_note(Does not exist<br/>, ./assets/svg/close.svg, id, This feature does not exist)}",
condition: "closed_at="
},
{
"id": "close_note_mapped",
"render": "{close_note(Already mapped, ./assets/svg/checkmark.svg, id, Already mapped)}"
"render": "{close_note(Already mapped, ./assets/svg/checkmark.svg, id, Already mapped)}",
condition: "closed_at="
},
{
"id": "handled",
"render": t.importHandled.translations,
condition: "closed_at~*"
},
{
"id": "comment",
@ -90,6 +144,10 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfig, Layer
{
"id": "add_image",
"render": "{add_image_to_note()}"
},
{
id:"alltags",
render:"{all_tags()}"
}
],
"mapRendering": [
@ -99,9 +157,14 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfig, Layer
"centroid"
],
"icon": {
"render": "teardrop:#3333cc"
"render": "circle:white;help:black",
mappings:[{
if: {or:["closed_at~*","_imported=yes"]},
then:"circle:white;checkmark:black"
}]
},
"iconSize": "40,40,bottom"
iconBadges,
"iconSize": "40,40,center"
}
]
}

View file

@ -1,439 +1,12 @@
import {LayoutConfigJson} from "../Json/LayoutConfigJson";
import DependencyCalculator from "../DependencyCalculator";
import LayerConfig from "../LayerConfig";
import {Translation} from "../../../UI/i18n/Translation";
import LayoutConfig from "../LayoutConfig";
import {Utils} from "../../../Utils";
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson";
import LineRenderingConfigJson from "../Json/LineRenderingConfigJson";
import {LayerConfigJson} from "../Json/LayerConfigJson";
import Constants from "../../Constants";
import {AllKnownLayouts} from "../../../Customizations/AllKnownLayouts";
import {SubstitutedTranslation} from "../../../UI/SubstitutedTranslation";
export interface DesugaringContext {
tagRenderings: Map<string, TagRenderingConfigJson>
sharedLayers: Map<string, LayerConfigJson>
}
export abstract class Conversion<TIn, TOut> {
public readonly modifiedAttributes: string[];
protected readonly doc: string;
constructor(doc: string, modifiedAttributes: string[] = []) {
this.modifiedAttributes = modifiedAttributes;
this.doc = doc + "\n\nModified attributes are\n" + modifiedAttributes.join(", ");
}
public static strict<T>(fixed: { errors: string[], warnings: string[], result?: T }): T {
if (fixed?.errors?.length > 0) {
throw fixed.errors.join("\n");
}
fixed.warnings?.forEach(w => console.warn(w))
return fixed.result;
}
public convertStrict(state: DesugaringContext, json: TIn, context: string): TOut {
const fixed = this.convert(state, json, context)
return DesugaringStep.strict(fixed)
}
abstract convert(state: DesugaringContext, json: TIn, context: string): { result: TOut, errors: string[], warnings: string[] }
public convertAll(state: DesugaringContext, jsons: TIn[], context: string): { result: TOut[], errors: string[], warnings: string[] } {
const result = []
const errors = []
const warnings = []
for (let i = 0; i < jsons.length; i++) {
const json = jsons[i];
const r = this.convert(state, json, context + "[" + i + "]")
result.push(r.result)
errors.push(...r.errors)
warnings.push(...r.warnings)
}
return {
result,
errors,
warnings
}
}
}
export abstract class DesugaringStep<T> extends Conversion<T, T> {
}
class OnEvery<X, T> extends DesugaringStep<T> {
private readonly key: string;
private readonly step: DesugaringStep<X>;
constructor(key: string, step: DesugaringStep<X>) {
super("Applies " + step.constructor.name + " onto every object of the list `key`", [key]);
this.step = step;
this.key = key;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
json = {...json}
const step = this.step
const key = this.key;
const r = step.convertAll(state, (<X[]>json[key]), context + "." + key)
json[key] = r.result
return {
result: json,
errors: r.errors,
warnings: r.warnings
};
}
}
class OnEveryConcat<X, T> extends DesugaringStep<T> {
private readonly key: string;
private readonly step: Conversion<X, X[]>;
constructor(key: string, step: Conversion<X, X[]>) {
super(`Applies ${step.constructor.name} onto every object of the list \`${key}\``, [key]);
this.step = step;
this.key = key;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
json = {...json}
const step = this.step
const key = this.key;
const values = json[key]
if (values === undefined) {
// Move on - nothing to see here!
return {
result: json,
errors: [],
warnings: []
}
}
const r = step.convertAll(state, (<X[]>values), context + "." + key)
const vals: X[][] = r.result
json[key] = [].concat(...vals)
return {
result: json,
errors: r.errors,
warnings: r.warnings
};
}
}
class Fuse<T> extends DesugaringStep<T> {
private readonly steps: DesugaringStep<T>[];
constructor(doc: string, ...steps: DesugaringStep<T>[]) {
super((doc ?? "") + "This fused pipeline of the following steps: " + steps.map(s => s.constructor.name).join(", "),
Utils.Dedup([].concat(...steps.map(step => step.modifiedAttributes)))
);
this.steps = steps;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
let r = step.convert(state, json, context + "(fusion " + this.constructor.name + "." + i + ")")
errors.push(...r.errors)
warnings.push(...r.warnings)
json = r.result
if (errors.length > 0) {
break;
}
}
return {
result: json,
errors,
warnings
};
}
}
class AddMiniMap extends DesugaringStep<LayerConfigJson> {
constructor() {
super("Adds a default 'minimap'-element to the tagrenderings if none of the elements define such a minimap", ["tagRenderings"]);
}
/**
* Returns true if this tag rendering has a minimap in some language.
* Note: this minimap can be hidden by conditions
*/
private static hasMinimap(renderingConfig: TagRenderingConfigJson): boolean {
const translations: Translation[] = Utils.NoNull([renderingConfig.render, ...(renderingConfig.mappings ?? []).map(m => m.then)]);
for (const translation of translations) {
for (const key in translation.translations) {
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) {
return true;
}
}
}
return false;
}
convert(state: DesugaringContext, layerConfig: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } {
const hasMinimap = layerConfig.tagRenderings?.some(tr => AddMiniMap.hasMinimap(<TagRenderingConfigJson> tr)) ?? true
if (!hasMinimap) {
layerConfig = {...layerConfig}
layerConfig.tagRenderings = [...layerConfig.tagRenderings]
layerConfig.tagRenderings.push(state.tagRenderings.get("minimap"))
}
return {
errors:[],
warnings: [],
result: layerConfig
};
}
}
class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { builtin: string | string[], override: any }, TagRenderingConfigJson[]> {
constructor() {
super("Converts a tagRenderingSpec into the full tagRendering", []);
}
convert(state: DesugaringContext, json: string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
return {
result: this.convertUntilStable(state, json, warnings, errors, context),
errors, warnings
};
}
private lookup(state: DesugaringContext, name: string): TagRenderingConfigJson[] {
if (state.tagRenderings.has(name)) {
return [state.tagRenderings.get(name)]
}
if (name.indexOf(".") >= 0) {
const spl = name.split(".");
const layer = state.sharedLayers.get(spl[0])
if (spl.length === 2 && layer !== undefined) {
const id = spl[1];
const layerTrs = <TagRenderingConfigJson[]>layer.tagRenderings.filter(tr => tr["id"] !== undefined)
let matchingTrs: TagRenderingConfigJson[]
if (id === "*") {
matchingTrs = layerTrs
} else if (id.startsWith("*")) {
const id_ = id.substring(1)
matchingTrs = layerTrs.filter(tr => tr.group === id_)
} else {
matchingTrs = layerTrs.filter(tr => tr.id === id)
}
for (let i = 0; i < matchingTrs.length; i++) {
// The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown
const found = Utils.Clone(matchingTrs[i]);
if (found.condition === undefined) {
found.condition = layer.source.osmTags
} else {
found.condition = {and: [found.condition, layer.source.osmTags]}
}
matchingTrs[i] = found
}
if (matchingTrs.length !== 0) {
return matchingTrs
}
}
}
return undefined;
}
private convertOnce(state: DesugaringContext, tr: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] {
if (tr === "questions") {
return [{
id: "questions"
}]
}
if (typeof tr === "string") {
const lookup = this.lookup(state, tr);
if (lookup !== undefined) {
return lookup
}
warnings.push(ctx + "A literal rendering was detected: " + tr)
return [{
render: tr,
id: tr.replace(/![a-zA-Z0-9]/g, "")
}]
}
if (tr["builtin"] !== undefined) {
let names = tr["builtin"]
if (typeof names === "string") {
names = [names]
}
for (const key of Object.keys(tr)) {
if (key === "builtin" || key === "override" || key === "id" || key.startsWith("#")) {
continue
}
errors.push("At " + ctx + ": an object calling a builtin can only have keys `builtin` or `override`, but a key with name `" + key + "` was found. This won't be picked up! The full object is: " + JSON.stringify(tr))
}
const trs: TagRenderingConfigJson[] = []
for (const name of names) {
const lookup = this.lookup(state, name)
if (lookup === undefined) {
errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " + Array.from(state.tagRenderings.keys()).join(", ") + "?")
continue
}
for (let foundTr of lookup) {
foundTr = Utils.Clone<any>(foundTr)
Utils.Merge(tr["override"] ?? {}, foundTr)
trs.push(foundTr)
}
}
return trs;
}
return [tr]
}
private convertUntilStable(state: DesugaringContext, spec: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] {
const trs = this.convertOnce(state, spec, warnings, errors, ctx);
const result = []
for (const tr of trs) {
if (tr["builtin"] !== undefined) {
const stable = this.convertUntilStable(state, tr, warnings, errors, ctx + "(RECURSIVE RESOLVE)")
result.push(...stable)
} else {
result.push(tr)
}
}
return result;
}
}
class ExpandGroupRewrite extends Conversion<{
rewrite: {
sourceString: string,
into: string[]
}[],
renderings: (string | { builtin: string, override: any } | TagRenderingConfigJson)[]
} | TagRenderingConfigJson, TagRenderingConfigJson[]> {
private static expandSubTagRenderings = new ExpandTagRendering()
constructor() {
super(
"Converts a rewrite config for tagRenderings into the expanded form"
);
}
convert(state: DesugaringContext, json:
{
rewrite:
{ sourceString: string; into: string[] }[]; renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[]
} | TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } {
if (json["rewrite"] === undefined) {
return {result: [<TagRenderingConfigJson>json], errors: [], warnings: []}
}
let config = <{
rewrite:
{ sourceString: string; into: string[] }[];
renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[]
}>json;
const subRenderingsRes = ExpandGroupRewrite.expandSubTagRenderings.convertAll(state, config.renderings, context);
const subRenderings: TagRenderingConfigJson[] = [].concat(subRenderingsRes.result);
const errors = subRenderingsRes.errors;
const warnings = subRenderingsRes.warnings;
const rewrittenPerGroup = new Map<string, TagRenderingConfigJson[]>()
// The actual rewriting
for (const rewrite of config.rewrite) {
const source = rewrite.sourceString;
for (const target of rewrite.into) {
const groupName = target;
const trs: TagRenderingConfigJson[] = []
for (const tr of subRenderings) {
trs.push(this.prepConfig(source, target, tr))
}
if (rewrittenPerGroup.has(groupName)) {
rewrittenPerGroup.get(groupName).push(...trs)
} else {
rewrittenPerGroup.set(groupName, trs)
}
}
}
// Add questions box for this category
rewrittenPerGroup.forEach((group, groupName) => {
group.push(<TagRenderingConfigJson>{
id: "questions",
group: groupName
})
})
rewrittenPerGroup.forEach((group, _) => {
group.forEach(tr => {
if (tr.id === undefined || tr.id === "") {
errors.push("A tagrendering has an empty ID after expanding the tag")
}
})
})
return {
result: [].concat(...Array.from(rewrittenPerGroup.values())),
errors, warnings
};
}
/* Used for left|right group creation and replacement */
private prepConfig(keyToRewrite: string, target: string, tr: TagRenderingConfigJson) {
function replaceRecursive(transl: string | any) {
if (typeof transl === "string") {
return transl.replace(keyToRewrite, target)
}
if (transl.map !== undefined) {
return transl.map(o => replaceRecursive(o))
}
transl = {...transl}
for (const key in transl) {
transl[key] = replaceRecursive(transl[key])
}
return transl
}
const orig = tr;
tr = replaceRecursive(tr)
tr.id = target + "-" + orig.id
tr.group = target
return tr
}
}
import {DesugaringContext, DesugaringStep, Fuse, OnEvery} from "./Conversion";
export class UpdateLegacyLayer extends DesugaringStep<LayerConfigJson | string | { builtin, override }> {
@ -822,235 +395,3 @@ export class ValidateThemeAndLayers extends Fuse<LayoutConfigJson> {
}
}
class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
constructor() {
super("If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)", ["layers"]);
}
private static CalculateDependencies(alreadyLoaded: LayerConfigJson[], allKnownLayers: Map<string, LayerConfigJson>, themeId: string): LayerConfigJson[] {
const dependenciesToAdd: LayerConfigJson[] = []
const loadedLayerIds: Set<string> = new Set<string>(alreadyLoaded.map(l => l.id));
// Verify cross-dependencies
let unmetDependencies: { neededLayer: string, neededBy: string, reason: string, context?: string }[] = []
do {
const dependencies: { neededLayer: string, reason: string, context?: string, neededBy: string }[] = []
for (const layerConfig of alreadyLoaded) {
const layerDeps = DependencyCalculator.getLayerDependencies(new LayerConfig(layerConfig))
dependencies.push(...layerDeps)
}
// During the generate script, builtin layers are verified but not loaded - so we have to add them manually here
// Their existance is checked elsewhere, so this is fine
unmetDependencies = dependencies.filter(dep => !loadedLayerIds.has(dep.neededLayer))
for (const unmetDependency of unmetDependencies) {
if (loadedLayerIds.has(unmetDependency.neededLayer)) {
continue
}
const dep = allKnownLayers.get(unmetDependency.neededLayer)
if (dep === undefined) {
const message =
["Loading a dependency failed: layer " + unmetDependency.neededLayer + " is not found, neither as layer of " + themeId + " nor as builtin layer.",
"This layer is needed by " + unmetDependency.neededBy,
unmetDependency.reason + " (at " + unmetDependency.context + ")",
"Loaded layers are: " + alreadyLoaded.map(l => l.id).join(",")
]
throw message.join("\n\t");
}
dependenciesToAdd.unshift(dep)
loadedLayerIds.add(dep.id);
unmetDependencies = unmetDependencies.filter(d => d.neededLayer !== unmetDependency.neededLayer)
}
} while (unmetDependencies.length > 0)
return dependenciesToAdd;
}
convert(state: DesugaringContext, theme: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } {
const allKnownLayers: Map<string, LayerConfigJson> = state.sharedLayers;
const knownTagRenderings: Map<string, TagRenderingConfigJson> = state.tagRenderings;
const errors = [];
const warnings = [];
const layers: LayerConfigJson[] = <LayerConfigJson[]> theme.layers; // Layers should be expanded at this point
knownTagRenderings.forEach((value, key) => {
value.id = key;
})
const dependencies = AddDependencyLayersToTheme.CalculateDependencies(layers, allKnownLayers, theme.id);
if (dependencies.length > 0) {
warnings.push(context + ": added " + dependencies.map(d => d.id).join(", ") + " to the theme as they are needed")
}
layers.unshift(...dependencies);
return {
result: {
...theme,
layers: layers
},
errors,
warnings
};
}
}
class SetDefault<T> extends DesugaringStep<T> {
private readonly value: any;
private readonly key: string;
private readonly _overrideEmptyString: boolean;
constructor(key: string, value: any, overrideEmptyString = false) {
super("Sets " + key + " to a default value if undefined");
this.key = key;
this.value = value;
this._overrideEmptyString = overrideEmptyString;
}
convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } {
if (json[this.key] === undefined || (json[this.key] === "" && this._overrideEmptyString)) {
json = {...json}
json[this.key] = this.value
}
return {
errors: [], warnings: [],
result: json
};
}
}
export class PrepareLayer extends Fuse<LayerConfigJson> {
constructor() {
super(
"Fully prepares and expands a layer for the LayerConfig.",
new OnEveryConcat("tagRenderings", new ExpandGroupRewrite()),
new OnEveryConcat("tagRenderings", new ExpandTagRendering()),
new SetDefault("titleIcons", ["defaults"]),
new OnEveryConcat("titleIcons", new ExpandTagRendering())
);
}
}
class SubstituteLayer extends Conversion<(string | LayerConfigJson), LayerConfigJson[]> {
constructor() {
super("Converts the identifier of a builtin layer into the actual layer, or converts a 'builtin' syntax with override in the fully expanded form", []);
}
convert(state: DesugaringContext, json: string | LayerConfigJson, context: string): { result: LayerConfigJson[]; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
if (typeof json === "string") {
const found = state.sharedLayers.get(json)
if (found === undefined) {
return {
result: null,
errors: [context + ": The layer with name " + json + " was not found as a builtin layer"],
warnings
}
}
return {
result: [found],
errors, warnings
}
}
if (json["builtin"] !== undefined) {
let names = json["builtin"]
if (typeof names === "string") {
names = [names]
}
const layers = []
for (const name of names) {
const found = Utils.Clone(state.sharedLayers.get(name))
if (found === undefined) {
errors.push(context + ": The layer with name " + json + " was not found as a builtin layer")
continue
}
if (json["override"]["tagRenderings"] !== undefined && (found["tagRenderings"] ?? []).length > 0) {
errors.push(`At ${context}: when overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`)
}
try {
Utils.Merge(json["override"], found);
layers.push(found)
} catch (e) {
errors.push(`At ${context}: could not apply an override due to: ${e}.\nThe override is: ${JSON.stringify(json["override"],)}`)
}
}
return {
result: layers,
errors, warnings
}
}
return {
result: [json],
errors, warnings
};
}
}
class AddDefaultLayers extends DesugaringStep<LayoutConfigJson> {
constructor() {
super("Adds the default layers, namely: " + Constants.added_by_default.join(", "), ["layers"]);
}
convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
json.layers = [...json.layers]
if (json.id === "personal") {
json.layers = []
for (const publicLayer of AllKnownLayouts.AllPublicLayers()) {
const id = publicLayer.id
const config = state.sharedLayers.get(id)
if(Constants.added_by_default.indexOf(id) >= 0){
continue;
}
if(config === undefined){
// This is a layer which is coded within a public theme, not as separate .json
continue
}
json.layers.push(config)
}
const publicIds = AllKnownLayouts.AllPublicLayers().map(l => l.id)
publicIds.map(id => state.sharedLayers.get(id))
}
for (const layerName of Constants.added_by_default) {
const v = state.sharedLayers.get(layerName)
if (v === undefined) {
errors.push("Default layer " + layerName + " not found")
}
json.layers.push(v)
}
return {
result: json,
errors,
warnings
};
}
}
export class PrepareTheme extends Fuse<LayoutConfigJson> {
constructor() {
super(
"Fully prepares and expands a theme",
new OnEveryConcat("layers", new SubstituteLayer()),
new SetDefault("socialImage", "assets/SocialImage.png", true),
new AddDefaultLayers(),
new AddDependencyLayersToTheme(),
new OnEvery("layers", new PrepareLayer()),
new OnEvery("layers", new AddMiniMap())
);
}
}

View file

@ -0,0 +1,252 @@
import {Conversion, DesugaringContext, Fuse, OnEveryConcat, SetDefault} from "./Conversion";
import {LayerConfigJson} from "../Json/LayerConfigJson";
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson";
import {Utils} from "../../../Utils";
class ExpandTagRendering extends Conversion<string | TagRenderingConfigJson | { builtin: string | string[], override: any }, TagRenderingConfigJson[]> {
constructor() {
super("Converts a tagRenderingSpec into the full tagRendering", []);
}
convert(state: DesugaringContext, json: string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
return {
result: this.convertUntilStable(state, json, warnings, errors, context),
errors, warnings
};
}
private lookup(state: DesugaringContext, name: string): TagRenderingConfigJson[] {
if (state.tagRenderings.has(name)) {
return [state.tagRenderings.get(name)]
}
if (name.indexOf(".") >= 0) {
const spl = name.split(".");
const layer = state.sharedLayers.get(spl[0])
if (spl.length === 2 && layer !== undefined) {
const id = spl[1];
const layerTrs = <TagRenderingConfigJson[]>layer.tagRenderings.filter(tr => tr["id"] !== undefined)
let matchingTrs: TagRenderingConfigJson[]
if (id === "*") {
matchingTrs = layerTrs
} else if (id.startsWith("*")) {
const id_ = id.substring(1)
matchingTrs = layerTrs.filter(tr => tr.group === id_)
} else {
matchingTrs = layerTrs.filter(tr => tr.id === id)
}
for (let i = 0; i < matchingTrs.length; i++) {
// The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown
const found = Utils.Clone(matchingTrs[i]);
if (found.condition === undefined) {
found.condition = layer.source.osmTags
} else {
found.condition = {and: [found.condition, layer.source.osmTags]}
}
matchingTrs[i] = found
}
if (matchingTrs.length !== 0) {
return matchingTrs
}
}
}
return undefined;
}
private convertOnce(state: DesugaringContext, tr: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] {
if (tr === "questions") {
return [{
id: "questions"
}]
}
if (typeof tr === "string") {
const lookup = this.lookup(state, tr);
if (lookup !== undefined) {
return lookup
}
warnings.push(ctx + "A literal rendering was detected: " + tr)
return [{
render: tr,
id: tr.replace(/![a-zA-Z0-9]/g, "")
}]
}
if (tr["builtin"] !== undefined) {
let names = tr["builtin"]
if (typeof names === "string") {
names = [names]
}
for (const key of Object.keys(tr)) {
if (key === "builtin" || key === "override" || key === "id" || key.startsWith("#")) {
continue
}
errors.push("At " + ctx + ": an object calling a builtin can only have keys `builtin` or `override`, but a key with name `" + key + "` was found. This won't be picked up! The full object is: " + JSON.stringify(tr))
}
const trs: TagRenderingConfigJson[] = []
for (const name of names) {
const lookup = this.lookup(state, name)
if (lookup === undefined) {
errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " + Array.from(state.tagRenderings.keys()).join(", ") + "?")
continue
}
for (let foundTr of lookup) {
foundTr = Utils.Clone<any>(foundTr)
Utils.Merge(tr["override"] ?? {}, foundTr)
trs.push(foundTr)
}
}
return trs;
}
return [tr]
}
private convertUntilStable(state: DesugaringContext, spec: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] {
const trs = this.convertOnce(state, spec, warnings, errors, ctx);
const result = []
for (const tr of trs) {
if (tr["builtin"] !== undefined) {
const stable = this.convertUntilStable(state, tr, warnings, errors, ctx + "(RECURSIVE RESOLVE)")
result.push(...stable)
} else {
result.push(tr)
}
}
return result;
}
}
class ExpandGroupRewrite extends Conversion<{
rewrite: {
sourceString: string,
into: string[]
}[],
renderings: (string | { builtin: string, override: any } | TagRenderingConfigJson)[]
} | TagRenderingConfigJson, TagRenderingConfigJson[]> {
private static expandSubTagRenderings = new ExpandTagRendering()
constructor() {
super(
"Converts a rewrite config for tagRenderings into the expanded form"
);
}
convert(state: DesugaringContext, json:
{
rewrite:
{ sourceString: string; into: string[] }[]; renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[]
} | TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } {
if (json["rewrite"] === undefined) {
return {result: [<TagRenderingConfigJson>json], errors: [], warnings: []}
}
let config = <{
rewrite:
{ sourceString: string; into: string[] }[];
renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[]
}>json;
const subRenderingsRes = ExpandGroupRewrite.expandSubTagRenderings.convertAll(state, config.renderings, context);
const subRenderings: TagRenderingConfigJson[] = [].concat(subRenderingsRes.result);
const errors = subRenderingsRes.errors;
const warnings = subRenderingsRes.warnings;
const rewrittenPerGroup = new Map<string, TagRenderingConfigJson[]>()
// The actual rewriting
for (const rewrite of config.rewrite) {
const source = rewrite.sourceString;
for (const target of rewrite.into) {
const groupName = target;
const trs: TagRenderingConfigJson[] = []
for (const tr of subRenderings) {
trs.push(this.prepConfig(source, target, tr))
}
if (rewrittenPerGroup.has(groupName)) {
rewrittenPerGroup.get(groupName).push(...trs)
} else {
rewrittenPerGroup.set(groupName, trs)
}
}
}
// Add questions box for this category
rewrittenPerGroup.forEach((group, groupName) => {
group.push(<TagRenderingConfigJson>{
id: "questions",
group: groupName
})
})
rewrittenPerGroup.forEach((group, _) => {
group.forEach(tr => {
if (tr.id === undefined || tr.id === "") {
errors.push("A tagrendering has an empty ID after expanding the tag")
}
})
})
return {
result: [].concat(...Array.from(rewrittenPerGroup.values())),
errors, warnings
};
}
/* Used for left|right group creation and replacement */
private prepConfig(keyToRewrite: string, target: string, tr: TagRenderingConfigJson) {
function replaceRecursive(transl: string | any) {
if (typeof transl === "string") {
return transl.replace(keyToRewrite, target)
}
if (transl.map !== undefined) {
return transl.map(o => replaceRecursive(o))
}
transl = {...transl}
for (const key in transl) {
transl[key] = replaceRecursive(transl[key])
}
return transl
}
const orig = tr;
tr = replaceRecursive(tr)
tr.id = target + "-" + orig.id
tr.group = target
return tr
}
}
export class PrepareLayer extends Fuse<LayerConfigJson> {
constructor() {
super(
"Fully prepares and expands a layer for the LayerConfig.",
new OnEveryConcat("tagRenderings", new ExpandGroupRewrite()),
new OnEveryConcat("tagRenderings", new ExpandTagRendering()),
new SetDefault("titleIcons", ["defaults"]),
new OnEveryConcat("titleIcons", new ExpandTagRendering())
);
}
}

View file

@ -0,0 +1,316 @@
import {Conversion, DesugaringContext, DesugaringStep, Fuse, OnEvery, OnEveryConcat, SetDefault} from "./Conversion";
import {LayoutConfigJson} from "../Json/LayoutConfigJson";
import {PrepareLayer} from "./PrepareLayer";
import {LayerConfigJson} from "../Json/LayerConfigJson";
import {Utils} from "../../../Utils";
import Constants from "../../Constants";
import {AllKnownLayouts} from "../../../Customizations/AllKnownLayouts";
import CreateNoteImportLayer from "./CreateNoteImportLayer";
import LayerConfig from "../LayerConfig";
import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson";
import {Translation} from "../../../UI/i18n/Translation";
import {SubstitutedTranslation} from "../../../UI/SubstitutedTranslation";
import DependencyCalculator from "../DependencyCalculator";
class SubstituteLayer extends Conversion<(string | LayerConfigJson), LayerConfigJson[]> {
constructor() {
super("Converts the identifier of a builtin layer into the actual layer, or converts a 'builtin' syntax with override in the fully expanded form", []);
}
convert(state: DesugaringContext, json: string | LayerConfigJson, context: string): { result: LayerConfigJson[]; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
if (typeof json === "string") {
const found = state.sharedLayers.get(json)
if (found === undefined) {
return {
result: null,
errors: [context + ": The layer with name " + json + " was not found as a builtin layer"],
warnings
}
}
return {
result: [found],
errors, warnings
}
}
if (json["builtin"] !== undefined) {
let names = json["builtin"]
if (typeof names === "string") {
names = [names]
}
const layers = []
for (const name of names) {
const found = Utils.Clone(state.sharedLayers.get(name))
if (found === undefined) {
errors.push(context + ": The layer with name " + json + " was not found as a builtin layer")
continue
}
if (json["override"]["tagRenderings"] !== undefined && (found["tagRenderings"] ?? []).length > 0) {
errors.push(`At ${context}: when overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`)
}
try {
Utils.Merge(json["override"], found);
layers.push(found)
} catch (e) {
errors.push(`At ${context}: could not apply an override due to: ${e}.\nThe override is: ${JSON.stringify(json["override"],)}`)
}
}
return {
result: layers,
errors, warnings
}
}
return {
result: [json],
errors, warnings
};
}
}
class AddDefaultLayers extends DesugaringStep<LayoutConfigJson> {
constructor() {
super("Adds the default layers, namely: " + Constants.added_by_default.join(", "), ["layers"]);
}
convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
json.layers = [...json.layers]
if (json.id === "personal") {
json.layers = []
for (const publicLayer of AllKnownLayouts.AllPublicLayers()) {
const id = publicLayer.id
const config = state.sharedLayers.get(id)
if (Constants.added_by_default.indexOf(id) >= 0) {
continue;
}
if (config === undefined) {
// This is a layer which is coded within a public theme, not as separate .json
continue
}
json.layers.push(config)
}
const publicIds = AllKnownLayouts.AllPublicLayers().map(l => l.id)
publicIds.map(id => state.sharedLayers.get(id))
}
for (const layerName of Constants.added_by_default) {
const v = state.sharedLayers.get(layerName)
if (v === undefined) {
errors.push("Default layer " + layerName + " not found")
}
json.layers.push(v)
}
return {
result: json,
errors,
warnings
};
}
}
class AddImportLayers extends DesugaringStep<LayoutConfigJson> {
constructor() {
super("For every layer in the 'layers'-list, create a new layer which'll import notes. (Note that priviliged layers and layers which have a geojson-source set are ignored)", ["layers"]);
}
convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } {
const errors = []
const warnings = []
json = {...json}
const allLayers: LayerConfigJson[] = <LayerConfigJson[]>json.layers;
json.layers = [...json.layers]
const creator = new CreateNoteImportLayer()
for (let i1 = 0; i1 < allLayers.length; i1++) {
const layer = allLayers[i1];
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
// Priviliged layers are skipped
continue
}
if (layer.source["geoJson"] !== undefined) {
// Layer which don't get their data from OSM are skipped
continue
}
if (layer.title === undefined || layer.name === undefined) {
// Anonymous layers and layers without popup are skipped
continue
}
if (layer.presets === undefined || layer.presets.length == 0) {
// A preset is needed to be able to generate a new point
continue;
}
try {
const importLayerResult = creator.convert(state, layer, context + ".(noteimportlayer)[" + i1 + "]")
errors.push(...importLayerResult.errors)
warnings.push(...importLayerResult.warnings)
if (importLayerResult.result !== undefined) {
warnings.push("Added an import layer to theme " + json.id + ", namely " + importLayerResult.result.id)
json.layers.push(importLayerResult.result)
}
} catch (e) {
errors.push("Could not generate an import-layer for " + layer.id + " due to " + e)
}
}
return {
errors,
warnings,
result: json
};
}
}
class AddMiniMap extends DesugaringStep<LayerConfigJson> {
constructor() {
super("Adds a default 'minimap'-element to the tagrenderings if none of the elements define such a minimap", ["tagRenderings"]);
}
/**
* Returns true if this tag rendering has a minimap in some language.
* Note: this minimap can be hidden by conditions
*/
private static hasMinimap(renderingConfig: TagRenderingConfigJson): boolean {
const translations: Translation[] = Utils.NoNull([renderingConfig.render, ...(renderingConfig.mappings ?? []).map(m => m.then)]);
for (const translation of translations) {
for (const key in translation.translations) {
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) {
return true;
}
}
}
return false;
}
convert(state: DesugaringContext, layerConfig: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } {
const hasMinimap = layerConfig.tagRenderings?.some(tr => AddMiniMap.hasMinimap(<TagRenderingConfigJson>tr)) ?? true
if (!hasMinimap) {
layerConfig = {...layerConfig}
layerConfig.tagRenderings = [...layerConfig.tagRenderings]
layerConfig.tagRenderings.push(state.tagRenderings.get("minimap"))
}
return {
errors: [],
warnings: [],
result: layerConfig
};
}
}
class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
constructor() {
super("If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)", ["layers"]);
}
private static CalculateDependencies(alreadyLoaded: LayerConfigJson[], allKnownLayers: Map<string, LayerConfigJson>, themeId: string): LayerConfigJson[] {
const dependenciesToAdd: LayerConfigJson[] = []
const loadedLayerIds: Set<string> = new Set<string>(alreadyLoaded.map(l => l.id));
// Verify cross-dependencies
let unmetDependencies: { neededLayer: string, neededBy: string, reason: string, context?: string }[] = []
do {
const dependencies: { neededLayer: string, reason: string, context?: string, neededBy: string }[] = []
for (const layerConfig of alreadyLoaded) {
const layerDeps = DependencyCalculator.getLayerDependencies(new LayerConfig(layerConfig))
dependencies.push(...layerDeps)
}
// During the generate script, builtin layers are verified but not loaded - so we have to add them manually here
// Their existance is checked elsewhere, so this is fine
unmetDependencies = dependencies.filter(dep => !loadedLayerIds.has(dep.neededLayer))
for (const unmetDependency of unmetDependencies) {
if (loadedLayerIds.has(unmetDependency.neededLayer)) {
continue
}
const dep = allKnownLayers.get(unmetDependency.neededLayer)
if (dep === undefined) {
const message =
["Loading a dependency failed: layer " + unmetDependency.neededLayer + " is not found, neither as layer of " + themeId + " nor as builtin layer.",
"This layer is needed by " + unmetDependency.neededBy,
unmetDependency.reason + " (at " + unmetDependency.context + ")",
"Loaded layers are: " + alreadyLoaded.map(l => l.id).join(",")
]
throw message.join("\n\t");
}
dependenciesToAdd.unshift(dep)
loadedLayerIds.add(dep.id);
unmetDependencies = unmetDependencies.filter(d => d.neededLayer !== unmetDependency.neededLayer)
}
} while (unmetDependencies.length > 0)
return dependenciesToAdd;
}
convert(state: DesugaringContext, theme: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } {
const allKnownLayers: Map<string, LayerConfigJson> = state.sharedLayers;
const knownTagRenderings: Map<string, TagRenderingConfigJson> = state.tagRenderings;
const errors = [];
const warnings = [];
const layers: LayerConfigJson[] = <LayerConfigJson[]>theme.layers; // Layers should be expanded at this point
knownTagRenderings.forEach((value, key) => {
value.id = key;
})
const dependencies = AddDependencyLayersToTheme.CalculateDependencies(layers, allKnownLayers, theme.id);
if (dependencies.length > 0) {
warnings.push(context + ": added " + dependencies.map(d => d.id).join(", ") + " to the theme as they are needed")
}
layers.unshift(...dependencies);
return {
result: {
...theme,
layers: layers
},
errors,
warnings
};
}
}
export class PrepareTheme extends Fuse<LayoutConfigJson> {
constructor() {
super(
"Fully prepares and expands a theme",
new OnEveryConcat("layers", new SubstituteLayer()),
new SetDefault("socialImage", "assets/SocialImage.png", true),
new AddDefaultLayers(),
new AddDependencyLayersToTheme(),
new OnEvery("layers", new PrepareLayer()),
new AddImportLayers(),
new OnEvery("layers", new AddMiniMap())
);
}
}

View file

@ -1,11 +1,11 @@
import {Translation} from "../i18n/Translation";
import Combine from "./Combine";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
export default class Loading extends Combine {
constructor(msg?: Translation | string) {
const t = Translations.T(msg) ?? Translations.t.general.loading.Clone();
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading;
t.SetClass("pl-2")
super([
Svg.loading_svg().SetClass("animate-spin").SetStyle("width: 1.5rem; height: 1.5rem;"),

View file

@ -37,7 +37,7 @@ export default class Minimap {
/**
* Construct a minimap
*/
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => {
public static createMiniMap: (options?: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => {
throw "CreateMinimap hasn't been initialized yet. Please call MinimapImplementation.initialize()"
}

View file

@ -28,7 +28,7 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
private readonly _addLayerControl: boolean;
private readonly _options: MinimapOptions;
private constructor(options: MinimapOptions) {
private constructor(options?: MinimapOptions) {
super()
options = options ?? {}
this.leafletMap = options.leafletMap ?? new UIEventSource<Map>(undefined)
@ -290,12 +290,6 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
map.setView([loc.lat, loc.lon], loc.zoom)
})
location.map(loc => loc.zoom)
.addCallback(zoom => {
if (Math.abs(map.getZoom() - zoom) > 0.1) {
map.setZoom(zoom, {});
}
})
if (self.bounds !== undefined) {
self.bounds.setData(BBox.fromLeafletBounds(map.getBounds()))

View file

@ -43,6 +43,7 @@ export default class SimpleAddUI extends Toggle {
constructor(isShown: UIEventSource<boolean>,
filterViewIsOpened: UIEventSource<boolean>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>,
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
changes: Changes,
@ -155,6 +156,7 @@ export default class SimpleAddUI extends Toggle {
private static CreateAllPresetsPanel(selectedPreset: UIEventSource<PresetInfo>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>;
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>,
osmConnection: OsmConnection
@ -162,10 +164,9 @@ export default class SimpleAddUI extends Toggle {
const presetButtons = SimpleAddUI.CreatePresetButtons(state, selectedPreset)
let intro: BaseUIElement = Translations.t.general.add.intro;
let testMode: BaseUIElement = undefined;
if (state.osmConnection?.userDetails?.data?.dryRun) {
testMode = Translations.t.general.testing.Clone().SetClass("alert")
}
let testMode: BaseUIElement = new Toggle(Translations.t.general.testing.SetClass("alert"),
undefined,
state.featureSwitchIsTesting);
return new Combine([intro, testMode, presetButtons]).SetClass("flex flex-col")

View file

@ -73,10 +73,11 @@ export default class UserBadge extends Toggle {
).SetClass("alert")
}
let dryrun = new FixedUiElement("");
if (user.dryRun) {
dryrun = new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4");
}
let dryrun = new Toggle(
new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"),
undefined,
state.featureSwitchIsTesting
)
const settings =
new Link(Svg.gear,

View file

@ -0,0 +1,100 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {UIEventSource} from "../../Logic/UIEventSource";
import ValidatedTextField from "../Input/ValidatedTextField";
import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource";
import Title from "../Base/Title";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import {DropDown} from "../Input/DropDown";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
export class AskMetadata extends Combine implements FlowStep<{
features: any[],
wikilink: string,
intro: string,
source: string,
theme: string
}> {
public readonly Value: UIEventSource<{
features: any[],
wikilink: string,
intro: string,
source: string,
theme: string
}>;
public readonly IsValid: UIEventSource<boolean>;
constructor(params: ({ features: any[], layer: LayerConfig })) {
const introduction = ValidatedTextField.InputForType("text", {
value: LocalStorageSource.Get("import-helper-introduction-text"),
inputStyle: "width: 100%"
})
const wikilink = ValidatedTextField.InputForType("string", {
value: LocalStorageSource.Get("import-helper-wikilink-text"),
inputStyle: "width: 100%"
})
const source = ValidatedTextField.InputForType("string", {
value: LocalStorageSource.Get("import-helper-source-text"),
inputStyle: "width: 100%"
})
let options : {value: string, shown: BaseUIElement}[]= AllKnownLayouts.layoutsList
.filter(th => th.layers.some(l => l.id === params.layer.id))
.filter(th => th.id !== "personal")
.map(th => ({
value: th.id,
shown: th.title
}))
options.splice(0,0, {
shown: new FixedUiElement("Select a theme"),
value: undefined
})
const theme = new DropDown("Which theme should be linked in the note?",options)
ValidatedTextField.InputForType("string", {
value: LocalStorageSource.Get("import-helper-theme-text"),
inputStyle: "width: 100%"
})
super([
new Title("Set metadata"),
"Before adding " + params.features.length + " notes, please provide some extra information.",
"Please, write an introduction for someone who sees the note",
introduction.SetClass("w-full border border-black"),
"What is the source of this data? If 'source' is set in the feature, this value will be ignored",
source.SetClass("w-full border border-black"),
"On what wikipage can one find more information about this import?",
wikilink.SetClass("w-full border border-black"),
theme
]);
this.SetClass("flex flex-col")
this.Value = introduction.GetValue().map(intro => {
return {
features: params.features,
wikilink: wikilink.GetValue().data,
intro,
source: source.GetValue().data,
theme: theme.GetValue().data
}
}, [wikilink.GetValue(), source.GetValue(), theme.GetValue()])
this.IsValid = this.Value.map(obj => {
if(obj === undefined){
return false;
}
return obj.theme !== undefined && obj.features !== undefined && obj.wikilink !== undefined && obj.intro !== undefined && obj.source !== undefined;
})
}
}

View file

@ -0,0 +1,134 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {BBox} from "../../Logic/BBox";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {UIEventSource} from "../../Logic/UIEventSource";
import {DesugaringContext} from "../../Models/ThemeConfig/Conversion/Conversion";
import CreateNoteImportLayer from "../../Models/ThemeConfig/Conversion/CreateNoteImportLayer";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import GeoJsonSource from "../../Logic/FeatureSource/Sources/GeoJsonSource";
import MetaTagging from "../../Logic/MetaTagging";
import RelationsTracker from "../../Logic/Osm/RelationsTracker";
import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource";
import Minimap from "../Base/Minimap";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import {ImportUtils} from "./ImportUtils";
import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import Title from "../Base/Title";
import Toggle from "../Input/Toggle";
import Loading from "../Base/Loading";
import {FixedUiElement} from "../Base/FixedUiElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import * as known_layers from "../../assets/generated/known_layers.json"
import {LayerConfigJson} from "../../Models/ThemeConfig/Json/LayerConfigJson";
/**
* Filters out points for which the import-note already exists, to prevent duplicates
*/
export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, geojson: any }> {
public IsValid: UIEventSource<boolean>
public Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, geojson: any }>
constructor(state, params: { bbox: BBox, layer: LayerConfig, geojson: { features: any[] } }) {
const convertState: DesugaringContext = {
sharedLayers: new Map(),
tagRenderings: new Map()
}
const layerConfig = known_layers.filter(l => l.id === params.layer.id)[0]
const importLayerJson = new CreateNoteImportLayer(365).convertStrict(convertState, <LayerConfigJson> layerConfig, "CompareToAlreadyExistingNotes")
const importLayer = new LayerConfig(importLayerJson, "import-layer-dynamic")
const flayer: FilteredLayer = {
appliedFilters: new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>()),
isDisplayed: new UIEventSource<boolean>(true),
layerDef: importLayer
}
const unfiltered = new GeoJsonSource(flayer, params.bbox.padAbsolute(0.0001))
unfiltered.features.map(f => MetaTagging.addMetatags(
f,
{
memberships: new RelationsTracker(),
getFeaturesWithin: (layerId, bbox: BBox) => [],
getFeatureById: (id: string) => undefined
},
importLayer,
state,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: true
}
)
)
const data = new FilteringFeatureSource(state, undefined, unfiltered)
data.features.addCallbackD(features => console.log("Loaded and filtered features are", features))
const map = Minimap.createMiniMap()
map.SetClass("w-full").SetStyle("height: 500px")
const comparison = Minimap.createMiniMap({
location: map.location,
})
comparison.SetClass("w-full").SetStyle("height: 500px")
new ShowDataLayer({
layerToShow: importLayer,
state,
zoomToFeatures: true,
leafletMap: map.leafletMap,
features: data,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state)
})
const maxDistance = new UIEventSource<number>(5)
const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(params.geojson, data.features
.map(ff => ({features: ff.map(ff => ff.feature)})), maxDistance)
new ShowDataLayer({
layerToShow: new LayerConfig(import_candidate),
state,
zoomToFeatures: true,
leafletMap: comparison.leafletMap,
features: new StaticFeatureSource(partitionedImportPoints.map(p => p.hasNearby), false),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state)
})
super([
new Title("Compare with already existing 'to-import'-notes"),
new Toggle(
new Loading("Fetching notes from OSM"),
new Combine([
map,
"The following (red) elements are elements to import which are nearby a matching element that is already up for import. These won't be imported",
new Toggle(
new FixedUiElement("All of the proposed points have (or had) an import note already").SetClass("alert w-full block").SetStyle("padding: 0.5rem"),
new VariableUiElement(partitionedImportPoints.map(({noNearby}) => noNearby.length + " elements can be imported")).SetClass("thanks p-8"),
partitionedImportPoints.map(({noNearby}) => noNearby.length === 0)
).SetClass("w-full"),
comparison,
]).SetClass("flex flex-col"),
unfiltered.features.map(ff => ff === undefined || ff.length === 0)
),
]);
this.SetClass("flex flex-col")
this.Value = partitionedImportPoints.map(({noNearby}) => ({
geojson: {features: noNearby, type: "FeatureCollection"},
bbox: params.bbox,
layer: params.layer
}))
this.IsValid = data.features.map(ff => ff.length > 0 && partitionedImportPoints.data.noNearby.length > 0, [partitionedImportPoints])
}
}

View file

@ -0,0 +1,32 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {UIEventSource} from "../../Logic/UIEventSource";
import Link from "../Base/Link";
import {FixedUiElement} from "../Base/FixedUiElement";
import CheckBoxes from "../Input/Checkboxes";
import Title from "../Base/Title";
export class ConfirmProcess<T> extends Combine implements FlowStep<T> {
public IsValid: UIEventSource<boolean>
public Value: UIEventSource<T>
constructor(v: T) {
const toConfirm = [
new Combine(["I have read the ", new Link("import guidelines on the OSM wiki", "https://wiki.openstreetmap.org/wiki/Import_guidelines", true)]),
new FixedUiElement("I did contact the (local) community about this import"),
new FixedUiElement("The license of the data to import allows it to be imported into OSM. They are allowed to be redistributed commercially, with only minimal attribution"),
new FixedUiElement("The process is documented on the OSM-wiki (you'll need this link later)")
];
const licenseClear = new CheckBoxes(toConfirm)
super([
new Title("Did you go through the import process?"),
licenseClear
]);
this.SetClass("link-underline")
this.IsValid = licenseClear.GetValue().map(selected => toConfirm.length == selected.length)
this.Value = new UIEventSource<T>(v)
}
}

View file

@ -27,10 +27,12 @@ import * as currentview from "../../assets/layers/current_view/current_view.json
import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json"
import {GeoOperations} from "../../Logic/GeoOperations";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import {ImportUtils} from "./ImportUtils";
/**
* Given the data to import, the bbox and the layer, will query overpass for similar items
*/
export default class ConflationChecker extends Combine implements FlowStep<any> {
export default class ConflationChecker extends Combine implements FlowStep<{features: any[], layer: LayerConfig}> {
public readonly IsValid
public readonly Value
@ -44,19 +46,21 @@ export default class ConflationChecker extends Combine implements FlowStep<any>
const layer = params.layer;
const toImport = params.geojson;
let overpassStatus = new UIEventSource<{ error: string } | "running" | "success" | "idle" | "cached" >("idle")
const cacheAge = new UIEventSource<number>(undefined);
const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>("importer-overpass-cache-" + layer.id, {
whenLoaded: (v) => {
if (v !== undefined) {
console.log("Loaded from local storage:", v)
const [geojson, date] = v;
const timeDiff = (new Date().getTime() - date.getTime()) / 1000;
console.log("The cache is ", timeDiff, "seconds old")
console.log("Loaded ", geojson.features.length," features; cache is ", timeDiff, "seconds old")
cacheAge.setData(timeDiff)
if (timeDiff < 24 * 60 * 60) {
// Recently cached!
overpassStatus.setData("cached")
return;
}
cacheAge.setData(-1)
}
// Load the data!
const url = Constants.defaultOverpassUrls[1]
@ -115,7 +119,7 @@ export default class ConflationChecker extends Combine implements FlowStep<any>
layerToShow:new LayerConfig(currentview),
state,
leafletMap: osmLiveData.leafletMap,
enablePopups: undefined,
popup: undefined,
zoomToFeatures: true,
features: new StaticFeatureSource([
bbox.asGeoJson({})
@ -161,17 +165,10 @@ export default class ConflationChecker extends Combine implements FlowStep<any>
toImport.features.some(imp =>
maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) )
}, [nearbyCutoff.GetValue()]), false);
const paritionedImport = ImportUtils.partitionFeaturesIfNearby(toImport, geojson, nearbyCutoff.GetValue().map(Number));
// Featuresource showing OSM-features which are nearby a toImport-feature
const toImportWithNearby = new StaticFeatureSource(geojson.map(osmData => {
if(osmData?.features === undefined){
return []
}
const maxDist = Number(nearbyCutoff.GetValue().data)
return toImport.features.filter(imp =>
osmData.features.some(f =>
maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) )
}, [nearbyCutoff.GetValue()]), false);
const toImportWithNearby = new StaticFeatureSource(paritionedImport.map(els =>els?.hasNearby ?? []), false);
new ShowDataLayer({
layerToShow:layer,
@ -192,6 +189,38 @@ export default class ConflationChecker extends Combine implements FlowStep<any>
})
const conflationMaps = new Combine([
new VariableUiElement(
geojson.map(geojson => {
if (geojson === undefined) {
return undefined;
}
return new SubtleButton(Svg.download_svg(), "Download the loaded geojson from overpass").onClick(() => {
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), "mapcomplete-" + layer.id + ".geojson", {
mimetype: "application/json+geo"
})
});
})),
new VariableUiElement(cacheAge.map(age => {
if(age === undefined){
return undefined;
}
if(age < 0){
return new FixedUiElement("Cache was expired")
}
return new FixedUiElement("Loaded data is from the cache and is "+Utils.toHumanTime(age)+" old")
})),
new Title("Live data on OSM"),
osmLiveData,
new Combine(["The live data is shown if the zoomlevel is at least ", zoomLevel, ". The current zoom level is ", new VariableUiElement(osmLiveData.location.map(l => ""+l.zoom))]).SetClass("flex"),
new Title("Nearby features"),
new Combine([ "The following map shows features to import which have an OSM-feature within ", nearbyCutoff, "meter"]).SetClass("flex"),
new FixedUiElement("The red elements on the following map will <b>not</b> be imported!").SetClass("alert"),
"Set the range to 0 or 1 if you want to import them all",
matchedFeaturesMap]).SetClass("flex flex-col")
super([
new Title("Comparison with existing data"),
new VariableUiElement(overpassStatus.map(d => {
@ -205,38 +234,19 @@ export default class ConflationChecker extends Combine implements FlowStep<any>
return new Loading("Querying overpass...")
}
if(d === "cached"){
return new FixedUiElement("Fetched data from local storage")
return conflationMaps
}
if(d === "success"){
return new FixedUiElement("Data loaded")
return conflationMaps
}
return new FixedUiElement("Unexpected state "+d).SetClass("alert")
})),
new VariableUiElement(
geojson.map(geojson => {
if (geojson === undefined) {
return undefined;
}
return new SubtleButton(Svg.download_svg(), "Download the loaded geojson from overpass").onClick(() => {
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), "mapcomplete-" + layer.id + ".geojson", {
mimetype: "application/json+geo"
})
});
})),
}))
new Title("Live data on OSM"),
osmLiveData,
new Combine(["The live data is shown if the zoomlevel is at least ", zoomLevel, ". The current zoom level is ", new VariableUiElement(osmLiveData.location.map(l => ""+l.zoom))]).SetClass("flex"),
new Title("Nearby features"),
new Combine([ "The following map shows features to import which have an OSM-feature within ", nearbyCutoff, "meter"]).SetClass("flex"),
new FixedUiElement("The red elements on the following map will <b>not</b> be imported!").SetClass("alert"),
"Set the range to 0 or 1 if you want to import them all",
matchedFeaturesMap
])
this.IsValid = new UIEventSource(false)
this.Value = new UIEventSource(undefined)
}
this.Value = paritionedImport.map(feats => ({features: feats?.noNearby, layer: params.layer}))
this.Value.addCallbackAndRun(v => console.log("ConflationChecker-step value is ", v))
this.IsValid = this.Value.map(v => v?.features !== undefined && v.features.length > 0)
}
}

View file

@ -0,0 +1,82 @@
import Combine from "../Base/Combine";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {UIEventSource} from "../../Logic/UIEventSource";
import Title from "../Base/Title";
import Toggle from "../Input/Toggle";
import Loading from "../Base/Loading";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import Link from "../Base/Link";
export class CreateNotes extends Combine {
constructor(state: { osmConnection: OsmConnection }, v: { features: any[]; wikilink: string; intro: string; source: string, theme: string }) {
const createdNotes: UIEventSource<number[]> = new UIEventSource<number[]>([])
const failed = new UIEventSource<string[]>([])
const currentNote = createdNotes.map(n => n.length)
for (const f of v.features) {
const src = f.properties["source"] ?? f.properties["src"] ?? v.source
delete f.properties["source"]
delete f.properties["src"]
const tags: string [] = []
for (const key in f.properties) {
if(f.properties[key] === ""){
continue
}
tags.push(key + "=" + f.properties[key].replace(/=/, "\\=").replace(/;/g, "\\;").replace(/\n/g, "\\n"))
}
const lat = f.geometry.coordinates[1]
const lon = f.geometry.coordinates[0]
const text = [v.intro,
'',
"Source: " + src,
'More information at ' + v.wikilink,
'',
'Import this point easily with',
`https://mapcomplete.osm.be/${v.theme}.html?z=18&lat=${lat}&lon=${lon}#import`,
...tags].join("\n")
state.osmConnection.openNote(
lat, lon, text)
.then(({id}) => {
createdNotes.data.push(id)
createdNotes.ping()
}, err => {
failed.data.push(err)
failed.ping()
})
}
super([
new Title("Creating notes"),
"Hang on while we are importing...",
new Toggle(
new Loading(new VariableUiElement(currentNote.map(count => new FixedUiElement("Imported <b>" + count + "</b> out of " + v.features.length + " notes")))),
new FixedUiElement("All done!"),
currentNote.map(count => count < v.features.length)
),
new VariableUiElement(failed.map(failed => {
if (failed.length === 0) {
return undefined
}
return new Combine([
new FixedUiElement("Some entries failed").SetClass("alert"),
...failed
]).SetClass("flex flex-col")
})),
new VariableUiElement(createdNotes.map(notes => {
const links = notes.map(n =>
new Link(new FixedUiElement("https://openstreetmap.org/note/" + n), "https://openstreetmap.org/note/" + n, true));
return new Combine(links).SetClass("flex flex-col");
}))
])
this.SetClass("flex flex-col");
}
}

View file

@ -21,7 +21,23 @@ import Table from "../Base/Table";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {FlowStep} from "./FlowStep";
import {Layer} from "leaflet";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import {AllTagsPanel} from "../SpecialVisualizations";
import Title from "../Base/Title";
class PreviewPanel extends ScrollableFullScreen {
constructor(tags, layer) {
super(
_ => new FixedUiElement("Element to import"),
_ => new Combine(["The tags are:",
new AllTagsPanel(tags)
]).SetClass("flex flex-col"),
"element"
);
}
}
/**
* Shows the data to import on a map, asks for the correct layer to be selected
@ -36,7 +52,6 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer:
const t = Translations.t.importHelper;
const propertyKeys = new Set<string>()
console.log("Datapanel input got ", geojson)
for (const f of geojson.features) {
Object.keys(f.properties).forEach(key => propertyKeys.add(key))
}
@ -56,6 +71,7 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer:
!layer.source.osmTags.matchesProperties(f.properties)
)
if (!mismatched) {
console.log("Autodected layer", layer.id)
layerPicker.GetValue().setData(layer);
layerPicker.GetValue().addCallback(_ => autodetected.setData(false))
autodetected.setData(true)
@ -96,25 +112,22 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer:
map.SetClass("w-full").SetStyle("height: 500px")
new ShowDataMultiLayer({
layers: new UIEventSource<FilteredLayer[]>(AllKnownLayouts.AllPublicLayers().map(l => ({
layers: new UIEventSource<FilteredLayer[]>(AllKnownLayouts.AllPublicLayers()
.filter(l => l.source.geojsonSource === undefined)
.map(l => ({
layerDef: l,
isDisplayed: new UIEventSource<boolean>(true),
appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined)
}))),
zoomToFeatures: true,
features: new StaticFeatureSource(matching, false),
state: {
...state,
filteredLayers: new UIEventSource<FilteredLayer[]>(undefined),
backgroundLayer: background
},
leafletMap: map.leafletMap,
popup: (tag, layer) => new PreviewPanel(tag, layer).SetClass("font-lg")
})
var bbox = matching.map(feats => BBox.bboxAroundAll(feats.map(f => new BBox([f.geometry.coordinates]))))
super([
"Has " + geojson.features.length + " features",
new Title(geojson.features.length + " features to import"),
layerPicker,
new Toggle("Automatically detected layer", undefined, autodetected),
new Table(["", "Key", "Values", "Unique values seen"],

View file

@ -8,7 +8,7 @@ import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import {UIElement} from "../UIElement";
export interface FlowStep<T> extends BaseUIElement{
export interface FlowStep<T> extends BaseUIElement {
readonly IsValid: UIEventSource<boolean>
readonly Value: UIEventSource<T>
}
@ -16,70 +16,97 @@ export interface FlowStep<T> extends BaseUIElement{
export class FlowPanelFactory<T> {
private _initial: FlowStep<any>;
private _steps: ((x: any) => FlowStep<any>)[];
private _stepNames: string[];
private constructor(initial: FlowStep<any>, steps: ((x:any) => FlowStep<any>)[], stepNames: string[]) {
private _stepNames: (string | BaseUIElement)[];
private constructor(initial: FlowStep<any>, steps: ((x: any) => FlowStep<any>)[], stepNames: (string | BaseUIElement)[]) {
this._initial = initial;
this._steps = steps;
this._stepNames = stepNames;
}
public static start<TOut> (step: FlowStep<TOut>): FlowPanelFactory<TOut>{
return new FlowPanelFactory(step, [], [])
public static start<TOut>(name: string | BaseUIElement, step: FlowStep<TOut>): FlowPanelFactory<TOut> {
return new FlowPanelFactory(step, [], [name])
}
public then<TOut>(name: string, construct: ((t:T) => FlowStep<TOut>)): FlowPanelFactory<TOut>{
public then<TOut>(name: string | BaseUIElement, construct: ((t: T) => FlowStep<TOut>)): FlowPanelFactory<TOut> {
return new FlowPanelFactory<TOut>(
this._initial,
this._steps.concat([construct]),
this._stepNames.concat([name])
)
}
public finish(construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)) : BaseUIElement {
public finish(name: string | BaseUIElement, construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)): {
flow: BaseUIElement,
furthestStep: UIEventSource<number>,
titles: (string | BaseUIElement)[]
} {
const furthestStep = new UIEventSource(0)
// Construct all the flowpanels step by step (in reverse order)
const nextConstr : ((t:any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined)
const nextConstr: ((t: any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined)
nextConstr.push(construct)
for (let i = this._steps.length - 1; i >= 0; i--){
const createFlowStep : (value) => FlowStep<any> = this._steps[i];
for (let i = this._steps.length - 1; i >= 0; i--) {
const createFlowStep: (value) => FlowStep<any> = this._steps[i];
const isConfirm = i == this._steps.length - 1;
nextConstr[i] = (value, backButton) => {
console.log("Creating flowSTep ", this._stepNames[i])
const flowStep = createFlowStep(value)
return new FlowPanel(flowStep, nextConstr[i + 1], backButton);
furthestStep.setData(i + 1);
const panel = new FlowPanel(flowStep, nextConstr[i + 1], backButton, isConfirm);
panel.isActive.addCallbackAndRun(active => {
if (active) {
furthestStep.setData(i + 1);
}
})
return panel
}
}
return new FlowPanel(this._initial, nextConstr[0],undefined)
const flow = new FlowPanel(this._initial, nextConstr[0])
flow.isActive.addCallbackAndRun(active => {
if (active) {
furthestStep.setData(0);
}
})
return {
flow,
furthestStep,
titles: this._stepNames
}
}
}
export class FlowPanel<T> extends Toggle {
public isActive: UIEventSource<boolean>
constructor(
initial: (FlowStep<T>),
constructNextstep: ((input: T, backButton: BaseUIElement) => BaseUIElement),
backbutton?: BaseUIElement
constructNextstep: ((input: T, backButton: BaseUIElement) => BaseUIElement),
backbutton?: BaseUIElement,
isConfirm = false
) {
const t = Translations.t.general;
const currentStepActive = new UIEventSource(true);
let nextStep: UIEventSource<BaseUIElement>= new UIEventSource<BaseUIElement>(undefined)
let nextStep: UIEventSource<BaseUIElement> = new UIEventSource<BaseUIElement>(undefined)
const backButtonForNextStep = new SubtleButton(Svg.back_svg(), t.back).onClick(() => {
currentStepActive.setData(true)
})
let elements : (BaseUIElement | string)[] = []
if(initial !== undefined){
let elements: (BaseUIElement | string)[] = []
if (initial !== undefined) {
// Startup the flow
elements = [
initial,
new Combine([
backbutton,
new Toggle(
new SubtleButton(Svg.back_svg().SetStyle("transform: rotate(180deg);"), t.next).onClick(() => {
new SubtleButton(
isConfirm ? Svg.checkmark_svg() :
Svg.back_svg().SetStyle("transform: rotate(180deg);"),
isConfirm ? t.confirm : t.next
).onClick(() => {
const v = initial.Value.data;
nextStep.setData(constructNextstep(v, backButtonForNextStep))
currentStepActive.setData(false)
@ -88,18 +115,18 @@ export class FlowPanel<T> extends Toggle {
initial.IsValid
)
]).SetClass("flex w-full justify-end space-x-2")
]
}
super(
new Combine(elements).SetClass("h-full flex flex-col justify-between"),
new VariableUiElement(nextStep),
currentStepActive
);
this.isActive = currentStepActive
}
}

View file

@ -9,11 +9,18 @@ import MoreScreen from "../BigComponents/MoreScreen";
import MinimapImplementation from "../Base/MinimapImplementation";
import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants";
import {FlowPanel, FlowPanelFactory} from "./FlowStep";
import {FlowPanelFactory} from "./FlowStep";
import {RequestFile} from "./RequestFile";
import {DataPanel} from "./DataPanel";
import {FixedUiElement} from "../Base/FixedUiElement";
import ConflationChecker from "./ConflationChecker";
import {AskMetadata} from "./AskMetadata";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {ConfirmProcess} from "./ConfirmProcess";
import {CreateNotes} from "./CreateNotes";
import {FixedUiElement} from "../Base/FixedUiElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import List from "../Base/List";
import {CompareToAlreadyExistingNotes} from "./CompareToAlreadyExistingNotes";
export default class ImportHelperGui extends LoginToggle {
constructor() {
@ -24,28 +31,51 @@ export default class ImportHelperGui extends LoginToggle {
// We disable the userbadge, as various 'showData'-layers will give a read-only view in this case
state.featureSwitchUserbadge.setData(false)
const {flow, furthestStep, titles} =
FlowPanelFactory
.start("Select file", new RequestFile())
.then("Inspect data", geojson => new DataPanel(state, geojson))
.then("Compare with open notes", v => new CompareToAlreadyExistingNotes(state, v))
.then("Compare with existing data", v => new ConflationChecker(state, v))
.then("License and community check", v => new ConfirmProcess(v))
.then("Metadata", (v:{features:any[], layer: LayerConfig}) => new AskMetadata(v))
.finish("Note creation", v => new CreateNotes(state, v));
const toc = new List(
titles.map((title, i) => new VariableUiElement(furthestStep.map(currentStep => {
if(i > currentStep){
return new Combine([title]).SetClass("subtle");
}
if(i == currentStep){
return new Combine([title]).SetClass("font-bold");
}
if(i < currentStep){
return title
}
})))
, true)
const leftContents: BaseUIElement[] = [
new BackToIndex().SetClass("block pl-4"),
toc,
new Toggle(new FixedUiElement("Testmode - won't actually import notes").SetClass("alert"), undefined, state.featureSwitchIsTesting),
LanguagePicker.CreateLanguagePicker(Translations.t.importHelper.title.SupportedLanguages())?.SetClass("mt-4 self-end flex-col"),
].map(el => el?.SetClass("pl-4"))
const leftBar = new Combine([
new Combine(leftContents).SetClass("sticky top-4 m-4")
]).SetClass("block w-full md:w-2/6 lg:w-1/6")
new Combine(leftContents).SetClass("sticky top-4 m-4"),
]).SetClass("block w-full md:w-2/6 lg:w-1/6")
const mainPanel =
FlowPanelFactory
.start(new RequestFile())
.then("datapanel", geojson => new DataPanel(state, geojson))
.then("conflation", v => new ConflationChecker(state, v))
.finish(_ => new FixedUiElement("All done!"))
super(
new Toggle(
new Combine([
leftBar,
mainPanel.SetClass("m-8 w-full mb-24")
flow.SetClass("m-8 w-full mb-24")
]).SetClass("h-full block md:flex")
,

View file

@ -0,0 +1,28 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {GeoOperations} from "../../Logic/GeoOperations";
export class ImportUtils {
public static partitionFeaturesIfNearby(toPartitionFeatureCollection: ({ features: any[] }), compareWith: UIEventSource<{ features: any[] }>, cutoffDistanceInMeters: UIEventSource<number>): UIEventSource<{ hasNearby: any[], noNearby: any[] }> {
return compareWith.map(osmData => {
if (osmData?.features === undefined) {
return undefined
}
const maxDist = cutoffDistanceInMeters.data
const hasNearby = []
const noNearby = []
for (const toImportElement of toPartitionFeatureCollection.features) {
const hasNearbyFeature = osmData.features.some(f =>
maxDist >= GeoOperations.distanceBetween(toImportElement.geometry.coordinates, GeoOperations.centerpointCoordinates(f)))
if (hasNearbyFeature) {
hasNearby.push(toImportElement)
} else {
noNearby.push(toImportElement)
}
}
return {hasNearby, noNearby}
}, [cutoffDistanceInMeters]);
}
}

View file

@ -504,7 +504,8 @@ export default class ValidatedTextField {
mapBackgroundLayer?: UIEventSource<any>,
unit?: Unit,
args?: (string | number | boolean)[] // Extra arguments for the inputHelper,
feature?: any
feature?: any,
inputStyle?: string
}): InputElement<string> {
options = options ?? {};
options.placeholder = options.placeholder ?? type;

View file

@ -19,6 +19,7 @@ export default class ConfirmLocationOfPoint extends Combine {
constructor(
state: {
featureSwitchIsTesting: UIEventSource<boolean>;
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline,
backgroundLayer?: UIEventSource<BaseLayer>
@ -167,8 +168,11 @@ export default class ConfirmLocationOfPoint extends Combine {
).onClick(cancel)
super([
state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined,
new Toggle(
Translations.t.general.testing.SetClass("alert"),
undefined,
state.featureSwitchIsTesting
),
disableFiltersOrConfirm,
cancelButton,
preset.description,

View file

@ -141,7 +141,7 @@ ${Utils.special_visualizations_importRequirementDocs}
if(tagSpec.indexOf(" ")< 0 && tagSpec.indexOf(";") < 0 && tagSource.data[args.tags] !== undefined){
// This is probably a key
tagSpec = tagSource.data[args.tags]
console.warn("Using tagspec tagSource.data["+args.tags+"] which is ",tagSpec)
console.debug("The import button is using tags from properties["+args.tags+"] of this object, namely ",tagSpec)
}
const importClicked = new UIEventSource(false);
@ -201,7 +201,7 @@ ${Utils.special_visualizations_importRequirementDocs}
if(tags.indexOf(" ") < 0 && tags.indexOf(";") < 0 && originalFeatureTags.data[tags] !== undefined){
// This might be a property to expand...
const items : string = originalFeatureTags.data[tags]
console.warn("Using tagspec tagSource.data["+tags+"] which is ",items)
console.debug("The import button is using tags from properties["+tags+"] of this object, namely ",items)
baseArgs["newTags"] = TagApplyButton.generateTagsToApply(items, originalFeatureTags)
}else{
baseArgs["newTags"] = TagApplyButton.generateTagsToApply(tags, originalFeatureTags)

View file

@ -55,6 +55,45 @@ export interface SpecialVisualization {
getLayerDependencies?: (argument: string[]) => string[]
}
export class AllTagsPanel extends VariableUiElement {
constructor(tags: UIEventSource<any>, state?) {
const calculatedTags = [].concat(
SimpleMetaTagger.lazyTags,
...(state?.layoutToUse?.layers?.map(l => l.calculatedTags?.map(c => c[0]) ?? []) ?? []))
super(tags.map(tags => {
const parts = [];
for (const key in tags) {
if (!tags.hasOwnProperty(key)) {
continue
}
let v = tags[key]
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"]);
}
for (const key of calculatedTags) {
const value = tags[key]
if (value === undefined) {
continue
}
parts.push(["<i>" + key + "</i>", value])
}
return new Table(
["key", "value"],
parts
)
.SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;").SetClass("zebra-table")
}))
}
}
export default class SpecialVisualizations {
public static specialVisualizations = SpecialVisualizations.init()
@ -99,37 +138,7 @@ export default class SpecialVisualizations {
funcName: "all_tags",
docs: "Prints all key-value pairs of the object - used for debugging",
args: [],
constr: ((state, tags: UIEventSource<any>) => {
const calculatedTags = [].concat(
SimpleMetaTagger.lazyTags,
...(state?.layoutToUse?.layers?.map(l => l.calculatedTags?.map(c => c[0]) ?? []) ?? []))
return new VariableUiElement(tags.map(tags => {
const parts = [];
for (const key in tags) {
if (!tags.hasOwnProperty(key)) {
continue
}
let v = tags[key]
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"]);
}
for (const key of calculatedTags) {
const value = tags[key]
if (value === undefined) {
continue
}
parts.push(["<i>" + key + "</i>", value])
}
return new Table(
["key", "value"],
parts
)
})).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;").SetClass("zebra-table")
})
constr: ((state, tags: UIEventSource<any>) => new AllTagsPanel(tags, state))
},
{
funcName: "image_carousel",
@ -339,7 +348,7 @@ export default class SpecialVisualizations {
const mangrove = MangroveReviews.Get(Number(tgs._lon), Number(tgs._lat),
encodeURIComponent(subject),
state.mangroveIdentity,
state.osmConnection._dryRun
state.featureSwitchIsTesting.data
);
const form = new ReviewForm((r, whenDone) => mangrove.AddReview(r, whenDone), state.osmConnection);
return new ReviewElement(mangrove.GetSubjectUri(), mangrove.GetReviews(), form);
@ -743,10 +752,6 @@ export default class SpecialVisualizations {
return t.addCommentAndClose
}))).onClick(() => {
const id = tags.data[args[1] ?? "id"]
if (state.featureSwitchIsTesting.data) {
console.log("Testmode: Not actually closing note...")
return;
}
state.osmConnection.closeNote(id, txt.data).then(_ => {
tags.data["closed_at"] = new Date().toISOString();
tags.ping()
@ -760,10 +765,6 @@ export default class SpecialVisualizations {
return t.reopenNoteAndComment
}))).onClick(() => {
const id = tags.data[args[1] ?? "id"]
if (state.featureSwitchIsTesting.data) {
console.log("Testmode: Not actually reopening note...")
return;
}
state.osmConnection.reopenNote(id, txt.data).then(_ => {
tags.data["closed_at"] = undefined;
tags.ping()

View file

@ -6,7 +6,7 @@
"description": "This layer shows notes on OpenStreetMap. Having this layer in your theme will trigger the 'add new note' functionality in the 'addNewPoint'-popup (or if your theme has no presets, it'll enable adding notes)",
"source": {
"osmTags": "id~*",
"geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?closed=7&bbox={x_min},{y_min},{x_max},{y_max}",
"geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed=7&bbox={x_min},{y_min},{x_max},{y_max}",
"geoJsonZoomLevel": 12,
"maxCacheAge": 0
},
@ -29,7 +29,8 @@
"_opened_by_anonymous_user:=feat.get('comments')[0].user === undefined",
"_first_user:=feat.get('comments')[0].user",
"_first_user_lc:=feat.get('comments')[0].user?.toLowerCase()",
"_first_user_id:=feat.get('comments')[0].uid"
"_first_user_id:=feat.get('comments')[0].uid",
"_is_import_note:=(() => {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()"
],
"titleIcons": [
{
@ -201,6 +202,17 @@
}
}
]
},
{
"id": "no_imports",
"options": [
{
"osmTags": "_is_import_note=",
"question": {
"en": "Hide import notes"
}
}
]
}
]
}

View file

@ -1493,6 +1493,10 @@ video {
padding: 0.125rem;
}
.p-8 {
padding: 2rem;
}
.pb-12 {
padding-bottom: 3rem;
}

View file

@ -465,7 +465,9 @@
"importLayer": {
"layerName": "Possible {title}",
"description": "A layer which imports entries for {title}",
"popupTitle": "Possible {title}"
"popupTitle": "Possible {title}",
"importButton": "import_button({layerId}, _tags, There might be a {title} here,./assets/svg/addSmall.svg,,,id)",
"importHandled": "<div class='thanks'>This feature has been handled! Thanks for your effort</div>"
},
"importHelper": {
"title": "Import helper",
@ -477,5 +479,5 @@
"selectLayer": "Select a layer...",
"selectFileTitle": "Select file",
"validateDataTitle": "Validate data"
}
}
}

View file

@ -3354,6 +3354,13 @@
"question": "Only show open notes"
}
}
},
"8": {
"options": {
"0": {
"question": "Hide import notes"
}
}
}
},
"name": "OpenStreetMap notes",

View file

@ -314,5 +314,12 @@
},
"multi_apply": {
"autoApply": "Wijzigingen aan eigenschappen {attr_names} zullen ook worden uitgevoerd op {count} andere objecten."
},
"importLayer": {
"layerName": "Hier is misschien een {title}",
"description": "Deze laag toont kaart-nota's die wijzen op een {title}",
"popupTitle": "Mogelijkse {title}",
"importButton": "import_button({layerId}, _tags, Hier is een {title}, voeg toe...,./assets/svg/addSmall.svg,,,id)",
"importHandled": "<div class='thanks'>Dit punt is afgehandeld. Bedankt om mee te helpen!</div>"
}
}

View file

@ -5,9 +5,6 @@ import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import Constants from "../Models/Constants";
import {
DesugaringContext,
PrepareLayer,
PrepareTheme,
ValidateLayer,
ValidateThemeAndLayers
} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
@ -16,6 +13,9 @@ import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingCon
import * as questions from "../assets/tagRenderings/questions.json";
import * as icons from "../assets/tagRenderings/icons.json";
import PointRenderingConfigJson from "../Models/ThemeConfig/Json/PointRenderingConfigJson";
import {PrepareLayer} from "../Models/ThemeConfig/Conversion/PrepareLayer";
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
import {DesugaringContext} from "../Models/ThemeConfig/Conversion/Conversion";
// This scripts scans 'assets/layers/*.json' for layer definition files and 'assets/themes/*.json' for theme definition files.
// It spits out an overview of those to be used to load them

View file

@ -1,10 +1,11 @@
import T from "./TestHelper";
import CreateNoteImportLayer from "../Models/ThemeConfig/Conversion/CreateNoteImportLayer";
import * as bookcases from "../assets/layers/public_bookcase/public_bookcase.json"
import {DesugaringContext, PrepareLayer} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {DesugaringContext} from "../Models/ThemeConfig/Conversion/Conversion";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import {PrepareLayer} from "../Models/ThemeConfig/Conversion/PrepareLayer";
export default class CreateNoteImportLayerSpec extends T {
@ -17,7 +18,7 @@ export default class CreateNoteImportLayerSpec extends T {
}
const layerPrepare = new PrepareLayer()
const layer = new LayerConfig(layerPrepare.convertStrict(desugaringState, bookcases, "ImportLayerGeneratorTest:Parse bookcases"), "ImportLayerGeneratorTest: init bookcases-layer")
const layer =layerPrepare.convertStrict(desugaringState, bookcases, "ImportLayerGeneratorTest:Parse bookcases")
const generator = new CreateNoteImportLayer()
const generatedLayer = generator.convertStrict(desugaringState, layer, "ImportLayerGeneratorTest: convert")
// fs.writeFileSync("bookcases-import-layer.generated.json", JSON.stringify(generatedLayer, null, " "), "utf8")

View file

@ -112,6 +112,12 @@ export default class TagSpec extends T {
equal(compare.matchesProperties({"key": "5"}), true);
equal(compare.matchesProperties({"key": "4.2"}), false);
const importMatch = TagUtils.Tag("tags~(^|.*;)amenity=public_bookcase($|;.*)")
equal(importMatch.matchesProperties({"tags": "amenity=public_bookcase;name=test"}), true)
equal(importMatch.matchesProperties({"tags": "amenity=public_bookcase"}), true)
equal(importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}), true)
equal(importMatch.matchesProperties({"tags": "amenity=bench"}), false)
})],
["Is equivalent test", (() => {

View file

@ -3,10 +3,10 @@ import * as assert from "assert";
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import * as bookcaseLayer from "../assets/generated/layers/public_bookcase.json"
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import Constants from "../Models/Constants";
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
export default class ThemeSpec extends T {
constructor() {