Refactoring of GPS-location (uses featureSource too now), factoring out state, add ReplaceGeometryAction and conflation example

This commit is contained in:
Pieter Vander Vennet 2021-11-03 00:44:53 +01:00
parent 1db54f3c8e
commit 2484848cd6
37 changed files with 1035 additions and 467 deletions

View file

@ -1,13 +1,16 @@
import * as L from "leaflet";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {QueryParameters} from "../Web/QueryParameters";
import FeatureSource from "../FeatureSource/FeatureSource";
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
export default class GeoLocationHandler extends VariableUiElement {
public readonly currentLocation : FeatureSource
/**
* Wether or not the geolocation is active, aka the user requested the current location
* @private
@ -25,20 +28,12 @@ export default class GeoLocationHandler extends VariableUiElement {
* @private
*/
private readonly _permission: UIEventSource<string>;
/***
* The marker on the map, in order to update it
* @private
*/
private _marker: L.Marker;
/**
* Literally: _currentGPSLocation.data != undefined
* @private
*/
private readonly _hasLocation: UIEventSource<boolean>;
private readonly _currentGPSLocation: UIEventSource<{
latlng: any;
accuracy: number;
}>;
private readonly _currentGPSLocation: UIEventSource<Coordinates>;
/**
* Kept in order to update the marker
* @private
@ -63,8 +58,8 @@ export default class GeoLocationHandler extends VariableUiElement {
private readonly _layoutToUse: LayoutConfig;
constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
currentGPSLocation: UIEventSource<Coordinates>,
leafletMap: UIEventSource<any>,
layoutToUse: LayoutConfig
) {
const hasLocation = currentGPSLocation.map(
@ -182,10 +177,25 @@ export default class GeoLocationHandler extends VariableUiElement {
}
})
this.currentLocation = new StaticFeatureSource([], false)
this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted");
const feature = {
"type": "Feature",
properties: {
"user:location":"yes",
"accuracy":location.accuracy,
"speed":location.speed,
},
geometry:{
type:"Point",
coordinates: [location.longitude, location.latitude],
}
}
self.currentLocation.features.setData([{feature, freshness: new Date()}])
const timeSinceRequest =
(new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
if (timeSinceRequest < 30) {
@ -194,33 +204,8 @@ export default class GeoLocationHandler extends VariableUiElement {
self.MoveToCurrentLoction();
}
let color = "#1111cc";
try {
color = getComputedStyle(document.body).getPropertyValue(
"--catch-detail-color"
);
} catch (e) {
console.error(e);
}
const icon = L.icon({
iconUrl: Img.AsData(Svg.location.replace(/#000000/g, color).replace(/#000/g, color)),
iconSize: [40, 40], // size of the icon
iconAnchor: [20, 20], // point of the icon which will correspond to marker's location
});
const map = self._leafletMap.data;
if(map === undefined){
return;
}
const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map);
if (self._marker !== undefined) {
map.removeLayer(self._marker);
}
self._marker = newMarker;
});
}
private init(askPermission: boolean, forceZoom: boolean) {
@ -261,8 +246,8 @@ export default class GeoLocationHandler extends VariableUiElement {
this._lastUserRequest = undefined;
if (
this._currentGPSLocation.data.latlng[0] === 0 &&
this._currentGPSLocation.data.latlng[1] === 0
this._currentGPSLocation.data.latitude === 0 &&
this._currentGPSLocation.data.longitude === 0
) {
console.debug("Not moving to GPS-location: it is null island");
return;
@ -275,20 +260,20 @@ export default class GeoLocationHandler extends VariableUiElement {
if (b !== true) {
// B is an array with our locklocation
inRange =
b[0][0] <= location.latlng[0] &&
location.latlng[0] <= b[1][0] &&
b[0][1] <= location.latlng[1] &&
location.latlng[1] <= b[1][1];
b[0][0] <= location.latitude &&
location.latitude <= b[1][0] &&
b[0][1] <= location.longitude &&
location.longitude <= b[1][1];
}
}
if (!inRange) {
console.log(
"Not zooming to GPS location: out of bounds",
b,
location.latlng
location
);
} else {
this._leafletMap.data.setView(location.latlng, targetZoom);
this._leafletMap.data.setView([location.latitude, location.longitude], targetZoom);
}
}
@ -312,10 +297,7 @@ export default class GeoLocationHandler extends VariableUiElement {
navigator.geolocation.watchPosition(
function (position) {
self._currentGPSLocation.setData({
latlng: [position.coords.latitude, position.coords.longitude],
accuracy: position.coords.accuracy,
});
self._currentGPSLocation.setData(position.coords);
},
function () {
console.warn("Could not get location with navigator.geolocation");

View file

@ -116,6 +116,11 @@ export class BBox {
getSouth() {
return this.minLat
}
contains(lonLat: [number, number]){
return this.minLat <= lonLat[1] && lonLat[1] <= this.maxLat
&& this.minLon<= lonLat[0] && lonLat[0] <= this.maxLon
}
pad(factor: number, maxIncrease = 2): BBox {

View file

@ -228,11 +228,15 @@ export default class FeaturePipeline {
})
if(state.layoutToUse.trackAllNodes){
new FullNodeDatabaseSource(state, osmFeatureSource, tile => {
const fullNodeDb = new FullNodeDatabaseSource(
state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0],
tile => {
new RegisteringAllFromFeatureSourceActor(tile)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
})
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => fullNodeDb.handleOsmJson(osmJson, tileId))
}

View file

@ -70,7 +70,7 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
w.coordinates = change.changes["coordinates"].map(coor => [coor[1], coor[0]])
add(w.asGeoJson())
break;
case "relation":

View file

@ -32,7 +32,7 @@ export default class RenderingMultiPlexerFeatureSource {
const withIndex: (any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined })[] = [];
function addAsPoint(feat, rendering, coordinate) {
function addAsPoint(feat, rendering, coordinate) {
const patched = {
...feat,
pointRenderingIndex: rendering.index
@ -46,8 +46,6 @@ export default class RenderingMultiPlexerFeatureSource {
for (const f of features) {
const feat = f.feature;
if (feat.geometry.type === "Point") {
for (const rendering of pointRenderings) {

View file

@ -2,30 +2,103 @@ import TileHierarchy from "./TileHierarchy";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {OsmNode, OsmObject, OsmWay} from "../../Osm/OsmObject";
import SimpleFeatureSource from "../Sources/SimpleFeatureSource";
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {TagsFilter} from "../../Tags/TagsFilter";
import OsmChangeAction from "../../Osm/Actions/OsmChangeAction";
import StaticFeatureSource from "../Sources/StaticFeatureSource";
import {OsmConnection} from "../../Osm/OsmConnection";
import {GeoOperations} from "../../GeoOperations";
import {Utils} from "../../../Utils";
import {UIEventSource} from "../../UIEventSource";
import {BBox} from "../../BBox";
import FeaturePipeline from "../FeaturePipeline";
import {Tag} from "../../Tags/Tag";
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig";
import {ChangeDescription} from "../../Osm/Actions/ChangeDescription";
import CreateNewNodeAction from "../../Osm/Actions/CreateNewNodeAction";
import ChangeTagAction from "../../Osm/Actions/ChangeTagAction";
import {And} from "../../Tags/And";
export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSource & Tiled> {
public readonly loadedTiles = new Map<number, FeatureSource & Tiled>()
private readonly onTileLoaded: (tile: (Tiled & FeatureSourceForLayer)) => void;
private readonly layer : FilteredLayer
private readonly layer: FilteredLayer
constructor(
state: {
readonly filteredLayers: UIEventSource<FilteredLayer[]>},
osmFeatureSource: { rawDataHandlers: ((data: any, tileId: number) => void)[] },
layer: FilteredLayer,
onTileLoaded: ((tile: Tiled & FeatureSourceForLayer) => void)) {
this.onTileLoaded = onTileLoaded
this.layer = state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0]
if(this.layer === undefined){
throw "Weird: tracking all nodes, but layer 'type_node' is not defined"
this.layer = layer;
if (this.layer === undefined) {
throw "Layer is undefined"
}
const self = this
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => self.handleOsmXml(osmJson, tileId))
}
private handleOsmXml(osmJson: any, tileId: number) {
/**
* Given a list of coordinates, will search already existing OSM-points to snap onto.
* Either the geometry will be moved OR the existing point will be moved, depending on configuration and tags.
* This requires the 'type_node'-layer to be activated
*/
public static MergePoints(
state: {
filteredLayers: UIEventSource<FilteredLayer[]>,
featurePipeline: FeaturePipeline,
layoutToUse: LayoutConfig
},
newGeometryLngLats: [number, number][],
configs: ConflationConfig[],
) {
const typeNode = state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0]
if (typeNode === undefined) {
throw "Type Node layer is not defined. Add 'type_node' as layer to your layerconfig to use this feature"
}
const bbox = new BBox(newGeometryLngLats)
const bbox_padded = bbox.pad(1.2)
const allNodes: any[] = [].concat(...state.featurePipeline.GetFeaturesWithin("type_node", bbox).map(tile => tile.filter(
feature => bbox_padded.contains(GeoOperations.centerpointCoordinates(feature))
)))
// The strategy: for every point of the new geometry, we search a point that is closeby and matches
// If multiple options match, we choose the most optimal (aka closest)
const maxDistance = Math.max(...configs.map(c => c.withinRangeOfM))
for (const coordinate of newGeometryLngLats) {
let closestNode = undefined;
let closestNodeDistance = undefined
for (const node of allNodes) {
const d = GeoOperations.distanceBetween(GeoOperations.centerpointCoordinates(node), coordinate)
if (d > maxDistance) {
continue
}
let matchesSomeConfig = false
for (const config of configs) {
if (d > config.withinRangeOfM) {
continue
}
if (!config.ifMatches.matchesProperties(node.properties)) {
continue
}
matchesSomeConfig = true;
}
if (!matchesSomeConfig) {
continue
}
if (closestNode === undefined || closestNodeDistance > d) {
closestNode = node;
closestNodeDistance = d;
}
}
}
}
public handleOsmJson(osmJson: any, tileId: number) {
const allObjects = OsmObject.ParseObjects(osmJson.elements)
const nodesById = new Map<number, OsmNode>()
@ -57,7 +130,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
})
const now = new Date()
const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({
feature: osmNode.asGeoJson(),freshness: now
feature: osmNode.asGeoJson(), freshness: now
}))
const featureSource = new SimpleFeatureSource(this.layer, tileId)
@ -66,5 +139,12 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
this.onTileLoaded(featureSource)
}
}
export interface ConflationConfig {
withinRangeOfM: number,
ifMatches: TagsFilter,
mode: "reuse_osm_point" | "move_osm_point"
}

View file

@ -68,7 +68,7 @@ export default class OsmFeatureSource {
console.log("Tile download", Tiles.tile_from_index(neededTile).join("/"), "started")
self.downloadedTiles.add(neededTile)
self.LoadTile(...Tiles.tile_from_index(neededTile)).then(_ => {
console.log("Tile ", Tiles.tile_from_index(neededTile).join("/"), "loaded")
console.debug("Tile ", Tiles.tile_from_index(neededTile).join("/"), "loaded")
})
}
} catch (e) {
@ -98,7 +98,7 @@ export default class OsmFeatureSource {
console.log("Attempting to get tile", z, x, y, "from the osm api")
const osmJson = await Utils.downloadJson(url)
try {
console.log("Got tile", z, x, y, "from the osm api")
console.debug("Got tile", z, x, y, "from the osm api")
this.rawDataHandlers.forEach(handler => handler(osmJson, Tiles.tile_index(z, x, y)))
const geojson = OsmToGeoJson.default(osmJson,
// @ts-ignore
@ -110,10 +110,8 @@ export default class OsmFeatureSource {
// We only keep what is needed
geojson.features = geojson.features.filter(feature => this.allowedTags.matchesProperties(feature.properties))
geojson.features.forEach(f => f.properties["_backend"] = this._backend)
console.log("Tile geojson:", z, x, y, "is", geojson)
const index = Tiles.tile_index(z, x, y);
new PerLayerFeatureSourceSplitter(this.filteredLayers,
this.handleTile,

View file

@ -11,7 +11,7 @@ export default class ChangeTagAction extends OsmChangeAction {
constructor(elementId: string, tagsFilter: TagsFilter, currentTags: any, meta: {
theme: string,
changeType: "answer" | "soft-delete" | "add-image"
changeType: "answer" | "soft-delete" | "add-image" | string
}) {
super();
this._elementId = elementId;
@ -27,11 +27,16 @@ export default class ChangeTagAction extends OsmChangeAction {
const key = kv.k;
const value = kv.v;
if (key === undefined || key === null) {
console.log("Invalid key");
console.error("Invalid key:", key);
return undefined;
}
if (value === undefined || value === null) {
console.log("Invalid value for ", key);
console.error("Invalid value for ", key,":", value);
return undefined;
}
if(typeof value !== "string"){
console.error("Invalid value for ", key, "as it is not a string:", value)
return undefined;
}

View file

@ -4,39 +4,25 @@ import {Changes} from "../Changes";
import {Tag} from "../../Tags/Tag";
import CreateNewNodeAction from "./CreateNewNodeAction";
import {And} from "../../Tags/And";
import {TagsFilter} from "../../Tags/TagsFilter";
export default class CreateNewWayAction extends OsmChangeAction {
public newElementId: string = undefined
private readonly coordinates: ({ nodeId?: number, lat: number, lon: number })[];
private readonly tags: Tag[];
private readonly _options: {
theme: string, existingPointHandling?: {
withinRangeOfM: number,
ifMatches?: TagsFilter,
mode: "reuse_osm_point" | "move_osm_point"
} []
theme: string
};
/***
* Creates a new way to upload to OSM
* @param tags: the tags to apply to the wya
* @param tags: the tags to apply to the way
* @param coordinates: the coordinates. Might have a nodeId, in this case, this node will be used
* @param options
*/
constructor(tags: Tag[], coordinates: ({ nodeId?: number, lat: number, lon: number })[],
options: {
theme: string,
/**
* IF specified, an existing OSM-point within this range and satisfying the condition 'ifMatches' will be used instead of a new coordinate.
* If multiple points are possible, only the closest point is considered
*/
existingPointHandling?: {
withinRangeOfM: number,
ifMatches?: TagsFilter,
mode: "reuse_osm_point" | "move_osm_point"
} []
theme: string
}) {
super()
this.coordinates = coordinates;

View file

@ -0,0 +1,232 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {Tag} from "../../Tags/Tag";
import FeatureSource from "../../FeatureSource/FeatureSource";
import {OsmNode, OsmObject, OsmWay} from "../OsmObject";
import {GeoOperations} from "../../GeoOperations";
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource";
import CreateNewNodeAction from "./CreateNewNodeAction";
import ChangeTagAction from "./ChangeTagAction";
import {And} from "../../Tags/And";
import {Utils} from "../../../Utils";
import {OsmConnection} from "../OsmConnection";
export default class ReplaceGeometryAction extends OsmChangeAction {
private readonly feature: any;
private readonly state: {
osmConnection: OsmConnection
};
private readonly wayToReplaceId: string;
private readonly theme: string;
private readonly targetCoordinates: [number, number][];
private readonly newTags: Tag[] | undefined;
constructor(
state: {
osmConnection: OsmConnection
},
feature: any,
wayToReplaceId: string,
options: {
theme: string,
newTags?: Tag[]
}
) {
super();
this.state = state;
this.feature = feature;
this.wayToReplaceId = wayToReplaceId;
this.theme = options.theme;
const geom = this.feature.geometry
let coordinates: [number, number][]
if (geom.type === "LineString") {
coordinates = geom.coordinates
} else if (geom.type === "Polygon") {
coordinates = geom.coordinates[0]
}
this.targetCoordinates = coordinates
this.newTags = options.newTags
}
public async GetPreview(): Promise<FeatureSource> {
const {closestIds, allNodesById} = await this.GetClosestIds();
const preview = closestIds.map((newId, i) => {
if (newId === undefined) {
return {
type: "Feature",
properties: {
"newpoint": "yes",
"id": "replace-geometry-move-" + i
},
geometry: {
type: "Point",
coordinates: this.targetCoordinates[i]
}
};
}
const origPoint = allNodesById.get(newId).centerpoint()
return {
type: "Feature",
properties: {
"move": "yes",
"osm-id": newId,
"id": "replace-geometry-move-" + i
},
geometry: {
type: "LineString",
coordinates: [[origPoint[1], origPoint[0]], this.targetCoordinates[i]]
}
};
})
return new StaticFeatureSource(preview, false)
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const allChanges: ChangeDescription[] = []
const actualIdsToUse: number[] = []
const {closestIds, osmWay} = await this.GetClosestIds()
for (let i = 0; i < closestIds.length; i++) {
const closestId = closestIds[i];
const [lon, lat] = this.targetCoordinates[i]
if (closestId === undefined) {
const newNodeAction = new CreateNewNodeAction(
[],
lat, lon,
{
allowReuseOfPreviouslyCreatedPoints: true,
theme: this.theme, changeType: null
})
const changeDescr = await newNodeAction.CreateChangeDescriptions(changes)
allChanges.push(...changeDescr)
actualIdsToUse.push(newNodeAction.newElementIdNumber)
} else {
const change = <ChangeDescription>{
id: closestId,
type: "node",
meta: {
theme: this.theme,
changeType: "move"
},
changes: {lon, lat}
}
actualIdsToUse.push(closestId)
allChanges.push(change)
}
}
if (this.newTags !== undefined && this.newTags.length > 0) {
const addExtraTags = new ChangeTagAction(
this.wayToReplaceId,
new And(this.newTags),
osmWay.tags, {
theme: this.theme,
changeType: "conflation"
}
)
allChanges.push(...await addExtraTags.CreateChangeDescriptions(changes))
}
// AT the very last: actually change the nodes of the way!
allChanges.push({
type: "way",
id: osmWay.id,
changes: {
nodes: actualIdsToUse,
coordinates: this.targetCoordinates
},
meta: {
theme: this.theme,
changeType: "conflation"
}
})
return allChanges
}
/**
* For 'this.feature`, gets a corresponding closest node that alreay exsists
* @constructor
* @private
*/
private async GetClosestIds(): Promise<{ closestIds: number[], allNodesById: Map<number, OsmNode>, osmWay: OsmWay }> {
// TODO FIXME: cap move length on points which are embedded into other ways (ev. disconnect them)
// TODO FIXME: if a new point has to be created, snap to already existing ways
// TODO FIXME: reuse points if they are the same in the target coordinates
const splitted = this.wayToReplaceId.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
if (idN < 0 || type !== "way") {
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${this.state.osmConnection._oauth_config.url}/api/0.6/${this.wayToReplaceId}/full`;
const rawData = await Utils.downloadJsonCached(url, 1000)
const parsed = OsmObject.ParseObjects(rawData.elements);
const allNodesById = new Map<number, OsmNode>()
const allNodes = parsed.filter(o => o.type === "node")
for (const node of allNodes) {
allNodesById.set(node.id, <OsmNode>node)
}
/**
* Allright! We know all the nodes of the original way and all the nodes of the target coordinates.
* For each of the target coordinates, we search the closest, already existing point and reuse this point
*/
const closestIds = []
const distances = []
for (const target of this.targetCoordinates) {
let closestDistance = undefined
let closestId = undefined;
for (const osmNode of allNodes) {
const cp = osmNode.centerpoint()
const d = GeoOperations.distanceBetween(target, [cp[1], cp[0]])
if (closestId === undefined || closestDistance > d) {
closestId = osmNode.id
closestDistance = d
}
}
closestIds.push(closestId)
distances.push(closestDistance)
}
// Next step: every closestId can only occur once in the list
for (let i = 0; i < closestIds.length; i++) {
const closestId = closestIds[i]
for (let j = i + 1; j < closestIds.length; j++) {
const otherClosestId = closestIds[j]
if (closestId !== otherClosestId) {
continue
}
// We have two occurences of 'closestId' - we only keep the closest instance!
const di = distances[i]
const dj = distances[j]
if (di < dj) {
closestIds[j] = undefined
} else {
closestIds[i] = undefined
}
}
}
const osmWay = <OsmWay>parsed[parsed.length - 1]
if (osmWay.type !== "way") {
throw "WEIRD: expected an OSM-way as last element here!"
}
return {closestIds, allNodesById, osmWay};
}
}

View file

@ -114,7 +114,16 @@ export class Changes {
}
public async applyAction(action: OsmChangeAction): Promise<void> {
const changes = await action.Perform(this)
this.applyChanges(await action.Perform(this))
}
public async applyActions(actions: OsmChangeAction[]) {
for (const action of actions) {
await this.applyAction(action)
}
}
public applyChanges(changes: ChangeDescription[]) {
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
@ -126,6 +135,7 @@ export class Changes {
CreateNewNodeAction.registerIdRewrites(mappings)
}
/**
* UPload the selected changes to OSM.
* Returns 'true' if successfull and if they can be removed

View file

@ -14,6 +14,7 @@ import {QueryParameters} from "../Web/QueryParameters";
import * as personal from "../../assets/themes/personal/personal.json";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer";
import {Coord} from "@turf/turf";
/**
* Contains all the leaflet-map related state
@ -44,13 +45,7 @@ export default class MapState extends UserRelatedState {
/**
* The location as delivered by the GPS
*/
public currentGPSLocation: UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}> = new UIEventSource<{
latlng: { lat: number; lng: number };
accuracy: number;
}>(undefined);
public currentGPSLocation: UIEventSource<Coordinates> = new UIEventSource<Coordinates>(undefined);
public readonly mainMapObject: BaseUIElement & MinimapObj;