Add rewrite of 'special' clauses, various QOLimprovements on import viewer

This commit is contained in:
Pieter Vander Vennet 2022-03-29 00:20:10 +02:00
parent 8df0324572
commit c47a6d5ea7
22 changed files with 597 additions and 155 deletions

View file

@ -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<number>(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;
}

View file

@ -128,8 +128,9 @@ export default class Histogram<T> 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]));
}

View file

@ -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<string>(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
})

View file

@ -168,7 +168,7 @@ export default class OpeningHoursVisualization extends Toggle {
}
return new Table(undefined,
[["&nbsp", 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")

View file

@ -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",

View file

@ -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<any>; 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<any>,
argument: string[]): Promise<void> {
@ -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<any>; 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 = <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
}

View file

@ -52,7 +52,7 @@ export interface SpecialVisualization {
constr: ((state: FeaturePipelineState, tagSource: UIEventSource<any>, 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 <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... "
doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, 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<any>, 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;
}