Fix: maproulette import flow

This commit is contained in:
Pieter Vander Vennet 2023-06-09 16:13:35 +02:00
parent f80054558f
commit 5f7cc351c9
18 changed files with 331 additions and 114 deletions

View file

@ -254,6 +254,7 @@ class ClosestNObjectFunc implements ExtraFunction {
const maxDistance = options?.maxDistance ?? 500
const uniqueTag: string | undefined = options?.uniqueTag
let allFeatures: Feature[][]
console.log("Calculating closest", options?.maxFeatures, "features around", feature, "in layer", features)
if (typeof features === "string") {
const name = features
const bbox = GeoOperations.bbox(
@ -414,7 +415,7 @@ class GetParsed implements ExtraFunction {
if (value === undefined) {
return undefined
}
if(typeof value !== "string"){
if (typeof value !== "string") {
return value
}
try {

View file

@ -105,6 +105,10 @@ export default class GeoJsonSource implements FeatureSource {
let i = 0
let skipped = 0
for (const feature of json.features) {
if(feature.geometry.type === "Point"){
// See https://github.com/maproulette/maproulette-backend/issues/242
feature.geometry.coordinates = feature.geometry.coordinates.map(Number)
}
const props = feature.properties
for (const key in props) {
if (props[key] === null) {

View file

@ -72,4 +72,23 @@ export default class Maproulette {
throw `Failed to close task: ${response.status}`
}
}
/**
* Converts a status text into the corresponding number
*
* Maproulette.codeToIndex("Created") // => 0
* Maproulette.codeToIndex("qdsf") // => undefined
*
*/
public static codeToIndex(code: string) : number | undefined{
if(code === "Created"){
return Maproulette.STATUS_OPEN
}
for (let i = 0; i < 9; i++) {
if(Maproulette.STATUS_MEANING[""+i] === code){
return i
}
}
return undefined
}
}

View file

@ -8,7 +8,6 @@ import {GeoIndexedStoreForLayer} from "./FeatureSource/Actors/GeoIndexedStore"
import {IndexedFeatureSource} from "./FeatureSource/FeatureSource"
import OsmObjectDownloader from "./Osm/OsmObjectDownloader"
import {Utils} from "../Utils";
import {GeoJSONFeature} from "maplibre-gl";
import {UIEventSource} from "./UIEventSource";
/**
@ -206,13 +205,13 @@ export default class MetaTagging {
private static createFunctionForFeature([key, code, isStrict]: [string, string, boolean],
helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>,
layerId: string = "unkown layer"
): ((feature: GeoJSONFeature, propertiesStore?: UIEventSource<any>) => void) | undefined {
): ((feature: Feature, propertiesStore?: UIEventSource<any>) => void) | undefined {
if (code === undefined) {
return undefined
}
const calculateAndAssign: ((feat: GeoJSONFeature, store?: UIEventSource<any>) => string | any) = (feat, store) => {
const calculateAndAssign: ((feat: Feature, store?: UIEventSource<any>) => string | any) = (feat, store) => {
try {
let result = new Function("feat", "{" + ExtraFunctions.types.join(", ") + "}", "return " + code + ";")(feat, helperFunctions)
if (result === "") {
@ -259,7 +258,7 @@ export default class MetaTagging {
if (isStrict) {
return calculateAndAssign
}
return (feature: any, store?: UIEventSource<any>) => {
return (feature: Feature, store?: UIEventSource<any>) => {
delete feature.properties[key]
Utils.AddLazyProperty(feature.properties, key, () => calculateAndAssign(feature, store))
}

View file

@ -132,6 +132,10 @@ class CountryTagger extends SimpleMetaTagger {
CountryTagger.coder
.GetCountryCodeAsync(lon, lat)
.then((countries) => {
if(!countries){
console.warn("Country coder returned ", countries)
return
}
const oldCountry = feature.properties["_country"]
const newCountry = countries[0].trim().toLowerCase()
if (oldCountry !== newCountry) {

View file

@ -538,7 +538,6 @@ export default class TagRenderingConfig {
}
if (
this.id === "questions" ||
this.freeform?.key === undefined ||
tags[this.freeform.key] !== undefined
) {

View file

@ -116,11 +116,12 @@
currentFlowStep = "imported"
dispatch("confirm")
}}>
<span slot="image">
{#if importFlow.args.icon}
<img src={importFlow.args.icon}>
{/if}
<span slot="image" class="w-8 h-8 pr-4">
{#if importFlow.args.icon}
<img src={importFlow.args.icon}>
{:else}
<ToSvelte construct={Svg.confirm_svg().SetClass("w-8 h-8 pr-4")}/>
{/if}
</span>
<slot name="confirm-text">
{importFlow.args.text}

View file

@ -1,6 +1,6 @@
import {SpecialVisualizationState} from "../../SpecialVisualization";
import {Utils} from "../../../Utils";
import {Store, UIEventSource} from "../../../Logic/UIEventSource";
import {ImmutableStore, Store, UIEventSource} from "../../../Logic/UIEventSource";
import {Tag} from "../../../Logic/Tags/Tag";
import TagApplyButton from "../TagApplyButton";
import {PointImportFlowArguments} from "./PointImportFlowState";
@ -11,6 +11,7 @@ import FilteredLayer from "../../../Models/FilteredLayer";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import {LayerConfigJson} from "../../../Models/ThemeConfig/Json/LayerConfigJson";
import conflation_json from "../../../assets/layers/conflation/conflation.json";
import {And} from "../../../Logic/Tags/And";
export interface ImportFlowArguments {
readonly text: string
@ -85,6 +86,15 @@ ${Utils.special_visualizations_importRequirementDocs}
"] of this object, namely ",
items
)
if(items.startsWith("{")){
// This is probably a JSON
const properties: Record<string, string> = JSON.parse(items)
const keys = Object.keys(properties)
const tags = keys.map(k => new Tag(k, properties[k]))
return new ImmutableStore((tags))
}
newTags = TagApplyButton.generateTagsToApply(items, originalFeatureTags)
} else {
newTags = TagApplyButton.generateTagsToApply(tags, originalFeatureTags)

View file

@ -1,22 +1,23 @@
import { AutoAction } from "./AutoApplyButton"
import {AutoAction} from "./AutoApplyButton"
import Translations from "../i18n/Translations"
import { VariableUiElement } from "../Base/VariableUIElement"
import {VariableUiElement} from "../Base/VariableUIElement"
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import {FixedUiElement} from "../Base/FixedUiElement"
import {Store, UIEventSource} from "../../Logic/UIEventSource"
import {SubtleButton} from "../Base/SubtleButton"
import Combine from "../Base/Combine"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { And } from "../../Logic/Tags/And"
import {And} from "../../Logic/Tags/And"
import Toggle from "../Input/Toggle"
import { Utils } from "../../Utils"
import { Tag } from "../../Logic/Tags/Tag"
import {Utils} from "../../Utils"
import {Tag} from "../../Logic/Tags/Tag"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Changes } from "../../Logic/Osm/Changes"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import {Changes} from "../../Logic/Osm/Changes"
import {SpecialVisualization, SpecialVisualizationState} from "../SpecialVisualization"
import {IndexedFeatureSource} from "../../Logic/FeatureSource/FeatureSource";
import {Feature} from "geojson";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import Maproulette from "../../Logic/Maproulette";
export default class TagApplyButton implements AutoAction, SpecialVisualization {
public readonly funcName = "tag_apply"
@ -27,7 +28,7 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
public readonly args = [
{
name: "tags_to_apply",
doc: "A specification of the tags to apply",
doc: "A specification of the tags to apply. This is either hardcoded in the layer or the `$name` of a property containing the tags to apply. If redirected and the value of the linked property starts with `{`, the other property will be interpreted as a json object",
},
{
name: "message",
@ -42,10 +43,62 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
defaultValue: undefined,
doc: "If specified, applies the the tags onto _another_ object. The id will be read from properties[id_of_object_to_apply_this_one] of the selected object. The tags are still calculated based on the tags of the _selected_ element",
},
{
name:"maproulette_task_id",
defaultValue: undefined,
doc: "If specified, this maproulette-challenge will be closed when the tags are applied"
}
]
public readonly example =
"`{tag_apply(survey_date=$_now:date, Surveyed today!)}`, `{tag_apply(addr:street=$addr:street, Apply the address, apply_icon.svg, _closest_osm_id)"
public static generateTagsToApply(
spec: string,
tagSource: Store<Record<string, string>>
): Store<Tag[]> {
// Check whether we need to look up a single value
if (!spec.includes(";") && !spec.includes("=") && spec.startsWith("$")) {
// We seem to be dealing with a single value, fetch it
spec = tagSource.data[spec.replace("$", "")]
}
let tgsSpec: [string, string][]
if (spec.startsWith("{")) {
const properties = JSON.parse(spec)
tgsSpec = []
for (const key of Object.keys(properties)) {
tgsSpec.push([key, properties[key]])
}
} else {
tgsSpec = TagApplyButton.parseTagSpec(spec)
}
return tagSource.map((tags) => {
const newTags: Tag[] = []
for (const [key, value] of tgsSpec) {
if (value.indexOf("$") >= 0) {
let parts = value.split("$")
// THe first of the split won't start with a '$', so no substitution needed
let actualValue = parts[0]
parts.shift()
for (const part of parts) {
const [_, varName, leftOver] = part.match(/([a-zA-Z0-9_:]*)(.*)/)
actualValue += (tags[varName] ?? "") + leftOver
}
newTags.push(new Tag(key, actualValue))
} else {
newTags.push(new Tag(key, value))
}
}
return newTags
})
}
/**
* Parses a tag specification
*
@ -79,41 +132,6 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
return tgsSpec
}
public static generateTagsToApply(
spec: string,
tagSource: Store<Record<string, string>>
): Store<Tag[]> {
// Check whether we need to look up a single value
if (!spec.includes(";") && !spec.includes("=") && spec.includes("$")) {
// We seem to be dealing with a single value, fetch it
spec = tagSource.data[spec.replace("$", "")]
}
const tgsSpec = TagApplyButton.parseTagSpec(spec)
return tagSource.map((tags) => {
const newTags: Tag[] = []
for (const [key, value] of tgsSpec) {
if (value.indexOf("$") >= 0) {
let parts = value.split("$")
// THe first of the split won't start with a '$', so no substitution needed
let actualValue = parts[0]
parts.shift()
for (const part of parts) {
const [_, varName, leftOver] = part.match(/([a-zA-Z0-9_:]*)(.*)/)
actualValue += (tags[varName] ?? "") + leftOver
}
newTags.push(new Tag(key, actualValue))
} else {
newTags.push(new Tag(key, value))
}
}
return newTags
})
}
public async applyActionOn(
feature: Feature,
state: {
@ -123,7 +141,6 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
},
tags: UIEventSource<any>,
args: string[],
): Promise<void> {
const tagsToApply = TagApplyButton.generateTagsToApply(args[0], tags)
const targetIdKey = args[3]
@ -139,6 +156,13 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
}
)
await state.changes.applyAction(changeAction)
const maproulette_id_key = args[4]
if(maproulette_id_key){
const maproulette_id = Number(tags.data[maproulette_id_key])
await Maproulette.singleton.closeTask(maproulette_id, Maproulette.STATUS_FIXED, {
comment: "Tags are copied onto "+targetId+" with MapComplete"
})
}
}
public constr(
@ -163,7 +187,7 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
let el: BaseUIElement = new FixedUiElement(tagsStr)
if (targetIdKey !== undefined) {
const targetId = tags.data[targetIdKey] ?? tags.data.id
el = t.appliedOnAnotherObject.Subs({ tags: tagsStr, id: targetId })
el = t.appliedOnAnotherObject.Subs({tags: tagsStr, id: targetId})
}
return el
})

View file

@ -11,27 +11,27 @@
export let tags: UIEventSource<Record<string, string> | undefined>;
let _tags: Record<string, string>;
onDestroy(tags.addCallbackAndRun(tags => {
_tags = tags;
}));
let trs: { then: Translation; icon?: string; iconClass?: string }[];
export let state: SpecialVisualizationState;
export let selectedElement: Feature;
export let layer: LayerConfig;
export let config: TagRenderingConfig;
export let extraClasses: string= ""
if (config === undefined) {
throw "Config is undefined in tagRenderingAnswer";
}
export let layer: LayerConfig;
let trs: { then: Translation; icon?: string; iconClass?: string }[];
$:{
onDestroy(tags.addCallbackAndRun(tags => {
_tags = tags;
trs = Utils.NoNull(config?.GetRenderValues(_tags));
}
export let extraClasses: string= ""
}));
let classes = ""
$:classes = config?.classes?.join(" ") ?? "";
</script>
{#if config !== undefined && (config?.condition === undefined || config.condition.matchesProperties(_tags))}
<div class={"link-underline flex flex-col w-full overflow-hidden "+classes+" "+extraClasses}>
<div class={"link-underline flex flex-col w-full "+classes+" "+extraClasses}>
{#if trs.length === 1}
<TagRenderingMapping mapping={trs[0]} {tags} {state} {selectedElement} {layer}></TagRenderingMapping>
{/if}

View file

@ -78,7 +78,7 @@
<XCircleIcon slot="upper-right" class="w-8 h-8 cursor-pointer" on:click={() => {editMode = false}}/>
</TagRenderingQuestion>
{:else}
<div class="flex justify-between low-interaction items-center rounded px-2">
<div class="flex justify-between low-interaction items-center rounded px-2 overflow-hidden">
<TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer}/>
<button on:click={() => {editMode = true}}
class="shrink-0 w-8 h-8 rounded-full p-1 secondary self-start">
@ -87,6 +87,8 @@
</div>
{/if}
{:else }
<div class="p-2 overflow-hidden">
<TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer}/>
</div>
{/if}
</div>

View file

@ -959,24 +959,6 @@ export default class SpecialVisualizations {
defaultValue: "id",
},
],
example:
" The following example sets the status to '2' (false positive)\n" +
"\n" +
"```json\n" +
"{\n" +
' "id": "mark_duplicate",\n' +
' "render": {\n' +
' "special": {\n' +
' "type": "maproulette_set_status",\n' +
' "message": {\n' +
' "en": "Mark as not found or false positive"\n' +
" },\n" +
' "status": "2",\n' +
' "image": "close"\n' +
" }\n" +
" }\n" +
"}\n" +
"```",
constr: (state, tags, args) => {
const isUploading = new UIEventSource(false)
const t = Translations.t.notes
@ -1080,6 +1062,24 @@ export default class SpecialVisualizations {
{
funcName: "maproulette_set_status",
docs: "Change the status of the given MapRoulette task",
example:
" The following example sets the status to '2' (false positive)\n" +
"\n" +
"```json\n" +
"{\n" +
' "id": "mark_duplicate",\n' +
' "render": {\n' +
' "special": {\n' +
' "type": "maproulette_set_status",\n' +
' "message": {\n' +
' "en": "Mark as not found or false positive"\n' +
" },\n" +
' "status": "2",\n' +
' "image": "close"\n' +
" }\n" +
" }\n" +
"}\n" +
"```",
args: [
{
name: "message",
@ -1110,11 +1110,14 @@ export default class SpecialVisualizations {
if (image === "") {
image = "confirm"
}
if(maproulette_id_key === "" || maproulette_id_key === undefined){
maproulette_id_key = "mr_taskId"
}
if (Svg.All[image] !== undefined || Svg.All[image + ".svg"] !== undefined) {
if (image.endsWith(".svg")) {
image = image.substring(0, image.length - 4)
}
image = Svg[image + "_ui"]()
image = Svg[image + "_svg"]()
}
const failed = new UIEventSource(false)
@ -1122,7 +1125,7 @@ export default class SpecialVisualizations {
Translations.t.general.loading,
async () => {
const maproulette_id =
tagsSource.data[maproulette_id_key] ?? tagsSource.data.id
tagsSource.data[maproulette_id_key] ?? tagsSource.data.mr_taskId ?? tagsSource.data.id
try {
await Maproulette.singleton.closeTask(
Number(maproulette_id),
@ -1150,13 +1153,19 @@ export default class SpecialVisualizations {
return new VariableUiElement(
tagsSource
.map(
(tgs) =>
tgs["status"] ??
Maproulette.STATUS_MEANING[tgs["mr_taskStatus"]]
(tgs) => {
if(tgs["status"]){
return tgs["status"]
}
const code = tgs["mr_taskStatus"]
console.log("Code is", code, Maproulette.codeToIndex(code))
return Maproulette.codeToIndex(code)
}
)
.map(Number)
.map(
(status) => {
console.log("Close MR button: status is", status)
if (failed.data) {
return new FixedUiElement(
"ERROR - could not close the MapRoulette task"

View file

@ -60,7 +60,6 @@
"tagRenderings": [
{
"id": "status",
"render": "Current status: {status}",
"mappings": [
{
"if": "status=0",
@ -130,6 +129,7 @@
]
},
{
"labels": ["controls"],
"id": "mark_fixed",
"render": {
"special": {
@ -144,6 +144,7 @@
},
{
"id": "mark_duplicate",
"labels": ["controls"],
"render": {
"special": {
"type": "maproulette_set_status",
@ -159,6 +160,7 @@
},
{
"id": "mark_too_hard",
"labels": ["controls"],
"render": {
"special": {
"type": "maproulette_set_status",
@ -306,4 +308,4 @@
]
}
]
}
}

View file

@ -77,7 +77,6 @@
},
{
"id": "status",
"render": "Current status: {status}",
"mappings": [
{
"if": "mr_taskStatus=Created",

View file

@ -34,6 +34,7 @@
"override": {
"id": "banks_with_atm",
"name": null,
"minzoom": 14,
"source": {
"osmTags": {
"and+": [
@ -54,6 +55,68 @@
"sameAs": "bank"
}
}
},
{
"builtin": "maproulette_challenge",
"override": {
"minzoom": 5,
"source": {
"geoJson": "https://maproulette.org/api/v2/challenge/view/39519"
},
"isShown": "mr_taskStatus=Created",
"calculatedTags": [
"_closest_osm_poi=closest(feat)('atm')?.properties?.id",
"_closest_osm_poi_distance=Math.round(distanceTo(feat)(feat.properties._closest_osm_poi))",
"_has_closeby_feature=Number(feat.properties._closest_osm_poi_distance) < 50 ? 'yes' : 'no'"
],
"=tagRenderings": [
{
"id": "import-button",
"condition": "_has_closeby_feature=no",
"render": {
"special": {
"type": "import_button",
"targetLayer": "atm",
"tags": "tags",
"maproulette_id": "mr_taskId",
"text": {
"en": "Import this ATM"
},
"icon": "./assets/svg/addSmall.svg"
}
}
},
{
"id": "closeness-indicator",
"condition": "_has_closeby_feature=yes",
"render": {
"en": "OpenStreetMap knows about <a href='#{_closest_osm_poi}'>an ATM which is {_closest_osm_poi_distance} meter away.</a> "
}
},
{
"id": "tag-apply-button",
"condition": "_has_closeby_feature=yes",
"render": {
"special": {
"type": "tag_apply",
"tags_to_apply": "$tags",
"id_of_object_to_apply_this_one": "_closest_osm_poi",
"message": {
"en": "Add all the suggested tags to the closest ATM"
},
"image": "./assets/svg/addSmall.svg",
"maproulette_task_id": "mr_taskId"
}
}
},
"maproulette.controls",
{
"id": "minimap_with_atm",
"render": "{minimap(18, id, _closest_osm_poi)}"
},
"all_tags"
]
}
}
]
}
}

View file

@ -356,7 +356,12 @@ class LayerOverviewUtils extends Script {
const context = "While building builtin layer " + sharedLayerPath
const fixed = prepLayer.convertStrict(parsed, context)
if (typeof fixed.source !== "string" && fixed.source["osmTags"]["and"] === undefined) {
if(!fixed.source){
console.error(sharedLayerPath,"has no source configured:",fixed)
throw sharedLayerPath+" layer has no source configured"
}
if (typeof fixed.source !== "string" && fixed.source["osmTags"] && fixed.source["osmTags"]["and"] === undefined) {
fixed.source["osmTags"] = { and: [fixed.source["osmTags"]] }
}

View file

@ -0,0 +1,92 @@
import fs from "fs"
import {OH} from "../../UI/OpeningHours/OpeningHours";
const cashpunten = JSON.parse(fs.readFileSync("/home/pietervdvn/Downloads/cash_punten.json", "utf8")).data
const features: any[] = []
const weekdays = [
"MO",
"TU",
"WE",
"TH",
"FR",
"SA",
"SU"
]
for (const atm of cashpunten) {
const properties = {
"amenity": "atm",
"addr:street": atm.adr_street,
"addr:housenumber": atm.adr_street_number,
"phone": <string>atm.phone_number,
"operator": "Batopin",
network: "CASH",
fee: "no",
"speech_output": "yes",
"brand": "CASH",
website: "https://batopin.be",
"source": "https://batopin.be",
"brand:wikidata": "Q112875867",
"operator:wikidata": "Q97142699",
"currency:EUR": "yes"
}
features.push({
geometry: {type: "Point", coordinates: [atm.adr_longitude, atm.adr_latitude]},
properties: {
tags: properties
}
})
switch (atm.accessibility) {
case "Green":
properties["wheelchair"] = "yes";
break;
case "Orange":
properties["wheelchair"] = "limited";
break;
case "Red":
properties["wheelchair"] = "no";
break;
default:
break;
}
delete atm.accessibility
if (atm.deposit_cash) {
properties["cash_in"] = atm.deposit_cash === "1" ? "yes" : "no"
delete atm.deposit_cash
}
if (!weekdays.some(wd => atm.regular_hours[wd] !== "00:00-00:00")) {
properties["opening_hours"] = "24/7"
delete atm.regular_hours
} else {
const rules = weekdays.filter(wd => atm.regular_hours[wd] !== undefined).map(wd => wd[0] + wd.toLowerCase()[1] + " " + atm.regular_hours[wd]).join(";")
properties["opening_hours"] = OH.ToString(OH.MergeTimes(OH.Parse(rules)))
delete atm.regular_hours
}
delete atm.special_hours // Only one data point has this
delete atm.location_language
delete atm.location_name
delete atm.shop_code
delete atm.id
delete atm.adr_longitude
delete atm.adr_latitude
delete atm.adr_street_number
delete atm.adr_street
delete atm.adr_zipcode
delete atm.adr_city
delete atm.adr_country
delete atm.phone_number
if (Object.keys(atm).length == 0) {
continue
}
console.log(atm, properties)
break
}
fs.writeFileSync("atms.geojson", JSON.stringify({type: "FeatureCollection", features}))

16
test.ts
View file

@ -3,9 +3,6 @@ import * as theme from "./assets/generated/themes/bookcases.json"
import ThemeViewState from "./Models/ThemeViewState"
import Combine from "./UI/Base/Combine"
import SpecialVisualizations from "./UI/SpecialVisualizations"
import {VariableUiElement} from "./UI/Base/VariableUIElement"
import {SvgToPdf} from "./Utils/svgToPdf"
import {Utils} from "./Utils"
function testspecial() {
const layout = new LayoutConfig(<any>theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data)
@ -18,19 +15,6 @@ function testspecial() {
}
async function testPdf() {
const svgs = await Promise.all(
SvgToPdf.templates["flyer_a4"].pages.map((url) => Utils.download(url))
)
console.log("Building svg")
const pdf = new SvgToPdf("Test", svgs, {
freeComponentId:"extradiv"
})
new VariableUiElement(pdf.status).AttachTo("maindiv")
await pdf.ExportPdf("nl")
}
testPdf().then((_) => console.log("All done"))
/*/
testspecial()
//*/