Add metadata in changeset with (binned) distance to changed feature

This commit is contained in:
pietervdvn 2021-11-09 01:49:07 +01:00
parent e8ce53d5eb
commit 8e66313ef1
21 changed files with 178 additions and 41 deletions

View file

@ -6,6 +6,18 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {QueryParameters} from "../Web/QueryParameters"; import {QueryParameters} from "../Web/QueryParameters";
import FeatureSource from "../FeatureSource/FeatureSource"; import FeatureSource from "../FeatureSource/FeatureSource";
export interface GeoLocationPointProperties {
id: "gps",
"user:location": "yes",
"date": string,
"latitude": number
"longitude":number,
"speed": number,
"accuracy": number
"heading": number
"altitude":number
}
export default class GeoLocationHandler extends VariableUiElement { export default class GeoLocationHandler extends VariableUiElement {
private readonly currentLocation: FeatureSource private readonly currentLocation: FeatureSource
@ -184,10 +196,9 @@ export default class GeoLocationHandler extends VariableUiElement {
this.currentLocation = state.currentUserLocation this.currentLocation = state.currentUserLocation
this._currentGPSLocation.addCallback((location) => { this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted"); self._previousLocationGrant.setData("granted");
console.log("Location is", location,)
const feature = { const feature = {
"type": "Feature", "type": "Feature",
properties: { properties: <GeoLocationPointProperties>{
id: "gps", id: "gps",
"user:location": "yes", "user:location": "yes",
"date": new Date().toISOString(), "date": new Date().toISOString(),

View file

@ -20,7 +20,11 @@ export interface ChangeDescription {
/** /**
* THe motivation for the change, e.g. 'deleted because does not exist anymore' * THe motivation for the change, e.g. 'deleted because does not exist anymore'
*/ */
specialMotivation?: string specialMotivation?: string,
/**
* Added by Changes.ts
*/
distanceToObject?: number
}, },
/** /**

View file

@ -11,7 +11,7 @@ export default class ChangeLocationAction extends OsmChangeAction {
theme: string, theme: string,
reason: string reason: string
}) { }) {
super(); super(id,true);
if (!id.startsWith("node/")) { if (!id.startsWith("node/")) {
throw "Invalid ID: only 'node/number' is accepted" throw "Invalid ID: only 'node/number' is accepted"
} }
@ -19,7 +19,7 @@ export default class ChangeLocationAction extends OsmChangeAction {
this._newLonLat = newLonLat; this._newLonLat = newLonLat;
this._meta = meta; this._meta = meta;
} }
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> { protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const d: ChangeDescription = { const d: ChangeDescription = {

View file

@ -13,13 +13,13 @@ export default class ChangeTagAction extends OsmChangeAction {
theme: string, theme: string,
changeType: "answer" | "soft-delete" | "add-image" | string changeType: "answer" | "soft-delete" | "add-image" | string
}) { }) {
super(); super(elementId, true);
this._elementId = elementId; this._elementId = elementId;
this._tagsFilter = tagsFilter; this._tagsFilter = tagsFilter;
this._currentTags = currentTags; this._currentTags = currentTags;
this._meta = meta; this._meta = meta;
} }
/** /**
* Doublechecks that no stupid values are added * Doublechecks that no stupid values are added
*/ */

View file

@ -31,7 +31,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
reusePointWithinMeters?: number, reusePointWithinMeters?: number,
theme: string, changeType: "create" | "import" | null theme: string, changeType: "create" | "import" | null
}) { }) {
super() super(null,basicTags !== undefined && basicTags.length > 0)
this._basicTags = basicTags; this._basicTags = basicTags;
this._lat = lat; this._lat = lat;
this._lon = lon; this._lon = lon;

View file

@ -24,13 +24,13 @@ export default class CreateNewWayAction extends OsmChangeAction {
options: { options: {
theme: string theme: string
}) { }) {
super() super(null,true)
this.coordinates = coordinates; this.coordinates = coordinates;
this.tags = tags; this.tags = tags;
this._options = options; this._options = options;
} }
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> { public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const newElements: ChangeDescription[] = [] const newElements: ChangeDescription[] = []
@ -46,7 +46,7 @@ export default class CreateNewWayAction extends OsmChangeAction {
changeType: null, changeType: null,
theme: this._options.theme theme: this._options.theme
}) })
await changes.applyAction(newPoint) newElements.push(...await newPoint.CreateChangeDescriptions(changes))
pointIds.push(newPoint.newElementIdNumber) pointIds.push(newPoint.newElementIdNumber)
} }

View file

@ -46,7 +46,7 @@ export default class CreateWayWithPointReuseAction extends OsmChangeAction {
state: FeaturePipelineState, state: FeaturePipelineState,
config: MergePointConfig[] config: MergePointConfig[]
) { ) {
super(); super(null,true);
this._tags = tags; this._tags = tags;
this._state = state; this._state = state;
this._config = config; this._config = config;
@ -194,9 +194,8 @@ export default class CreateWayWithPointReuseAction extends OsmChangeAction {
const newWay = new CreateNewWayAction(this._tags, nodeIdsToUse, { const newWay = new CreateNewWayAction(this._tags, nodeIdsToUse, {
theme theme
}) })
allChanges.push(...(await newWay.Perform(changes))) allChanges.push(...(await newWay.CreateChangeDescriptions(changes)))
return allChanges return allChanges
} }

View file

@ -27,7 +27,7 @@ export default class DeleteAction extends OsmChangeAction {
specialMotivation: string specialMotivation: string
}, },
hardDelete: boolean) { hardDelete: boolean) {
super() super(id,true)
this._id = id; this._id = id;
this._hardDelete = hardDelete; this._hardDelete = hardDelete;
this.meta = {...meta, changeType: "deletion"}; this.meta = {...meta, changeType: "deletion"};

View file

@ -8,6 +8,18 @@ import {ChangeDescription} from "./ChangeDescription";
export default abstract class OsmChangeAction { export default abstract class OsmChangeAction {
private isUsed = false private isUsed = false
public readonly trackStatistics: boolean;
/**
* The ID of the object that is the center of this change.
* Null if the action creates a new object
* Undefined if such an id does not make sense
*/
public readonly mainObjectId: string;
constructor(mainObjectId: string, trackStatistics: boolean = true) {
this.trackStatistics = trackStatistics;
this.mainObjectId = mainObjectId
}
public Perform(changes: Changes) { public Perform(changes: Changes) {
if (this.isUsed) { if (this.isUsed) {
@ -18,6 +30,4 @@ export default abstract class OsmChangeAction {
} }
protected abstract CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> protected abstract CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]>
} }

View file

@ -16,11 +16,10 @@ abstract class AbstractRelationSplitHandler extends OsmChangeAction {
protected readonly _theme: string; protected readonly _theme: string;
constructor(input: RelationSplitInput, theme: string) { constructor(input: RelationSplitInput, theme: string) {
super() super("relation/"+input.relation.id, false)
this._input = input; this._input = input;
this._theme = theme; this._theme = theme;
} }
/** /**
* Returns which node should border the member at the given index * Returns which node should border the member at the given index
*/ */

View file

@ -41,7 +41,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
newTags?: Tag[] newTags?: Tag[]
} }
) { ) {
super(); super(wayToReplaceId, false);
this.state = state; this.state = state;
this.feature = feature; this.feature = feature;
this.wayToReplaceId = wayToReplaceId; this.wayToReplaceId = wayToReplaceId;

View file

@ -26,7 +26,7 @@ export default class SplitAction extends OsmChangeAction {
* @param toleranceInMeters: if a splitpoint closer then this amount of meters to an existing point, the existing point will be used to split the line instead of a new point * @param toleranceInMeters: if a splitpoint closer then this amount of meters to an existing point, the existing point will be used to split the line instead of a new point
*/ */
constructor(wayId: string, splitPointCoordinates: [number, number][], meta: { theme: string }, toleranceInMeters = 5) { constructor(wayId: string, splitPointCoordinates: [number, number][], meta: { theme: string }, toleranceInMeters = 5) {
super() super(wayId,true)
this.wayId = wayId; this.wayId = wayId;
this._splitPointsCoordinates = splitPointCoordinates this._splitPointsCoordinates = splitPointCoordinates
this._toleranceInMeters = toleranceInMeters; this._toleranceInMeters = toleranceInMeters;

View file

@ -8,6 +8,11 @@ import {Utils} from "../../Utils";
import {LocalStorageSource} from "../Web/LocalStorageSource"; import {LocalStorageSource} from "../Web/LocalStorageSource";
import SimpleMetaTagger from "../SimpleMetaTagger"; import SimpleMetaTagger from "../SimpleMetaTagger";
import CreateNewNodeAction from "./Actions/CreateNewNodeAction"; import CreateNewNodeAction from "./Actions/CreateNewNodeAction";
import FeatureSource from "../FeatureSource/FeatureSource";
import {ElementStorage} from "../ElementStorage";
import {GeoLocationPointProperties} from "../Actors/GeoLocationHandler";
import {GeoOperations} from "../GeoOperations";
import {ChangesetTag} from "./ChangesetHandler";
/** /**
* Handles all changes made to OSM. * Handles all changes made to OSM.
@ -27,6 +32,8 @@ export class Changes {
private readonly previouslyCreated: OsmObject[] = [] private readonly previouslyCreated: OsmObject[] = []
private readonly _leftRightSensitive: boolean; private readonly _leftRightSensitive: boolean;
private _state : { allElements: ElementStorage; historicalUserLocations: FeatureSource }
constructor(leftRightSensitive: boolean = false) { constructor(leftRightSensitive: boolean = false) {
this._leftRightSensitive = leftRightSensitive; this._leftRightSensitive = leftRightSensitive;
@ -113,14 +120,71 @@ export class Changes {
}) })
} }
public async applyAction(action: OsmChangeAction): Promise<void> { private calculateDistanceToChanges(change: OsmChangeAction, changeDescriptions: ChangeDescription[]){
this.applyChanges(await action.Perform(this))
}
public async applyActions(actions: OsmChangeAction[]) { if (this._state === undefined) {
for (const action of actions) { // No state loaded -> we can't calculate...
await this.applyAction(action) return;
} }
if(!change.trackStatistics){
// Probably irrelevant, such as a new helper node
return;
}
const now = new Date()
const recentLocationPoints = this._state.historicalUserLocations.features.data.map(ff => ff.feature)
.filter(feat => feat.geometry.type === "Point")
.filter(feat => {
const visitTime = new Date((<GeoLocationPointProperties>feat.properties).date)
// In seconds
const diff = (now.getTime() - visitTime.getTime()) / 1000
return diff < Constants.nearbyVisitTime;
})
if(recentLocationPoints.length === 0){
// Probably no GPS enabled/no fix
return;
}
// The applicable points, contain information in their properties about location, time and GPS accuracy
// They are all GeoLocationPointProperties
// We walk every change and determine the closest distance possible
// Only if the change itself does _not_ contain any coordinates, we fall back and search the original feature in the state
const changedObjectCoordinates : [number, number][] = []
const feature = this._state.allElements.ContainingFeatures.get(change.mainObjectId)
if(feature !== undefined){
changedObjectCoordinates.push(GeoOperations.centerpointCoordinates(feature))
}
for (const changeDescription of changeDescriptions) {
const chng : {lat: number, lon: number} | {coordinates : [number,number][]} | {members} = changeDescription.changes
if(chng === undefined){
continue
}
if(chng["lat"] !== undefined){
changedObjectCoordinates.push([chng["lat"],chng["lon"]])
}
if(chng["coordinates"] !== undefined){
changedObjectCoordinates.push(...chng["coordinates"])
}
}
const leastDistance = Math.min(...changedObjectCoordinates.map(coor =>
Math.min(...recentLocationPoints.map(gpsPoint => {
const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint)
const dist = GeoOperations.distanceBetween(coor, otherCoor) * 1000;
console.log("Comparing ", coor, "and ", otherCoor, " --> ", dist)
return dist
}))
))
return leastDistance
}
public async applyAction(action: OsmChangeAction): Promise<void> {
const changeDescriptions = await action.Perform(this)
const distanceToObject = this.calculateDistanceToChanges(action, changeDescriptions)
changeDescriptions[0].meta.distanceToObject = distanceToObject
this.applyChanges(changeDescriptions)
} }
public applyChanges(changes: ChangeDescription[]) { public applyChanges(changes: ChangeDescription[]) {
@ -130,6 +194,13 @@ export class Changes {
this.allChanges.data.push(...changes) this.allChanges.data.push(...changes)
this.allChanges.ping() this.allChanges.ping()
} }
public useLocationHistory(state: {
allElements: ElementStorage,
historicalUserLocations: FeatureSource
}){
this._state= state
}
public registerIdRewrites(mappings: Map<string, string>): void { public registerIdRewrites(mappings: Map<string, string>): void {
CreateNewNodeAction.registerIdRewrites(mappings) CreateNewNodeAction.registerIdRewrites(mappings)
@ -162,7 +233,6 @@ export class Changes {
return true return true
} }
const meta = pending[0].meta
const perType = Array.from( const perType = Array.from(
Utils.Hist(pending.filter(descr => descr.meta.changeType !== undefined && descr.meta.changeType !== null) Utils.Hist(pending.filter(descr => descr.meta.changeType !== undefined && descr.meta.changeType !== null)
@ -177,16 +247,46 @@ export class Changes {
key: descr.meta.changeType + ":" + descr.type + "/" + descr.id, key: descr.meta.changeType + ":" + descr.type + "/" + descr.id,
value: descr.meta.specialMotivation value: descr.meta.specialMotivation
})) }))
const metatags = [{
const distances = Utils.NoNull(pending.map(descr => descr.meta.distanceToObject));
distances.sort((a, b) => a - b)
const perBinCount = Constants.distanceToChangeObjectBins.map(_ => 0)
let j = 0;
const maxDistances = Constants.distanceToChangeObjectBins
for (let i = 0; i < maxDistances.length; i++){
const maxDistance = maxDistances[i];
// distances is sorted in ascending order, so as soon as one is to big, all the resting elements will be bigger too
while(j < distances.length && distances[j] < maxDistance){
perBinCount[i] ++
j++
}
}
const perBinMessage = Utils.NoNull(perBinCount.map((count, i) => {
if(count === 0){
return undefined
}
return {
key: "change_within_"+maxDistances[i]+"m",
value: count,
aggregate:true
}
}))
// This method is only called with changedescriptions for this theme
const theme = pending[0].meta.theme
const metatags : ChangesetTag[] = [{
key: "comment", key: "comment",
value: "Adding data with #MapComplete for theme #" + meta.theme value: "Adding data with #MapComplete for theme #" + theme
}, },
{ {
key: "theme", key: "theme",
value: meta.theme value: theme
}, },
...perType, ...perType,
...motivations ...motivations,
...perBinMessage
] ]
await State.state.osmConnection.changesetHandler.UploadChangeset( await State.state.osmConnection.changesetHandler.UploadChangeset(

View file

@ -78,6 +78,7 @@ export class ChangesetHandler {
} }
if (this._dryRun) { if (this._dryRun) {
const changesetXML = generateChangeXML(123456); const changesetXML = generateChangeXML(123456);
console.log("Metatags are", extraMetaTags)
console.log(changesetXML); console.log(changesetXML);
return; return;
} }

View file

@ -11,6 +11,7 @@ import {Utils} from "../../Utils";
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"; import ChangeToElementsActor from "../Actors/ChangeToElementsActor";
import PendingChangesUploader from "../Actors/PendingChangesUploader"; import PendingChangesUploader from "../Actors/PendingChangesUploader";
import TitleHandler from "../Actors/TitleHandler"; import TitleHandler from "../Actors/TitleHandler";
import FeatureSource from "../FeatureSource/FeatureSource";
/** /**
* The part of the state keeping track of where the elements, loading them, configuring the feature pipeline etc * The part of the state keeping track of where the elements, loading them, configuring the feature pipeline etc
@ -50,7 +51,6 @@ export default class ElementsState extends FeatureSwitchState {
super(layoutToUse); super(layoutToUse);
this.changes = new Changes(layoutToUse?.isLeftRightSensitive() ?? false) this.changes = new Changes(layoutToUse?.isLeftRightSensitive() ?? false)
{ {
// -- Location control initialization // -- Location control initialization
const zoom = UIEventSource.asFloat( const zoom = UIEventSource.asFloat(

View file

@ -14,7 +14,7 @@ import {QueryParameters} from "../Web/QueryParameters";
import * as personal from "../../assets/themes/personal/personal.json"; import * as personal from "../../assets/themes/personal/personal.json";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"; import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource";
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource";
/** /**
@ -209,7 +209,6 @@ export default class MapState extends UserRelatedState {
const feature = JSON.parse(JSON.stringify(location.feature)) const feature = JSON.parse(JSON.stringify(location.feature))
feature.properties.id = "gps/"+i feature.properties.id = "gps/"+i
i++ i++
console.log("New location: ", feature)
features.data.push({feature, freshness: new Date()}) features.data.push({feature, freshness: new Date()})
histCoordinates.push(feature.geometry.coordinates) histCoordinates.push(feature.geometry.coordinates)
@ -224,7 +223,7 @@ export default class MapState extends UserRelatedState {
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_track")[0] let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_track")[0]
this.historicalUserLocations = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0), features); this.historicalUserLocations = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0), features);
this.changes.useLocationHistory(this)
} }
private initHomeLocation() { private initHomeLocation() {

View file

@ -11,6 +11,7 @@ import ElementsState from "./ElementsState";
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater"; import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater";
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"; import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
import FeatureSource from "../FeatureSource/FeatureSource"; import FeatureSource from "../FeatureSource/FeatureSource";
import {Feature} from "@turf/turf";
/** /**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection, * The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,

View file

@ -2,7 +2,7 @@ import {Utils} from "../Utils";
export default class Constants { export default class Constants {
public static vNumber = "0.12.3"; public static vNumber = "0.12.4";
public static ImgurApiKey = '7070e7167f0a25a' public static ImgurApiKey = '7070e7167f0a25a'
public static readonly mapillary_client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2' public static readonly mapillary_client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2'
public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
@ -39,6 +39,19 @@ export default class Constants {
* (Note that pendingChanges might upload sooner if the popup is closed or similar) * (Note that pendingChanges might upload sooner if the popup is closed or similar)
*/ */
static updateTimeoutSec: number = 30; static updateTimeoutSec: number = 30;
/**
* If the contributor has their GPS location enabled and makes a change,
* the points visited less then `nearbyVisitTime`-seconds ago will be inspected.
* The point closest to the changed feature will be considered and this distance will be tracked.
* ALl these distances are used to calculate a nearby-score
*/
static nearbyVisitTime: number= 30 * 60;
/**
* If a user makes a change, the distance to the changed object is calculated.
* If a user makes multiple changes, all these distances are put into multiple bins, depending on this distance.
* For every bin, the totals are uploaded as metadata
*/
static distanceToChangeObjectBins = [25,50,100,500,1000,5000]
private static isRetina(): boolean { private static isRetina(): boolean {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {

View file

@ -15,7 +15,7 @@ export default class AllThemesGui {
try { try {
new FixedUiElement("").AttachTo("centermessage") new FixedUiElement("").AttachTo("centermessage")
const state = new UserRelatedState(undefined); const state = new UserRelatedState(undefined, undefined);
const intro = new Combine([ const intro = new Combine([
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages()) LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
.SetClass("absolute top-2 right-3"), .SetClass("absolute top-2 right-3"),

View file

@ -95,7 +95,7 @@ export default class SplitRoadWizard extends Toggle {
const points = splitPoints.data.map((f, i) => [f.feature, i]) const points = splitPoints.data.map((f, i) => [f.feature, i])
.filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5) .filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5)
.map(p => p[1]) .map(p => p[1])
.sort() .sort((a, b) => a - b)
.reverse() .reverse()
if (points.length > 0) { if (points.length > 0) {
for (const point of points) { for (const point of points) {

View file

@ -52,7 +52,7 @@ export default class ActorsSpec extends T {
[ [
"download latest version", "download latest version",
() => { () => {
const state = new UserRelatedState(AllKnownLayouts.allKnownLayouts.get("bookcases")) const state = new UserRelatedState(AllKnownLayouts.allKnownLayouts.get("bookcases"), undefined)
const feature = { const feature = {
"type": "Feature", "type": "Feature",
"id": "node/5568693115", "id": "node/5568693115",