diff --git a/Docs/Tools/GenerateSeries.ts b/Docs/Tools/GenerateSeries.ts index 1c0a4d043..ca68ff1de 100644 --- a/Docs/Tools/GenerateSeries.ts +++ b/Docs/Tools/GenerateSeries.ts @@ -181,6 +181,9 @@ class ChangesetDataTools { cs.properties.metadata.host = new URL(cs.properties.metadata.host).host } catch (e) { + } + if(cs.properties.metadata["answer"] > 100){ + console.log("Lots of answers for https://osm.org/changeset/"+cs.id) } return cs } diff --git a/Models/ThemeConfig/Conversion/Conversion.ts b/Models/ThemeConfig/Conversion/Conversion.ts index 75f1683f2..6be0b2756 100644 --- a/Models/ThemeConfig/Conversion/Conversion.ts +++ b/Models/ThemeConfig/Conversion/Conversion.ts @@ -42,7 +42,7 @@ export abstract class Conversion { public convertAll(jsons: TIn[], context: string): { result: TOut[], errors: string[], warnings: string[], information?: string[] } { if(jsons === undefined || jsons === null){ - throw "convertAll received undefined or null - don't do this (at "+context+")" + throw `Detected a bug in the preprocessor pipeline: ${this.name}.convertAll received undefined or null - don't do this (at ${context})` } const result = [] const errors = [] @@ -72,23 +72,34 @@ export abstract class DesugaringStep extends Conversion { export class OnEvery extends DesugaringStep { private readonly key: string; private readonly step: DesugaringStep; + private _options: { ignoreIfUndefined: boolean }; - constructor(key: string, step: DesugaringStep) { + constructor(key: string, step: DesugaringStep, options?: { + ignoreIfUndefined: false | boolean + }) { super("Applies " + step.name + " onto every object of the list `key`", [key], "OnEvery("+step.name+")"); this.step = step; this.key = key; + this._options = options; } convert(json: T, context: string): { result: T; errors?: string[]; warnings?: string[], information?: string[] } { json = {...json} const step = this.step const key = this.key; - const r = step.convertAll((json[key]), context + "." + key) - json[key] = r.result - return { - ...r, - result: json, - }; + if( this._options?.ignoreIfUndefined && json[key] === undefined){ + return { + result: json, + }; + }else{ + const r = step.convertAll((json[key]), context + "." + key) + json[key] = r.result + return { + ...r, + result: json, + }; + } + } } diff --git a/Models/ThemeConfig/Conversion/PrepareLayer.ts b/Models/ThemeConfig/Conversion/PrepareLayer.ts index 195853577..f251a539f 100644 --- a/Models/ThemeConfig/Conversion/PrepareLayer.ts +++ b/Models/ThemeConfig/Conversion/PrepareLayer.ts @@ -1,10 +1,12 @@ -import {Conversion, DesugaringContext, Fuse, OnEvery, OnEveryConcat, SetDefault} from "./Conversion"; +import {Conversion, DesugaringContext, DesugaringStep, Fuse, OnEvery, OnEveryConcat, SetDefault} from "./Conversion"; import {LayerConfigJson} from "../Json/LayerConfigJson"; import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"; import {Utils} from "../../../Utils"; +import RewritableConfigJson from "../Json/RewritableConfigJson"; +import SpecialVisualizations from "../../../UI/SpecialVisualizations"; import Translations from "../../../UI/i18n/Translations"; import {Translation} from "../../../UI/i18n/Translation"; -import RewritableConfigJson from "../Json/RewritableConfigJson"; +import * as tagrenderingconfigmeta from "../../../assets/tagrenderingconfigmeta.json" class ExpandTagRendering extends Conversion { private readonly _state: DesugaringContext; @@ -349,28 +351,168 @@ class ExpandRewrite extends Conversion, T[]> { } - -class ExpandRewriteWithFlatten extends Conversion, T[]> { - - private _rewrite = new ExpandRewrite() - +/** + * Converts a 'special' translation into a regular translation which uses parameters + * E.g. + * + * const tr = { + * "special": + * } + */ +export class RewriteSpecial extends DesugaringStep { constructor() { - super("Applies a rewrite, the result is flattened if it is an array", [], "ExpandRewriteWithFlatten"); + super("Converts a 'special' translation into a regular translation which uses parameters", ["special"],"RewriteSpecial"); } - convert(json: RewritableConfigJson | T, context: string): { result: T[]; errors?: string[]; warnings?: string[]; information?: string[] } { - return undefined; + /** + * Does the heavy lifting and conversion + * + * // should not do anything if no 'special'-key is present + * RewriteSpecial.convertIfNeeded({"en": "xyz", "nl": "abc"}, [], "test") // => {"en": "xyz", "nl": "abc"} + * + * // should handle a simple special case + * RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel"}}, [], "test") // => {'*': "{image_carousel()}"} + * + * // should handle special case with a parameter + * RewriteSpecial.convertIfNeeded({"special": {"type":"image_carousel", "image_key": "some_image_key"}}, [], "test") // => {'*': "{image_carousel(some_image_key)}"} + * + * // should handle special case with a translated parameter + * const spec = {"special": {"type":"image_upload", "label": {"en": "Add a picture to this object", "nl": "Voeg een afbeelding toe"}}} + * const r = RewriteSpecial.convertIfNeeded(spec, [], "test") + * r // => {"en": "{image_upload(,Add a picture to this object)}", "nl": "{image_upload(,Voeg een afbeelding toe)}" } + * + * // should warn for unexpected keys + * const errors = [] + * RewriteSpecial.convertIfNeeded({"special": {type: "image_carousel"}, "en": "xyz"}, errors, "test") // => {'*': "{image_carousel()}"} + * errors // => ["At test: Unexpected key in a special block: en"] + * + * // should give an error on unknown visualisations + * const errors = [] + * RewriteSpecial.convertIfNeeded({"special": {type: "qsdf"}}, errors, "test") // => undefined + * errors.length // => 1 + * errors[0].indexOf("Special visualisation 'qsdf' not found") >= 0 // => true + * + * // should give an error is 'type' is missing + * const errors = [] + * RewriteSpecial.convertIfNeeded({"special": {}}, errors, "test") // => undefined + * errors // => ["A 'special'-block should define 'type' to indicate which visualisation should be used"] + */ + private static convertIfNeeded(input: (object & {special : {type: string}}) | any, errors: string[], context: string): any { + const special = input["special"] + if(special === undefined){ + return input + } + + for (const wrongKey of Object.keys(input).filter(k => k !== "special")) { + errors.push(`At ${context}: Unexpected key in a special block: ${wrongKey}`) + } + + const type = special["type"] + if(type === undefined){ + errors.push("A 'special'-block should define 'type' to indicate which visualisation should be used") + return undefined + } + const vis = SpecialVisualizations.specialVisualizations.find(sp => sp.funcName === type) + if(vis === undefined){ + const options = Utils.sortedByLevenshteinDistance(type, SpecialVisualizations.specialVisualizations, sp => sp.funcName) + errors.push(`Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md`) + return undefined + } + + const argNamesList = vis.args.map(a => a.name) + const argNames = new Set(argNamesList) + // Check for obsolete and misspelled arguments + errors.push(...Object.keys(special) + .filter(k => !argNames.has(k)) + .filter(k => k !== "type") + .map(wrongArg => { + const byDistance = Utils.sortedByLevenshteinDistance(wrongArg, argNamesList, x => x) + return `Unexpected argument with name '${wrongArg}'. Did you mean ${byDistance[0]}?\n\tAll known arguments are ${ argNamesList.join(", ")}` ; + })) + + // Check that all obligated arguments are present. They are obligated if they don't have a preset value + for (const arg of vis.args) { + if (arg.required !== true) { + continue; + } + const param = special[arg.name] + if(param === undefined){ + errors.push(`Obligated parameter '${arg.name}' not found`) + } + } + + const foundLanguages = new Set() + const translatedArgs = argNamesList.map(nm => special[nm]) + .filter(v => v !== undefined) + .filter(v => Translations.isProbablyATranslation(v)) + for (const translatedArg of translatedArgs) { + for (const ln of Object.keys(translatedArg)) { + foundLanguages.add(ln) + } + } + + if(foundLanguages.size === 0){ + const args= argNamesList.map(nm => special[nm] ?? "").join(",") + return {'*': `{${type}(${args})}` + } + } + + const result = {} + const languages = Array.from(foundLanguages) + languages.sort() + for (const ln of languages) { + const args = [] + for (const argName of argNamesList) { + const v = special[argName] ?? "" + if(Translations.isProbablyATranslation(v)){ + args.push(new Translation(v).textFor(ln)) + }else{ + args.push(v) + } + } + result[ln] = `{${type}(${args.join(",")})}` + } + return result } - + /** + * const tr = { + * render: {special: {type: "image_carousel", image_key: "image" }}, + * mappings: [ + * { + * if: "other_image_key", + * then: {special: {type: "image_carousel", image_key: "other_image_key"}} + * } + * ] + * } + * const result = new RewriteSpecial().convert(tr,"test").result + * const expected = {render: {'*': "{image_carousel(image)}"}, mappings: [{if: "other_image_key", then: {'*': "{image_carousel(other_image_key)}"}} ]} + * result // => expected + */ + convert(json: TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson; errors?: string[]; warnings?: string[]; information?: string[] } { + const errors = [] + json = Utils.Clone(json) + const paths : {path: string[], type?: any, typeHint?: string}[] = tagrenderingconfigmeta["default"] ?? tagrenderingconfigmeta + for (const path of paths) { + if(path.typeHint !== "rendered"){ + continue + } + Utils.WalkPath(path.path, json, ((leaf, travelled) => RewriteSpecial.convertIfNeeded(leaf, errors, travelled.join(".")))) + } + + return { + result:json, + errors + }; + } + } export class PrepareLayer extends Fuse { - - constructor(state: DesugaringContext) { super( "Fully prepares and expands a layer for the LayerConfig.", + new OnEvery("tagRenderings", new RewriteSpecial(), {ignoreIfUndefined: true}), new OnEveryConcat("tagRenderings", new ExpandGroupRewrite(state)), new OnEveryConcat("tagRenderings", new ExpandTagRendering(state)), new OnEveryConcat("mapRendering", new ExpandRewrite()), diff --git a/Models/ThemeConfig/Json/LayerConfigJson.ts b/Models/ThemeConfig/Json/LayerConfigJson.ts index 9bc71e38f..0839d7ec7 100644 --- a/Models/ThemeConfig/Json/LayerConfigJson.ts +++ b/Models/ThemeConfig/Json/LayerConfigJson.ts @@ -234,7 +234,7 @@ export interface LayerConfigJson { /** * The type of background picture */ - preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string | string [], + preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string | string[], /** * If specified, these layers will be shown to and the new point will be snapped towards it */ diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index 405cf59be..8a080f00f 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -197,14 +197,14 @@ export default class LayerConfig extends WithContextLoader { snapToLayers = pr.preciseInput.snapToLayer } - let preferredBackground: string[] + let preferredBackground: ("map" | "photo" | "osmbasedmap" | "historicphoto" | string)[] if (typeof pr.preciseInput.preferredBackground === "string") { preferredBackground = [pr.preciseInput.preferredBackground] } else { preferredBackground = pr.preciseInput.preferredBackground } preciseInput = { - preferredBackground: preferredBackground, + preferredBackground, snapToLayers, maxSnapDistance: pr.preciseInput.maxSnapDistance ?? 10 } diff --git a/Models/ThemeConfig/PresetConfig.ts b/Models/ThemeConfig/PresetConfig.ts index 4522d9079..a9e68050c 100644 --- a/Models/ThemeConfig/PresetConfig.ts +++ b/Models/ThemeConfig/PresetConfig.ts @@ -2,7 +2,7 @@ import {Translation} from "../../UI/i18n/Translation"; import {Tag} from "../../Logic/Tags/Tag"; export interface PreciseInput { - preferredBackground?: string[], + preferredBackground?: ("map" | "photo" | "osmbasedmap" | "historicphoto" | string)[], snapToLayers?: string[], maxSnapDistance?: number } diff --git a/UI/Base/Table.ts b/UI/Base/Table.ts index a26f4a025..9ffa4317c 100644 --- a/UI/Base/Table.ts +++ b/UI/Base/Table.ts @@ -1,20 +1,26 @@ import BaseUIElement from "../BaseUIElement"; import {Utils} from "../../Utils"; import Translations from "../i18n/Translations"; +import {UIEventSource} from "../../Logic/UIEventSource"; export default class Table extends BaseUIElement { private readonly _header: BaseUIElement[]; private readonly _contents: BaseUIElement[][]; private readonly _contentStyle: string[][]; - + private readonly _sortable: boolean; + constructor(header: (BaseUIElement | string)[], contents: (BaseUIElement | string)[][], - contentStyle?: string[][]) { + options?: { + contentStyle?: string[][], + sortable?: false | boolean + }) { super(); - this._contentStyle = contentStyle ?? [["min-width: 9rem"]]; + this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]]; this._header = header?.map(Translations.W); this._contents = contents.map(row => row.map(Translations.W)); + this._sortable = options?.sortable ?? false } AsMarkdown(): string { @@ -30,7 +36,25 @@ export default class Table extends BaseUIElement { protected InnerConstructElement(): HTMLElement { const table = document.createElement("table") - const headerElems = Utils.NoNull((this._header ?? []).map(elems => elems.ConstructElement())) + /** + * Sortmode: i: sort column i ascending; + * if i is negative : sort column (-i - 1) descending + */ + const sortmode = new UIEventSource(undefined); + const self = this; + const headerElems = Utils.NoNull((this._header ?? []).map((elem, i) => { + if(self._sortable){ + elem.onClick(() => { + const current = sortmode.data + if(current == i){ + sortmode.setData(- 1 - i ) + }else{ + sortmode.setData(i) + } + }) + } + return elem.ConstructElement(); + })) if (headerElems.length > 0) { const thead = document.createElement("thead") @@ -73,6 +97,31 @@ export default class Table extends BaseUIElement { } table.appendChild(tr) } + + sortmode.addCallback(sortCol => { + if(sortCol === undefined){ + return + } + const descending = sortCol < 0 + const col = descending ? - sortCol - 1: sortCol; + let rows: HTMLTableRowElement[] = Array.from(table.rows) + rows.splice(0,1) // remove header row + rows = rows.sort((a, b) => { + const ac = a.cells[col]?.innerText?.toLowerCase() + const bc = b.cells[col]?.innerText?.toLowerCase() + if(ac === bc){ + return 0 + } + return( ac < bc !== descending) ? -1 : 1; + }) + for (let j = rows.length ; j > 1; j--) { + table.deleteRow(j) + } + for (const row of rows) { + table.appendChild(row) + } + }) + return table; } diff --git a/UI/BigComponents/Histogram.ts b/UI/BigComponents/Histogram.ts index d786fbf5d..ba1387ddd 100644 --- a/UI/BigComponents/Histogram.ts +++ b/UI/BigComponents/Histogram.ts @@ -128,8 +128,9 @@ export default class Histogram extends VariableUiElement { .SetStyle(`background: ${actualAssignColor(key)}; width: ${100 * counts.get(key) / max}%`) ]).SetClass("block w-full") - ]), - keys.map(_ => ["width: 20%"]) + ]),{ + contentStyle:keys.map(_ => ["width: 20%"]) + } ).SetClass("w-full zebra-table"); }, [sortMode])); } diff --git a/UI/ImportFlow/ImportViewerGui.ts b/UI/ImportFlow/ImportViewerGui.ts index f0af1f059..2c427bd9c 100644 --- a/UI/ImportFlow/ImportViewerGui.ts +++ b/UI/ImportFlow/ImportViewerGui.ts @@ -139,16 +139,59 @@ class MassAction extends Combine { } +class NoteTable extends Combine { + + constructor(noteStates: NoteState[], state?: UserRelatedState) { + const typicalComment = noteStates[0].props.comments[0].html + + const table = new Table( + ["id", "status", "last comment", "last modified by"], + noteStates.map(ns => { + const link = new Link( + "" + ns.props.id, + "https://openstreetmap.org/note/" + ns.props.id, true + ) + let last_comment = ""; + const last_comment_props = ns.props.comments[ns.props.comments.length - 1] + const before_last_comment = ns.props.comments[ns.props.comments.length - 2] + if (ns.props.comments.length > 1) { + last_comment = last_comment_props.text + if (last_comment === undefined && before_last_comment?.uid === last_comment_props.uid) { + last_comment = before_last_comment.text + } + } + const statusIcon = BatchView.icons[ns.status]().SetClass("h-4 w-4 shrink-0") + return [link, new Combine([statusIcon, ns.status]).SetClass("flex"), last_comment, + new Link(last_comment_props.user, "https://www.openstreetmap.org/user/" + last_comment_props.user, true) + ] + }), + {sortable: true} + ).SetClass("zebra-table link-underline"); + + + super([ + new Title("Mass apply an action on " + noteStates.length + " notes below"), + state !== undefined ? new MassAction(state, noteStates.map(ns => ns.props)).SetClass("block") : undefined, + table, + new Title("Example note", 4), + new FixedUiElement(typicalComment).SetClass("literal-code link-underline"), + + ]) + this.SetClass("flex flex-col") + } + +} + class BatchView extends Toggleable { - private static icons = { + public static icons = { open: Svg.compass_svg, has_comments: Svg.speech_bubble_svg, imported: Svg.addSmall_svg, already_mapped: Svg.checkmark_svg, - invalid: Svg.invalid_svg, - closed: Svg.close_svg, not_found: Svg.not_found_svg, + closed: Svg.close_svg, + invalid: Svg.invalid_svg, } constructor(noteStates: NoteState[], state?: UserRelatedState) { @@ -164,14 +207,44 @@ class BatchView extends Toggleable { statusHist.set(st, c + 1) } - const badges: (BaseUIElement)[] = [new FixedUiElement(dateStr).SetClass("literal-code rounded-full")] - statusHist.forEach((count, status) => { - const icon = BatchView.icons[status]().SetClass("h-6 m-1") - badges.push(new Combine([icon, count + " " + status]) - .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black")) + const unresolvedTotal = (statusHist.get("open") ?? 0) + (statusHist.get("has_comments") ?? 0) + const badges: (BaseUIElement)[] = [ + new FixedUiElement(dateStr).SetClass("literal-code rounded-full"), + new FixedUiElement(noteStates.length + " total").SetClass("literal-code rounded-full ml-1 border-4 border-gray") + .onClick(() => filterOn.setData(undefined)), + unresolvedTotal === 0 ? + new Combine([Svg.party_svg().SetClass("h-6 m-1"), "All done!"]) + .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black") : + new FixedUiElement(Math.round(100 - 100 * unresolvedTotal / noteStates.length) + "%").SetClass("literal-code rounded-full ml-1") + ] + + const filterOn = new UIEventSource(undefined) + Object.keys(BatchView.icons).forEach(status => { + const count = statusHist.get(status) + if (count === undefined) { + return undefined + } + + const normal = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status]) + .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black") + const selected = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status]) + .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border-4 border-black animate-pulse") + + const toggle = new Toggle(selected, normal, filterOn.map(f => f === status, [], (selected, previous) => { + if (selected) { + return status; + } + if (previous === status) { + return undefined + } + return previous + })).ToggleOnClick() + + badges.push(toggle) }) - const typicalComment = noteStates[0].props.comments[0].html + + const fullTable = new NoteTable(noteStates, state); super( @@ -179,27 +252,12 @@ class BatchView extends Toggleable { new Title(theme + ": " + intro, 2), new Combine(badges).SetClass("flex flex-wrap"), ]), - new Combine([ - new Title("Example note", 4), - new FixedUiElement(typicalComment).SetClass("literal-code link-underline"), - new Title("Mass apply an action"), - state !== undefined ? new MassAction(state, noteStates.map(ns => ns.props)).SetClass("block") : undefined, - new Table( - ["id", "status", "last comment"], - noteStates.map(ns => { - const link = new Link( - "" + ns.props.id, - "https://openstreetmap.org/note/" + ns.props.id, true - ) - let last_comment = ""; - if (ns.props.comments.length > 1) { - last_comment = ns.props.comments[ns.props.comments.length - 1].text - } - const statusIcon = BatchView.icons[ns.status]().SetClass("h-4 w-4 shrink-0") - return [link, new Combine([statusIcon, ns.status]).SetClass("flex"), last_comment] - }) - ).SetClass("zebra-table link-underline") - ]).SetClass("flex flex-col"), + new VariableUiElement(filterOn.map(filter => { + if (filter === undefined) { + return fullTable + } + return new NoteTable(noteStates.filter(ns => ns.status === filter), state) + })), { closeOnClick: false }) diff --git a/UI/OpeningHours/OpeningHoursVisualization.ts b/UI/OpeningHours/OpeningHoursVisualization.ts index 41744a489..b3e94c7ae 100644 --- a/UI/OpeningHours/OpeningHoursVisualization.ts +++ b/UI/OpeningHours/OpeningHoursVisualization.ts @@ -168,7 +168,7 @@ export default class OpeningHoursVisualization extends Toggle { } return new Table(undefined, [[" ", header], ...weekdays], - [["width: 5%", `position: relative; height: ${headerHeight}`], ...weekdayStyles] + {contentStyle: [["width: 5%", `position: relative; height: ${headerHeight}`], ...weekdayStyles]} ).SetClass("w-full") .SetStyle("border-collapse: collapse; word-break; word-break: normal; word-wrap: normal") diff --git a/UI/Popup/AutoApplyButton.ts b/UI/Popup/AutoApplyButton.ts index 27c1d2a48..0956a56c5 100644 --- a/UI/Popup/AutoApplyButton.ts +++ b/UI/Popup/AutoApplyButton.ts @@ -156,22 +156,26 @@ class ApplyButton extends UIElement { export default class AutoApplyButton implements SpecialVisualization { public readonly docs: string; public readonly funcName: string = "auto_apply"; - public readonly args: { name: string; defaultValue?: string; doc: string }[] = [ + public readonly args: { name: string; defaultValue?: string; doc: string, required?: boolean }[] = [ { name: "target_layer", - doc: "The layer that the target features will reside in" + doc: "The layer that the target features will reside in", + required: true }, { name: "target_feature_ids", - doc: "The key, of which the value contains a list of ids" + doc: "The key, of which the value contains a list of ids", + required: true }, { name: "tag_rendering_id", - doc: "The ID of the tagRendering containing the autoAction. This tagrendering will be calculated. The embedded actions will be executed" + doc: "The ID of the tagRendering containing the autoAction. This tagrendering will be calculated. The embedded actions will be executed", + required: true }, { name: "text", - doc: "The text to show on the button" + doc: "The text to show on the button", + required: true }, { name: "icon", diff --git a/UI/Popup/ImportButton.ts b/UI/Popup/ImportButton.ts index c8a50ba42..4108c4e24 100644 --- a/UI/Popup/ImportButton.ts +++ b/UI/Popup/ImportButton.ts @@ -42,6 +42,7 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import {Changes} from "../../Logic/Osm/Changes"; import {ElementStorage} from "../../Logic/ElementStorage"; import Hash from "../../Logic/Web/Hash"; +import {PreciseInput} from "../../Models/ThemeConfig/PresetConfig"; /** * A helper class for the various import-flows. @@ -54,7 +55,7 @@ abstract class AbstractImportButton implements SpecialVisualizations { public readonly args: { name: string, defaultValue?: string, doc: string }[] private readonly showRemovedTags: boolean; - constructor(funcName: string, docsIntro: string, extraArgs: { name: string, doc: string, defaultValue?: string }[], showRemovedTags = true) { + constructor(funcName: string, docsIntro: string, extraArgs: { name: string, doc: string, defaultValue?: string, required?: boolean }[], showRemovedTags = true) { this.funcName = funcName this.showRemovedTags = showRemovedTags; @@ -73,11 +74,13 @@ ${Utils.special_visualizations_importRequirementDocs} this.args = [ { name: "targetLayer", - doc: "The id of the layer where this point should end up. This is not very strict, it will simply result in checking that this layer is shown preventing possible duplicate elements" + doc: "The id of the layer where this point should end up. This is not very strict, it will simply result in checking that this layer is shown preventing possible duplicate elements", + required: true }, { name: "tags", - doc: "The tags to add onto the new object - see specification above. If this is a key (a single word occuring in the properties of the object), the corresponding value is taken and expanded instead" + doc: "The tags to add onto the new object - see specification above. If this is a key (a single word occuring in the properties of the object), the corresponding value is taken and expanded instead", + required: true }, { name: "text", @@ -394,6 +397,43 @@ export class ImportWayButton extends AbstractImportButton implements AutoAction ) } + private static CreateAction(feature, + args: { max_snap_distance: string; snap_onto_layers: string; icon: string; text: string; tags: string; newTags: UIEventSource; targetLayer: string }, + state: FeaturePipelineState, + mergeConfigs: any[]) { + const coors = feature.geometry.coordinates + if ((feature.geometry.type === "Polygon") && coors.length > 1) { + const outer = coors[0] + const inner = [...coors] + inner.splice(0, 1) + return new CreateMultiPolygonWithPointReuseAction( + args.newTags.data, + outer, + inner, + state, + mergeConfigs, + "import" + ) + } else if (feature.geometry.type === "Polygon") { + const outer = coors[0] + return new CreateWayWithPointReuseAction( + args.newTags.data, + outer, + state, + mergeConfigs + ) + } else if (feature.geometry.type === "LineString") { + return new CreateWayWithPointReuseAction( + args.newTags.data, + coors, + state, + mergeConfigs + ) + } else { + throw "Unsupported type" + } + } + async applyActionOn(state: { layoutToUse: LayoutConfig; changes: Changes, allElements: ElementStorage }, originalFeatureTags: UIEventSource, argument: string[]): Promise { @@ -484,43 +524,6 @@ export class ImportWayButton extends AbstractImportButton implements AutoAction return mergeConfigs; } - - private static CreateAction(feature, - args: { max_snap_distance: string; snap_onto_layers: string; icon: string; text: string; tags: string; newTags: UIEventSource; targetLayer: string }, - state: FeaturePipelineState, - mergeConfigs: any[]) { - const coors = feature.geometry.coordinates - if ((feature.geometry.type === "Polygon" ) && coors.length > 1) { - const outer = coors[0] - const inner = [...coors] - inner.splice(0, 1) - return new CreateMultiPolygonWithPointReuseAction( - args.newTags.data, - outer, - inner, - state, - mergeConfigs, - "import" - ) - } else if(feature.geometry.type === "Polygon"){ - const outer = coors[0] - return new CreateWayWithPointReuseAction( - args.newTags.data, - outer, - state, - mergeConfigs - ) - }else if(feature.geometry.type === "LineString"){ - return new CreateWayWithPointReuseAction( - args.newTags.data, - coors, - state, - mergeConfigs - ) - }else{ - throw "Unsupported type" - } - } } export class ImportPointButton extends AbstractImportButton { @@ -528,18 +531,23 @@ export class ImportPointButton extends AbstractImportButton { constructor() { super("import_button", "This button will copy the point from an external dataset into OpenStreetMap", - [{ - name: "snap_onto_layers", - doc: "If a way of the given layer(s) is closeby, will snap the new point onto this way (similar as preset might snap). To show multiple layers to snap onto, use a `;`-seperated list" - }, + [ + { + name: "snap_onto_layers", + doc: "If a way of the given layer(s) is closeby, will snap the new point onto this way (similar as preset might snap). To show multiple layers to snap onto, use a `;`-seperated list" + }, { name: "max_snap_distance", doc: "The maximum distance that the imported point will be moved to snap onto a way in an already existing layer (in meters). This is previewed to the contributor, similar to the 'add new point'-action of MapComplete", defaultValue: "5" - }, { - name: "note_id", - doc: "If given, this key will be read. The corresponding note on OSM will be closed, stating 'imported'" - }], + }, + { + name: "note_id", + doc: "If given, this key will be read. The corresponding note on OSM will be closed, stating 'imported'" + }, + {name:"location_picker", + defaultValue: "photo", + doc: "Chooses the background for the precise location picker, options are 'map', 'photo' or 'osmbasedmap' or 'none' if the precise input picker should be disabled"}], false ) } @@ -581,7 +589,7 @@ export class ImportPointButton extends AbstractImportButton { newElementAction.newElementId )) Hash.hash.setData(newElementAction.newElementId) - + if (note_id !== undefined) { state.osmConnection.closeNote(note_id, "imported") originalFeatureTags.data["closed_at"] = new Date().toISOString() @@ -589,16 +597,24 @@ export class ImportPointButton extends AbstractImportButton { } } + let preciseInputOption = args["location_picker"] + let preciseInputSpec: PreciseInput = undefined + console.log("Precise input location is ", preciseInputOption) + if(preciseInputOption !== "none") { + preciseInputSpec = { + snapToLayers: args.snap_onto_layers?.split(";"), + maxSnapDistance: Number(args.max_snap_distance), + preferredBackground: args["location_picker"] ?? ["photo", "map"] + } + } + const presetInfo = { tags: args.newTags.data, icon: () => new Img(args.icon), layerToAddTo: state.filteredLayers.data.filter(l => l.layerDef.id === args.targetLayer)[0], name: args.text, title: Translations.WT(args.text), - preciseInput: { - snapToLayers: args.snap_onto_layers?.split(";"), - maxSnapDistance: Number(args.max_snap_distance) - }, + preciseInput: preciseInputSpec, // must be explicitely assigned, if 'undefined' won't work otherwise boundsFactor: 3 } diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 68f9e75dc..92ee0dd11 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -52,7 +52,7 @@ export interface SpecialVisualization { constr: ((state: FeaturePipelineState, tagSource: UIEventSource, argument: string[], guistate: DefaultGuiState,) => BaseUIElement), docs: string, example?: string, - args: { name: string, defaultValue?: string, doc: string }[], + args: { name: string, defaultValue?: string, doc: string, required?: false | boolean }[], getLayerDependencies?: (argument: string[]) => string[] } @@ -102,6 +102,7 @@ class CloseNoteButton implements SpecialVisualization { { name: "text", doc: "Text to show on this button", + required: true }, { name: "icon", @@ -179,7 +180,7 @@ class CloseNoteButton implements SpecialVisualization { export default class SpecialVisualizations { - public static specialVisualizations = SpecialVisualizations.init() + public static specialVisualizations : SpecialVisualization[] = SpecialVisualizations.init() public static HelpMessage() { @@ -206,9 +207,28 @@ export default class SpecialVisualizations { )); return new Combine([ + new Combine([ + new Title("Special tag renderings", 1), + "In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.", "General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_ need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args", + new Title("Using expanded syntax",4), + `Instead of using \`{"render": {"en": "{some_special_visualisation(some_arg, some other really long message, more args)} , "nl": "{some_special_visualisation(some_arg, een boodschap in een andere taal, more args)}}, one can also write`, + new FixedUiElement(JSON.stringify({ + render: { + special:{ + type: "some_special_visualisation", + "argname": "some_arg", + "message":{ + en:"some other really long message", + nl: "een boodschap in een andere taal" + }, + "other_arg_name":"more args" + } + } + })).SetClass("code") + ]).SetClass("flex flex-col"), ...helpTexts ] ).SetClass("flex flex-col"); @@ -227,9 +247,9 @@ export default class SpecialVisualizations { funcName: "image_carousel", docs: "Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)", args: [{ - name: "image key/prefix (multiple values allowed if comma-seperated)", + name: "image_key", defaultValue: AllImageProviders.defaultKeys.join(","), - doc: "The keys given to the images, e.g. if image is given, the first picture URL will be added as image, the second as image:0, the third as image:1, etc... " + doc: "The keys given to the images, e.g. if image is given, the first picture URL will be added as image, the second as image:0, the third as image:1, etc... Multiple values are allowed if ';'-separated " }], constr: (state, tags, args) => { let imagePrefixes: string[] = undefined; @@ -368,6 +388,7 @@ export default class SpecialVisualizations { { doc: "The side to show, either `left` or `right`", name: "side", + required: true } ], example: "`{sided_minimap(left)}`", @@ -461,12 +482,15 @@ export default class SpecialVisualizations { docs: "Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed_value)}", example: "{live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)}", args: [{ - name: "Url", doc: "The URL to load" + name: "Url", + doc: "The URL to load", + required: true }, { name: "Shorthands", doc: "A list of shorthands, of the format 'shorthandname:path.path.path'. separated by ;" }, { - name: "path", doc: "The path (or shorthand) that should be returned" + name: "path", + doc: "The path (or shorthand) that should be returned" }], constr: (state, tagSource: UIEventSource, args) => { const url = args[0]; @@ -483,7 +507,8 @@ export default class SpecialVisualizations { args: [ { name: "key", - doc: "The key to be read and to generate a histogram from" + doc: "The key to be read and to generate a histogram from", + required: true }, { name: "title", @@ -588,7 +613,8 @@ export default class SpecialVisualizations { example: "{canonical(length)} will give 42 metre (in french)", args: [{ name: "key", - doc: "The key of the tag to give the canonical text for" + doc: "The key of the tag to give the canonical text for", + required: true }], constr: (state, tagSource, args) => { const key = args [0] @@ -618,16 +644,19 @@ export default class SpecialVisualizations { {name: "feature_ids", doc: "A JSON-serialized list of IDs of features to apply the tagging on"}, { name: "keys", - doc: "One key (or multiple keys, seperated by ';') of the attribute that should be copied onto the other features." + doc: "One key (or multiple keys, seperated by ';') of the attribute that should be copied onto the other features.", + required: true }, {name: "text", doc: "The text to show on the button"}, { name: "autoapply", - doc: "A boolean indicating wether this tagging should be applied automatically if the relevant tags on this object are changed. A visual element indicating the multi_apply is still shown" + doc: "A boolean indicating wether this tagging should be applied automatically if the relevant tags on this object are changed. A visual element indicating the multi_apply is still shown", + required: true }, { name: "overwrite", - doc: "If set to 'true', the tags on the other objects will always be overwritten. The default behaviour will be to only change the tags on other objects if they are either undefined or had the same value before the change" + doc: "If set to 'true', the tags on the other objects will always be overwritten. The default behaviour will be to only change the tags on other objects if they are either undefined or had the same value before the change", + required: true } ], example: "{multi_apply(_features_with_the_same_name_within_100m, name:etymology:wikidata;name:etymology, Apply etymology information on all nearby objects with the same name)}", @@ -918,7 +947,7 @@ export default class SpecialVisualizations { ] specialVisualizations.push(new AutoApplyButton(specialVisualizations)) - + return specialVisualizations; } diff --git a/Utils.ts b/Utils.ts index 43f891c0e..0a993b06c 100644 --- a/Utils.ts +++ b/Utils.ts @@ -407,7 +407,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be * * If a list is encountered, this is tranparently walked recursively on every object. * - * The leaf objects are replaced by the function + * The leaf objects are replaced in the object itself by the specified function */ public static WalkPath(path: string[], object: any, replaceLeaf: ((leaf: any, travelledPath: string[]) => any), travelledPath: string[] = []) { const head = path[0] @@ -793,6 +793,12 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be } return new Date(str) } + + public static sortedByLevenshteinDistance(reference: string, ts: T[], getName: (t:T) => string): T[]{ + const withDistance: [T, number][] = ts.map(t => [t, Utils.levenshteinDistance(getName(t), reference)]) + withDistance.sort(([_, a], [__, b]) => a - b) + return withDistance.map(n => n[0]) + } public static levenshteinDistance(str1: string, str2: string) { const track = Array(str2.length + 1).fill(null).map(() => diff --git a/assets/layers/note/note.json b/assets/layers/note/note.json index c2e978a80..f4342ff90 100644 --- a/assets/layers/note/note.json +++ b/assets/layers/note/note.json @@ -85,7 +85,7 @@ "iconBadges": [ { "if": "_total_comments>1", - "then": "speech_bubble" + "then": "circle:white;speech_bubble" } ] } diff --git a/assets/svg/license_info.json b/assets/svg/license_info.json index c886034d3..530adea9a 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -915,6 +915,16 @@ "https://www.OpenStreetMap.org" ] }, + { + "path": "party.svg", + "license": "CC-BY 4.0", + "authors": [ + "Twemoji" + ], + "sources": [ + "https://github.com/twitter/twemoji" + ] + }, { "path": "payment_card.svg", "license": "CC0", diff --git a/assets/svg/party.svg b/assets/svg/party.svg new file mode 100644 index 000000000..a4b8305af --- /dev/null +++ b/assets/svg/party.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/themes/toerisme_vlaanderen/license_info.json b/assets/themes/toerisme_vlaanderen/license_info.json index 0013ceba5..feefa8298 100644 --- a/assets/themes/toerisme_vlaanderen/license_info.json +++ b/assets/themes/toerisme_vlaanderen/license_info.json @@ -144,6 +144,72 @@ "https://mapcomplete.osm.be/toerisme_vlaanderen" ] }, + { + "path": "pin je punt1.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, + { + "path": "pin je punt2.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, + { + "path": "pin je punt3.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, + { + "path": "pin je punt4.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, + { + "path": "pin je punt5.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, + { + "path": "pin je punt6.jpg", + "license": "CC0", + "authors": [ + "Toerisme Vlaanderen" + ], + "sources": [ + "https://toerismevlaanderen.be/pinjepunt", + "https://mapcomplete.osm.be/toerisme_vlaanderenn" + ] + }, { "path": "playground.svg", "license": "CC0", diff --git a/assets/themes/uk_addresses/uk_addresses.json b/assets/themes/uk_addresses/uk_addresses.json index 75a10030c..c7d1cc552 100644 --- a/assets/themes/uk_addresses/uk_addresses.json +++ b/assets/themes/uk_addresses/uk_addresses.json @@ -123,7 +123,16 @@ }, { "id": "uk_addresses_import_button", - "render": "{import_button(address,urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$, Add this address, ./assets/themes/uk_addresses/housenumber_add.svg)}" + "render":{ + "special": { + "type": "import_button", + "targetLayer": "address", + "tags": "urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$", + "text": "Add this address", + "icon": "./assets/themes/uk_addresses/housenumber_add.svg", + "location_picker": "none" + } + } } ], "calculatedTags": [ diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index d44c18b2d..db8ca3cc6 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -706,6 +706,7 @@ video { } .sticky { + position: -webkit-sticky; position: sticky; } @@ -1504,10 +1505,6 @@ video { padding: 0.125rem; } -.p-8 { - padding: 2rem; -} - .px-0 { padding-left: 0px; padding-right: 0px; diff --git a/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts b/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts index b87a683cd..b94e925a1 100644 --- a/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts +++ b/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts @@ -1,21 +1,15 @@ import {describe} from 'mocha' import {expect} from 'chai' -import {LayoutConfigJson} from "../../../../Models/ThemeConfig/Json/LayoutConfigJson"; import {LayerConfigJson} from "../../../../Models/ThemeConfig/Json/LayerConfigJson"; -import {PrepareTheme} from "../../../../Models/ThemeConfig/Conversion/PrepareTheme"; import {TagRenderingConfigJson} from "../../../../Models/ThemeConfig/Json/TagRenderingConfigJson"; -import LayoutConfig from "../../../../Models/ThemeConfig/LayoutConfig"; -import * as bookcaseLayer from "../../../../assets/generated/layers/public_bookcase.json" -import LayerConfig from "../../../../Models/ThemeConfig/LayerConfig"; -import {ExtractImages} from "../../../../Models/ThemeConfig/Conversion/FixImages"; -import * as cyclofix from "../../../../assets/generated/themes/cyclofix.json" import LineRenderingConfigJson from "../../../../Models/ThemeConfig/Json/LineRenderingConfigJson"; -import {PrepareLayer} from "../../../../Models/ThemeConfig/Conversion/PrepareLayer"; - - +import {PrepareLayer, RewriteSpecial} from "../../../../Models/ThemeConfig/Conversion/PrepareLayer"; +import { + QuestionableTagRenderingConfigJson +} from "../../../../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"; describe("PrepareLayer", () => { - + it("should expand mappings in map renderings", () => { const exampleLayer: LayerConfigJson = { id: "testlayer", @@ -66,10 +60,12 @@ describe("PrepareLayer", () => { "if": "parking:condition:left=free", "then": "#299921" }, - {"if": "parking:condition:left=disc", - "then": "#219991"}] + { + "if": "parking:condition:left=disc", + "then": "#219991" + }] }, - "offset": -6 + "offset": -6 }, { "color": { "render": "#888", @@ -77,8 +73,10 @@ describe("PrepareLayer", () => { "if": "parking:condition:right=free", "then": "#299921" }, - {"if": "parking:condition:right=disc", - "then": "#219991"}] + { + "if": "parking:condition:right=disc", + "then": "#219991" + }] }, "offset": 6 }], @@ -91,3 +89,26 @@ describe("PrepareLayer", () => { ) }) +describe('RewriteSpecial', function () { + it("should rewrite the UK import button", () => { + const tr = { + "id": "uk_addresses_import_button", + "render": { + "special": { + "type": "import_button", + "targetLayer": "address", + "tags": "urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$", + "text": "Add this address", + "icon": "./assets/themes/uk_addresses/housenumber_add.svg", + "location_picker": "none" + } + } + } + const r = new RewriteSpecial().convert(tr, "test").result + expect(r).to.deep.eq({ + "id": "uk_addresses_import_button", + "render": {'*': "{import_button(address,urpn_count=$urpn_count;ref:GB:uprn=$ref:GB:uprn$,Add this address,./assets/themes/uk_addresses/housenumber_add.svg,,,,none)}"} + }) + }) +}); + diff --git a/tests/UI/SpecialVisualisations.spec.ts b/tests/UI/SpecialVisualisations.spec.ts new file mode 100644 index 000000000..b68339309 --- /dev/null +++ b/tests/UI/SpecialVisualisations.spec.ts @@ -0,0 +1,19 @@ +import {describe} from 'mocha' +import SpecialVisualizations from "../../UI/SpecialVisualizations"; +import {expect} from "chai"; + +describe("SpecialVisualisations", () => { + + describe("predifined special visualisations", () => { + it("should not have an argument called 'type'", () => { + const specials = SpecialVisualizations.specialVisualizations + for (const special of specials) { + expect(special.funcName).not.eq('type', "A special visualisation is not allowed to be named 'type', as this will conflict with the 'special'-blocks") + for (const arg of special.args) { + expect(arg.name).not.eq('type', "An argument is not allowed to be called 'type', as this will conflict with the 'special'-blocks") + } + + } + }) + }) +}) \ No newline at end of file