More work on splitting roads, WIP; refactoring tests

This commit is contained in:
pietervdvn 2021-09-22 05:02:09 +02:00
parent e374bb355c
commit 1f93923820
62 changed files with 1163 additions and 823 deletions

View file

@ -2,12 +2,13 @@ import {UIEventSource} from "../UIEventSource";
import {OsmObject} from "../Osm/OsmObject";
import Loc from "../../Models/Loc";
import {ElementStorage} from "../ElementStorage";
import FeaturePipeline from "../FeatureSource/FeaturePipeline";
/**
* Makes sure the hash shows the selected element and vice-versa.
*/
export default class SelectedFeatureHandler {
private static readonly _no_trigger_on = ["welcome", "copyright", "layers", "new"]
private static readonly _no_trigger_on = new Set(["welcome", "copyright", "layers", "new", "", undefined])
hash: UIEventSource<string>;
private readonly state: {
selectedElement: UIEventSource<any>
@ -17,30 +18,35 @@ export default class SelectedFeatureHandler {
hash: UIEventSource<string>,
state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage;
allElements: ElementStorage,
featurePipeline: FeaturePipeline
}
) {
this.hash = hash;
this.state = state
// Getting a blank hash clears the selected element
hash.addCallback(h => {
// If the hash changes, set the selected element correctly
function setSelectedElementFromHash(h){
if (h === undefined || h === "") {
// Hash has been cleared - we clear the selected element
state.selectedElement.setData(undefined);
}else{
// we search the element to select
const feature = state.allElements.ContainingFeatures.get(h)
if(feature !== undefined){
state.selectedElement.setData(feature)
}
}
})
}
hash.addCallback(setSelectedElementFromHash)
// IF the selected element changes, set the hash correctly
state.selectedElement.addCallback(feature => {
if (feature === undefined) {
console.trace("Resetting hash")
if (SelectedFeatureHandler._no_trigger_on.indexOf(hash.data) < 0) {
if (SelectedFeatureHandler._no_trigger_on.has(hash.data)) {
hash.setData("")
}
}
@ -50,13 +56,26 @@ export default class SelectedFeatureHandler {
hash.setData(h)
}
})
state.featurePipeline.newDataLoadedSignal.addCallbackAndRunD(_ => {
// New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet
if(hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)){
// This is an invalid hash anyway
return;
}
if(state.selectedElement.data !== undefined){
// We already have something selected
return;
}
setSelectedElementFromHash(hash.data)
})
}
// If a feature is selected via the hash, zoom there
public zoomToSelectedFeature(location: UIEventSource<Loc>) {
const hash = this.hash.data;
if (hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0) {
if (hash === undefined || SelectedFeatureHandler._no_trigger_on.has(hash)) {
return; // No valid feature selected
}
// We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure

View file

@ -2,65 +2,38 @@ import {UIEventSource} from "../UIEventSource";
import Translations from "../../UI/i18n/Translations";
import Locale from "../../UI/i18n/Locale";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import {ElementStorage} from "../ElementStorage";
import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
class TitleElement extends UIEventSource<string> {
export default class TitleHandler {
constructor(state) {
const currentTitle: UIEventSource<string> = state.selectedElement.map(
selected => {
console.log("UPdating title")
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _selectedFeature: UIEventSource<any>;
private readonly _allElementsStorage: ElementStorage;
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
super("MapComplete");
this._layoutToUse = layoutToUse;
this._selectedFeature = selectedFeature;
this._allElementsStorage = allElementsStorage;
this.syncWith(
this._selectedFeature.map(
selected => {
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete"
if (selected === undefined) {
return defaultTitle
}
const layout = layoutToUse.data;
const tags = selected.properties;
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = allElementsStorage.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
}
}
const layout = state.layoutToUse.data
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
if (selected === undefined) {
return defaultTitle
}
, [Locale.language, layoutToUse]
)
const tags = selected.properties;
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = state.allElements.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
}
}
return defaultTitle
}, [Locale.language, state.layoutToUse]
)
}
}
export default class TitleHandler {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => {
currentTitle.addCallbackAndRunD(title => {
document.title = title
})
}

View file

@ -88,7 +88,7 @@ export class ExtraFunction {
{
name: "distanceTo",
doc: "Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object",
args: ["longitude", "latitude"]
args: ["feature OR featureID OR longitude", "undefined OR latitude"]
},
(featuresPerLayer, feature) => {
return (arg0, lat) => {
@ -128,10 +128,10 @@ export class ExtraFunction {
private static readonly ClosestNObjectFunc = new ExtraFunction(
{
name: "closestn",
doc: "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. " +
"Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet laoded)\n\n" +
doc: "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature (excluding the feature itself). In the case of ways/polygons, only the centerpoint is considered. " +
"Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet loaded)\n\n" +
"If a 'unique tag key' is given, the tag with this key will only appear once (e.g. if 'name' is given, all features will have a different name)",
args: ["list of features", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"]
args: ["list of features or layer name", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"]
},
(params, feature) => {
return (features, amount, uniqueTag, maxDistanceInMeters) => ExtraFunction.GetClosestNFeatures(params, feature, features, {

View file

@ -4,15 +4,13 @@
* Technically, more an Actor then a featuresource, but it fits more neatly this ay
*/
import {FeatureSourceForLayer} from "../FeatureSource";
import {Utils} from "../../../Utils";
export default class LocalStorageSaverActor {
export default class SaveTileToLocalStorageActor {
public static readonly storageKey: string = "cached-features";
constructor(source: FeatureSourceForLayer, x: number, y: number, z: number) {
constructor(source: FeatureSourceForLayer, tileIndex: number) {
source.features.addCallbackAndRunD(features => {
const index = Utils.tile_index(z, x, y)
const key = `${LocalStorageSaverActor.storageKey}-${source.layer.layerDef.id}-${index}`
const key = `${SaveTileToLocalStorageActor.storageKey}-${source.layer.layerDef.id}-${tileIndex}`
const now = new Date().getTime()
if (features.length == 0) {

View file

@ -1,202 +1,85 @@
import FeatureSource, {IndexedFeatureSource} from "./FeatureSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource} from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import {Changes} from "../Osm/Changes";
import {ChangeDescription} from "../Osm/Actions/ChangeDescription";
import {ChangeDescription, ChangeDescriptionTools} from "../Osm/Actions/ChangeDescription";
import {Utils} from "../../Utils";
import FilteredLayer from "../../Models/FilteredLayer";
import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject";
/**
* A feature source containing exclusively new elements
*/
export class NewGeometryChangeApplicatorFeatureSource implements FeatureSource{
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name: string = "newFeatures";
constructor(changes: Changes) {
const seenChanges = new Set<ChangeDescription>();
changes.pendingChanges.addCallbackAndRunD(changes => {
for (const change of changes) {
if(seenChanges.has(change)){
continue
}
seenChanges.add(change)
if(change.id < 0){
// This is a new object!
}
}
})
}
}
/**
* Applies changes from 'Changes' onto a featureSource
* Applies geometry changes from 'Changes' onto every feature of a featureSource
*/
export default class ChangeApplicator implements FeatureSource {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
export default class ChangeGeometryApplicator implements FeatureSourceForLayer {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string;
private readonly source: IndexedFeatureSource;
private readonly changes: Changes;
private readonly mode?: {
generateNewGeometries: boolean
};
public readonly layer: FilteredLayer
constructor(source: IndexedFeatureSource, changes: Changes, mode?: {
generateNewGeometries: boolean
}) {
constructor(source: (IndexedFeatureSource & FeatureSourceForLayer), changes: Changes) {
this.source = source;
this.changes = changes;
this.mode = mode;
this.layer = source.layer
this.name = "ChangesApplied(" + source.name + ")"
this.features = source.features
const seenChanges = new Set<ChangeDescription>();
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>(undefined)
const self = this;
let runningUpdate = false;
source.features.addCallbackAndRunD(features => {
if (runningUpdate) {
return; // No need to ping again
}
self.ApplyChanges()
seenChanges.clear()
})
source.features.addCallbackAndRunD(_ => self.update())
changes.allChanges.addCallbackAndRunD(_ => self.update())
changes.pendingChanges.addCallbackAndRunD(changes => {
runningUpdate = true;
changes = changes.filter(ch => !seenChanges.has(ch))
changes.forEach(c => seenChanges.add(c))
self.ApplyChanges()
source.features.ping()
runningUpdate = false;
})
}
private update() {
const upstreamFeatures = this.source.features.data
const upstreamIds = this.source.containedIds.data
const changesToApply = this.changes.allChanges.data
?.filter(ch =>
// Does upsteram have this element? If not, we skip
upstreamIds.has(ch.type + "/" + ch.id) &&
// Are any (geometry) changes defined?
ch.changes !== undefined &&
// Ignore new elements, they are handled by the NewGeometryFromChangesFeatureSource
ch.id > 0)
/**
* Returns true if the geometry is changed and the source should be pinged
*/
private ApplyChanges(): boolean {
const cs = this.changes.pendingChanges.data
const features = this.source.features.data
const loadedIds = this.source.containedIds
if (cs.length === 0 || features === undefined) {
if (changesToApply === undefined || changesToApply.length === 0) {
// No changes to apply!
// Pass the original feature and lets continue our day
this.features.setData(upstreamFeatures);
return;
}
console.log("Applying changes ", this.name, cs)
let geometryChanged = false;
const changesPerId: Map<string, ChangeDescription[]> = new Map<string, ChangeDescription[]>()
for (const c of cs) {
const id = c.type + "/" + c.id
if (!loadedIds.has(id)) {
continue
const changesPerId = new Map<string, ChangeDescription[]>()
for (const ch of changesToApply) {
const key = ch.type + "/" + ch.id
if(changesPerId.has(key)){
changesPerId.get(key).push(ch)
}else{
changesPerId.set(key, [ch])
}
if (!changesPerId.has(id)) {
changesPerId.set(id, [])
}
changesPerId.get(id).push(c)
}
if (changesPerId.size === 0) {
// The current feature source set doesn't contain any changed feature, so we can safely skip
return;
}
const now = new Date()
function add(feature) {
feature.id = feature.properties.id
features.push({
feature: feature,
freshness: now
})
console.log("Added a new feature: ", feature)
geometryChanged = true;
}
// First, create the new features - they have a negative ID
// We don't set the properties yet though
if (this.mode?.generateNewGeometries) {
changesPerId.forEach(cs => {
cs
.forEach(change => {
if (change.id >= 0) {
return; // Nothing to do here, already created
}
if (change.changes === undefined) {
// An update to the object - not the actual created
return;
}
try {
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break;
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
add(w.asGeoJson())
break;
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
add(r.asGeoJson())
break;
}
} catch (e) {
console.error(e)
}
})
})
}
for (const feature of features) {
const f = feature.feature;
const id = f.properties.id;
if (!changesPerId.has(id)) {
const newFeatures: { feature: any, freshness: Date }[] = []
for (const feature of upstreamFeatures) {
const changesForFeature = changesPerId.get(feature.feature.properties.id)
if (changesForFeature === undefined) {
// No changes for this element
newFeatures.push(feature)
continue;
}
const changed = {}
// Copy all the properties
Utils.Merge(f, changed)
// play the changes onto the copied object
for (const change of changesPerId.get(id)) {
for (const kv of change.tags ?? []) {
// Apply tag changes and ping the consumers
f.properties[kv.k] = kv.v;
}
// Apply other changes to the object
if (change.changes !== undefined) {
geometryChanged = true;
switch (change.type) {
case "node":
// @ts-ignore
const coor: { lat, lon } = change.changes;
f.geometry.coordinates = [coor.lon, coor.lat]
break;
case "way":
f.geometry.coordinates = change.changes["locations"]
break;
case "relation":
console.error("Changes to relations are not yet supported")
break;
}
}
// Allright! We have a feature to rewrite!
const copy = {
...feature
}
// We only apply the last change as that one'll have the latest geometry
const change = changesForFeature[changesForFeature.length - 1]
copy.feature.geometry = ChangeDescriptionTools.getGeojsonGeometry(change)
newFeatures.push(copy)
}
return geometryChanged
this.features.setData(newFeatures)
}
}

View file

@ -1,7 +1,7 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter";
import FeatureSource, {FeatureSourceForLayer, FeatureSourceState, Tiled} from "./FeatureSource";
import FeatureSource, {FeatureSourceForLayer, FeatureSourceState, IndexedFeatureSource, Tiled} from "./FeatureSource";
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource";
import {UIEventSource} from "../UIEventSource";
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
@ -14,13 +14,14 @@ import GeoJsonSource from "./Sources/GeoJsonSource";
import Loc from "../../Models/Loc";
import WayHandlingApplyingFeatureSource from "./Sources/WayHandlingApplyingFeatureSource";
import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor";
import {Utils} from "../../Utils";
import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource";
import LocalStorageSaverActor from "./Actors/LocalStorageSaverActor";
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor";
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource";
import {BBox} from "../GeoOperations";
import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger";
import RelationsTracker from "../Osm/RelationsTracker";
import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource";
import ChangeGeometryApplicator from "./ChangeApplicator";
export default class FeaturePipeline implements FeatureSourceState {
@ -29,10 +30,12 @@ export default class FeaturePipeline implements FeatureSourceState {
public readonly runningQuery: UIEventSource<boolean>;
public readonly timeout: UIEventSource<number>;
public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
private readonly overpassUpdater: OverpassFeatureSource
private readonly relationTracker: RelationsTracker
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>;
constructor(
handleFeatureSource: (source: FeatureSourceForLayer) => void,
state: {
@ -49,6 +52,7 @@ export default class FeaturePipeline implements FeatureSourceState {
const self = this
const updater = new OverpassFeatureSource(state);
updater.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(updater))
this.overpassUpdater = updater;
this.sufficientlyZoomed = updater.sufficientlyZoomed
this.runningQuery = updater.runningQuery
@ -56,16 +60,17 @@ export default class FeaturePipeline implements FeatureSourceState {
this.relationTracker = updater.relationsTracker
// Register everything in the state' 'AllElements'
new RegisteringAllFromFeatureSourceActor(updater)
const perLayerHierarchy = new Map<string, TileHierarchyMerger>()
this.perLayerHierarchy = perLayerHierarchy
const patchedHandleFeatureSource = function (src: FeatureSourceForLayer) {
const patchedHandleFeatureSource = function (src: FeatureSourceForLayer & IndexedFeatureSource) {
// This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile
const srcFiltered =
new FilteringFeatureSource(state,
new WayHandlingApplyingFeatureSource(
src,
new ChangeGeometryApplicator(src, state.changes)
)
)
handleFeatureSource(srcFiltered)
@ -90,6 +95,7 @@ export default class FeaturePipeline implements FeatureSourceState {
(src) => {
new RegisteringAllFromFeatureSourceActor(src)
hierarchy.registerTile(src);
src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src))
}, state)
continue
}
@ -103,6 +109,7 @@ export default class FeaturePipeline implements FeatureSourceState {
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
})
} else {
@ -113,6 +120,7 @@ export default class FeaturePipeline implements FeatureSourceState {
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile)
addToHierarchy(tile, id)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
}
}),
state
@ -122,64 +130,90 @@ export default class FeaturePipeline implements FeatureSourceState {
}
// Actually load data from the overpass source
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(source) => TiledFeatureSource.createHierarchy(source, {
layer: source.layer,
registerTile: (tile) => {
// We save the tile data for the given layer to local storage
const [z, x, y] = Utils.tile_from_index(tile.tileIndex)
new LocalStorageSaverActor(tile, x, y, z)
new SaveTileToLocalStorageActor(tile, tile.tileIndex)
addToHierarchy(tile, source.layer.layerDef.id);
}
}), new RememberingSource(updater))
}),
new RememberingSource(updater))
// Also load points/lines that are newly added.
const newGeometry = new NewGeometryFromChangesFeatureSource(state.changes)
new RegisteringAllFromFeatureSourceActor(newGeometry)
// A NewGeometryFromChangesFeatureSource does not split per layer, so we do this next
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(perLayer) => {
// We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
addToHierarchy(perLayer, perLayer.layer.layerDef.id)
// AT last, we always apply the metatags whenever possible
perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer))
},
newGeometry
)
// Whenever fresh data comes in, we need to update the metatagging
updater.features.addCallback(_ => {
self.newDataLoadedSignal.stabilized(1000).addCallback(src => {
console.log("Got an update from ", src.name)
self.updateAllMetaTagging()
})
}
private applyMetaTags(src: FeatureSourceForLayer){
const self = this
MetaTagging.addMetatags(
src.features.data,
{
memberships: this.relationTracker,
getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox)
},
src.layer.layerDef,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer
}
)
}
private updateAllMetaTagging() {
console.log("Updating the meta tagging")
const self = this;
this.perLayerHierarchy.forEach(hierarchy => {
hierarchy.loadedTiles.forEach(src => {
MetaTagging.addMetatags(
src.features.data,
{
memberships: this.relationTracker,
getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox)
},
src.layer.layerDef
)
self.applyMetaTags(src)
})
})
}
public GetAllFeaturesWithin(bbox: BBox): any[][]{
public GetAllFeaturesWithin(bbox: BBox): any[][] {
const self = this
const tiles = []
Array.from(this.perLayerHierarchy.keys())
.forEach(key => tiles.push(...self.GetFeaturesWithin(key, bbox)))
return tiles;
}
public GetFeaturesWithin(layerId: string, bbox: BBox): any[][]{
public GetFeaturesWithin(layerId: string, bbox: BBox): any[][] {
const requestedHierarchy = this.perLayerHierarchy.get(layerId)
if (requestedHierarchy === undefined) {
console.warn("Layer ", layerId, "is not defined. Try one of ", Array.from(this.perLayerHierarchy.keys()))
return undefined;
}
return TileHierarchyTools.getTiles(requestedHierarchy, bbox)
.filter(featureSource => featureSource.features?.data !== undefined)
.map(featureSource => featureSource.features.data.map(fs => fs.feature))
}
public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void){
Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => {
public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void) {
Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => {
TileHierarchyTools.getTiles(hierarchy, bbox).forEach(handleTile)
})
}

View file

@ -1,4 +1,4 @@
import FeatureSource, {FeatureSourceForLayer} from "./FeatureSource";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import FilteredLayer from "../../Models/FilteredLayer";
import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
@ -12,10 +12,13 @@ import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
export default class PerLayerFeatureSourceSplitter {
constructor(layers: UIEventSource<FilteredLayer[]>,
handleLayerData: (source: FeatureSourceForLayer) => void,
upstream: FeatureSource) {
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
upstream: FeatureSource,
options?:{
handleLeftovers?: (featuresWithoutLayer: any[]) => void
}) {
const knownLayers = new Map<string, FeatureSourceForLayer>()
const knownLayers = new Map<string, FeatureSourceForLayer & Tiled>()
function update() {
const features = upstream.features.data;
@ -30,7 +33,7 @@ export default class PerLayerFeatureSourceSplitter {
// Note that this splitter is only run when it is invoked by the overpass feature source, so we can't be sure in which layer it should go
const featuresPerLayer = new Map<string, { feature, freshness } []>();
const noLayerFound = []
function addTo(layer: FilteredLayer, feature: { feature, freshness }) {
const id = layer.layerDef.id
const list = featuresPerLayer.get(id)
@ -51,6 +54,7 @@ export default class PerLayerFeatureSourceSplitter {
break;
}
}
noLayerFound.push(f)
}
}
@ -75,6 +79,11 @@ export default class PerLayerFeatureSourceSplitter {
featureSource.features.setData(features)
}
}
// AT last, the leftovers are handled
if(options?.handleLeftovers !== undefined && noLayerFound.length > 0){
options.handleLeftovers(noLayerFound)
}
}
layers.addCallback(_ => update())

View file

@ -3,12 +3,12 @@
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
*/
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {BBox} from "../../GeoOperations";
import {Utils} from "../../../Utils";
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled {
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
@ -16,6 +16,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
private readonly _sources: UIEventSource<FeatureSource[]>;
public readonly tileIndex: number;
public readonly bbox: BBox;
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(new Set())
constructor(layer: FilteredLayer, tileIndex: number, bbox: BBox, sources: UIEventSource<FeatureSource[]>) {
this.tileIndex = tileIndex;
@ -54,7 +55,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
// We seed the dictionary with the previously loaded features
const oldValues = this.features.data ?? [];
for (const oldValue of oldValues) {
all.set(oldValue.feature.id + oldValue.feature._matching_layer_id, oldValue)
all.set(oldValue.feature.id, oldValue)
}
for (const source of this._sources.data) {
@ -62,7 +63,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
continue;
}
for (const f of source.features.data) {
const id = f.feature.properties.id + f.feature._matching_layer_id;
const id = f.feature.properties.id;
if (!all.has(id)) {
// This is a new feature
somethingChanged = true;
@ -90,6 +91,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
all.forEach((value, _) => {
newList.push(value)
})
this.containedIds.setData(new Set(all.keys()))
this.features.setData(newList);
}

View file

@ -0,0 +1,90 @@
import {Changes} from "../../Osm/Changes";
import {OsmNode, OsmRelation, OsmWay} from "../../Osm/OsmObject";
import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {ChangeDescription} from "../../Osm/Actions/ChangeDescription";
export class NewGeometryFromChangesFeatureSource implements FeatureSource {
// This class name truly puts the 'Java' into 'Javascript'
/**
* A feature source containing exclusively new elements
*/
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "newFeatures";
constructor(changes: Changes) {
const seenChanges = new Set<ChangeDescription>();
const features = this.features.data;
const self = this;
changes.pendingChanges
.map(changes => changes.filter(ch =>
// only new objects allowed
ch.id < 0 &&
// The change is an update to the object (e.g. tags or geometry) - not the actual create
ch.changes !== undefined &&
// If tags is undefined, this is probably a new point that is part of a split road
ch.tags !== undefined &&
// Already handled
!seenChanges.has(ch)))
.addCallbackAndRunD(changes => {
if (changes.length === 0) {
return;
}
const now = new Date();
function add(feature) {
feature.id = feature.properties.id
features.push({
feature: feature,
freshness: now
})
console.warn("Added a new feature: ", JSON.stringify(feature))
}
for (const change of changes) {
seenChanges.add(change)
try {
const tags = {}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
tags["id"] = change.type+"/"+change.id
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break;
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
add(w.asGeoJson())
break;
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
add(r.asGeoJson())
break;
}
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
self.features.ping()
})
}
}

View file

@ -1,16 +1,19 @@
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer} from "../FeatureSource";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {BBox} from "../../GeoOperations";
import {Utils} from "../../../Utils";
export default class SimpleFeatureSource implements FeatureSourceForLayer {
export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "SimpleFeatureSource";
public readonly layer: FilteredLayer;
public readonly bbox: BBox = BBox.global;
public readonly tileIndex: number = Utils.tile_index(0, 0, 0);
constructor(layer: FilteredLayer) {
this.name = "SimpleFeatureSource("+layer.layerDef.id+")"
this.name = "SimpleFeatureSource(" + layer.layerDef.id + ")"
this.layer = layer
}
}

View file

@ -1,6 +1,6 @@
import TileHierarchy from "./TileHierarchy";
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
import {BBox} from "../../GeoOperations";
@ -11,9 +11,9 @@ export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer
private readonly sources: Map<number, UIEventSource<FeatureSource[]>> = new Map<number, UIEventSource<FeatureSource[]>>();
public readonly layer: FilteredLayer;
private _handleTile: (src: FeatureSourceForLayer, index: number) => void;
private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void;
constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer, index: number) => void) {
constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void) {
this.layer = layer;
this._handleTile = handleTile;
}

View file

@ -4,7 +4,7 @@ import {UIEventSource} from "../../UIEventSource";
import Loc from "../../../Models/Loc";
import TileHierarchy from "./TileHierarchy";
import {Utils} from "../../../Utils";
import LocalStorageSaverActor from "../Actors/LocalStorageSaverActor";
import SaveTileToLocalStorageActor from "../Actors/SaveTileToLocalStorageActor";
import {BBox} from "../../GeoOperations";
export default class TiledFromLocalStorageSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
@ -17,7 +17,7 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
leafletMap: any
}) {
const prefix = LocalStorageSaverActor.storageKey + "-" + layer.layerDef.id + "-"
const prefix = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-"
// @ts-ignore
const indexes: number[] = Object.keys(localStorage)
.filter(key => {
@ -76,7 +76,7 @@ export default class TiledFromLocalStorageSource implements TileHierarchy<Featur
for (const neededIndex of neededIndexes) {
// We load the features from localStorage
try {
const key = LocalStorageSaverActor.storageKey + "-" + layer.layerDef.id + "-" + neededIndex
const key = SaveTileToLocalStorageActor.storageKey + "-" + layer.layerDef.id + "-" + neededIndex
const data = localStorage.getItem(key)
const features = JSON.parse(data)
const src = {

View file

@ -185,14 +185,14 @@ export class GeoOperations {
static lengthInMeters(feature: any) {
return turf.length(feature) * 1000
}
static buffer(feature: any, bufferSizeInMeter: number){
return turf.buffer(feature, bufferSizeInMeter/1000, {
static buffer(feature: any, bufferSizeInMeter: number) {
return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: 'kilometers'
})
}
static bbox(feature: any){
static bbox(feature: any) {
const [lon, lat, lon0, lat0] = turf.bbox(feature)
return {
"type": "Feature",
@ -226,6 +226,11 @@ export class GeoOperations {
/**
* Generates the closest point on a way from a given point
*
* The properties object will contain three values:
// - `index`: closest point was found on nth line part,
// - `dist`: distance between pt and the closest point (in kilometer),
// `location`: distance along the line between start and the closest point.
* @param way The road on which you want to find a point
* @param point Point defined as [lon, lat]
*/
@ -379,7 +384,7 @@ export class BBox {
readonly maxLon: number;
readonly minLat: number;
readonly minLon: number;
static global: BBox = new BBox([[-180,-90],[180,90]]);
static global: BBox = new BBox([[-180, -90], [180, 90]]);
constructor(coordinates) {
this.maxLat = Number.MIN_VALUE;
@ -447,7 +452,7 @@ export class BBox {
}
static fromTile(z: number, x: number, y: number) {
return new BBox( Utils.tile_bounds_lon_lat(z, x, y))
return new BBox(Utils.tile_bounds_lon_lat(z, x, y))
}
getEast() {
@ -465,4 +470,20 @@ export class BBox {
getSouth() {
return this.minLat
}
pad(factor: number) : BBox {
const latDiff = this.maxLat - this.minLat
const lat = (this.maxLat + this.minLat) / 2
const lonDiff = this.maxLon - this.minLon
const lon = (this.maxLon + this.minLon) / 2
return new BBox([[
lon - lonDiff * factor,
lat - latDiff * factor
], [lon + lonDiff * factor,
lat + latDiff * factor]])
}
toLeaflet() {
return [[this.minLat, this.minLon], [this.maxLat, this.maxLon]]
}
}

View file

@ -19,22 +19,31 @@ export default class MetaTagging {
* This method (re)calculates all metatags and calculated tags on every given object.
* The given features should be part of the given layer
*/
static addMetatags(features: { feature: any; freshness: Date }[],
public static addMetatags(features: { feature: any; freshness: Date }[],
params: ExtraFuncParams,
layer: LayerConfig,
includeDates = true) {
options?: {
includeDates?: true | boolean,
includeNonDates?: true | boolean
}) {
if (features === undefined || features.length === 0) {
return;
}
for (const metatag of SimpleMetaTagger.metatags) {
if (metatag.includesDates && !includeDates) {
// We do not add dated entries
continue;
}
try {
metatag.addMetaTags(features);
if (metatag.includesDates) {
if (options.includeDates ?? true) {
metatag.addMetaTags(features);
}
} else {
if (options.includeNonDates ?? true) {
metatag.addMetaTags(features);
}
}
} catch (e) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e)
}

View file

@ -1,3 +1,5 @@
import {OsmNode, OsmRelation, OsmWay} from "../OsmObject";
/**
* Represents a single change to an object
*/
@ -29,8 +31,8 @@ export interface ChangeDescription {
lat: number,
lon: number
} | {
// Coordinates are only used for rendering. They should be lon, lat
locations: [number, number][]
// Coordinates are only used for rendering. They should be LAT, LON
coordinates: [number, number][]
nodes: number[],
} | {
members: { type: "node" | "way" | "relation", ref: number, role: string }[]
@ -40,6 +42,26 @@ export interface ChangeDescription {
Set to delete the object
*/
doDelete?: boolean
}
export class ChangeDescriptionTools{
public static getGeojsonGeometry(change: ChangeDescription): any{
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
return n.asGeoJson().geometry
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
return w.asGeoJson().geometry
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
return r.asGeoJson().geometry
}
}
}

View file

@ -37,7 +37,7 @@ export default class ChangeTagAction extends OsmChangeAction {
return {k: key.trim(), v: value.trim()};
}
CreateChangeDescriptions(changes: Changes): ChangeDescription [] {
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange)
const typeId = this._elementId.split("/")
const type = typeId[0]

View file

@ -27,7 +27,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
this._reusePointDistance = options?.reusePointWithinMeters ?? 1
}
CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const id = changes.getNewID()
const properties = {
id: "node/" + id
@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
type: "way",
id: this._snapOnto.id,
changes: {
locations: locations,
coordinates: locations,
nodes: ids
}
}

View file

@ -159,7 +159,7 @@ export default class DeleteAction {
canBeDeleted: false,
reason: t.notEnoughExperience
})
return;
return true; // unregister this caller!
}
if (!useTheInternet) {
@ -167,13 +167,14 @@ export default class DeleteAction {
}
// All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations
OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => {
OsmObject.DownloadReferencingRelations(id).then(rels => {
hasRelations.setData(rels.length > 0)
})
OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => {
OsmObject.DownloadReferencingWays(id).then(ways => {
hasWays.setData(ways.length > 0)
})
return true; // unregister to only run once
})

View file

@ -17,7 +17,7 @@ export default abstract class OsmChangeAction {
return this.CreateChangeDescriptions(changes)
}
protected abstract CreateChangeDescriptions(changes: Changes): ChangeDescription[]
protected abstract CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]>
}

View file

@ -0,0 +1,142 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {OsmObject, OsmRelation, OsmWay} from "../OsmObject";
export interface RelationSplitInput {
relation: OsmRelation,
originalWayId: number,
allWayIdsInOrder: number[],
originalNodes: number[],
allWaysNodesInOrder: number[][]
}
/**
* When a way is split and this way is part of a relation, the relation should be updated too to have the new segment if relevant.
*/
export default class RelationSplitHandler extends OsmChangeAction {
constructor(input: RelationSplitInput) {
super()
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
return [];
}
}
/**
* A simple strategy to split relations:
* -> Download the way members just before and just after the original way
* -> Make sure they are still aligned
*
* Note that the feature might appear multiple times.
*/
export class InPlaceReplacedmentRTSH extends OsmChangeAction {
private readonly _input: RelationSplitInput;
constructor(input: RelationSplitInput) {
super();
this._input = input;
}
/**
* Returns which node should border the member at the given index
*/
private async targetNodeAt(i: number, first: boolean) {
const member = this._input.relation.members[i]
if (member === undefined) {
return undefined
}
if (member.type === "node") {
return member.ref
}
if (member.type === "way") {
const osmWay = <OsmWay>await OsmObject.DownloadObjectAsync("way/" + member.ref)
const nodes = osmWay.nodes
if (first) {
return nodes[0]
} else {
return nodes[nodes.length - 1]
}
}
if (member.type === "relation") {
return undefined
}
return undefined;
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const wayId = this._input.originalWayId
const relation = this._input.relation
const members = relation.members
const originalNodes = this._input.originalNodes;
const firstNode = originalNodes[0]
const lastNode = originalNodes[originalNodes.length - 1]
const newMembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = []
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member.type !== "way" || member.ref !== wayId) {
newMembers.push(member)
continue;
}
const nodeIdBefore = await this.targetNodeAt(i - 1, false)
const nodeIdAfter = await this.targetNodeAt(i + 1, true)
const firstNodeMatches = nodeIdBefore === undefined || nodeIdBefore === firstNode
const lastNodeMatches =nodeIdAfter === undefined || nodeIdAfter === lastNode
if (firstNodeMatches && lastNodeMatches) {
// We have a classic situation, forward situation
for (const wId of this._input.allWayIdsInOrder) {
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
continue;
}
const firstNodeMatchesRev = nodeIdBefore === undefined || nodeIdBefore === lastNode
const lastNodeMatchesRev =nodeIdAfter === undefined || nodeIdAfter === firstNode
if (firstNodeMatchesRev || lastNodeMatchesRev) {
// We (probably) have a reversed situation, backward situation
for (let i1 = this._input.allWayIdsInOrder.length - 1; i1 >= 0; i1--){
// Iterate BACKWARDS
const wId = this._input.allWayIdsInOrder[i1];
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
continue;
}
// Euhm, allright... Something weird is going on, but let's not care too much
// Lets pretend this is forward going
for (const wId of this._input.allWayIdsInOrder) {
newMembers.push({
ref: wId,
type: "way",
role: member.role
})
}
}
return [{
id: relation.id,
type: "relation",
changes: {members: newMembers}
}];
}
}

View file

@ -1,20 +0,0 @@
/**
* The logic to handle relations after a way within
*/
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {OsmRelation} from "../OsmObject";
export default class RelationSplitlHandler extends OsmChangeAction {
constructor(partOf: OsmRelation[], newWayIds: number[], originalNodes: number[]) {
super()
}
CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
return [];
}
}

View file

@ -1,9 +1,9 @@
import {OsmRelation, OsmWay} from "../OsmObject";
import {OsmObject, OsmWay} from "../OsmObject";
import {Changes} from "../Changes";
import {GeoOperations} from "../../GeoOperations";
import OsmChangeAction from "./OsmChangeAction";
import {ChangeDescription} from "./ChangeDescription";
import RelationSplitlHandler from "./RelationSplitlHandler";
import RelationSplitHandler from "./RelationSplitHandler";
interface SplitInfo {
originalIndex?: number, // or negative for new elements
@ -12,17 +12,13 @@ interface SplitInfo {
}
export default class SplitAction extends OsmChangeAction {
private readonly roadObject: any;
private readonly osmWay: OsmWay;
private _partOf: OsmRelation[];
private readonly _splitPoints: any[];
private readonly wayId: string;
private readonly _splitPointsCoordinates: [number, number] []// lon, lat
constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) {
constructor(wayId: string, splitPointCoordinates: [number, number][]) {
super()
this.osmWay = osmWay;
this.roadObject = wayGeoJson;
this._partOf = partOf;
this._splitPoints = splitPoints;
this.wayId = wayId;
this._splitPointsCoordinates = splitPointCoordinates
}
private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] {
@ -42,26 +38,17 @@ export default class SplitAction extends OsmChangeAction {
return wayParts.filter(wp => wp.length > 0)
}
CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
const splitPoints = this._splitPoints
// We mark the new split points with a new id
console.log(splitPoints)
for (const splitPoint of splitPoints) {
splitPoint.properties["_is_split_point"] = true
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const self = this;
const partOf = this._partOf
const originalElement = this.osmWay
const originalNodes = this.osmWay.nodes;
const originalElement = <OsmWay>await OsmObject.DownloadObjectAsync(this.wayId)
const originalNodes = originalElement.nodes;
// First, calculate splitpoints and remove points close to one another
const splitInfo = self.CalculateSplitCoordinates(splitPoints)
const splitInfo = self.CalculateSplitCoordinates(originalElement)
// Now we have a list with e.g.
// [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}]
// Lets change 'originalIndex' to the actual node id first:
// Lets change 'originalIndex' to the actual node id first (or assign a new id if needed):
for (const element of splitInfo) {
if (element.originalIndex >= 0) {
element.originalIndex = originalElement.nodes[element.originalIndex]
@ -102,25 +89,30 @@ export default class SplitAction extends OsmChangeAction {
})
}
const newWayIds: number[] = []
// The ids of all the ways (including the original)
const allWayIdsInOrder: number[] = []
const allWaysNodesInOrder: number[][] = []
// Lets create OsmWays based on them
for (const wayPart of wayParts) {
let isOriginal = wayPart === longest
if (isOriginal) {
// We change the actual element!
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({
type: "way",
id: originalElement.id,
changes: {
locations: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex)
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
}
})
allWayIdsInOrder.push(originalElement.id)
allWaysNodesInOrder.push(nodeIds)
} else {
let id = changes.getNewID();
newWayIds.push(id)
// Copy the tags from the original object onto the new
const kv = []
for (const k in originalElement.tags) {
if (!originalElement.tags.hasOwnProperty(k)) {
@ -131,22 +123,35 @@ export default class SplitAction extends OsmChangeAction {
}
kv.push({k: k, v: originalElement.tags[k]})
}
const nodeIds = wayPart.map(p => p.originalIndex)
changeDescription.push({
type: "way",
id: id,
tags: kv,
changes: {
locations: wayPart.map(p => p.lngLat),
nodes: wayPart.map(p => p.originalIndex)
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
}
})
}
allWayIdsInOrder.push(id)
allWaysNodesInOrder.push(nodeIds)
}
}
// At last, we still have to check that we aren't part of a relation...
// At least, the order of the ways is identical, so we can keep the same roles
changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).CreateChangeDescriptions(changes))
const relations = await OsmObject.DownloadReferencingRelations(this.wayId)
for (const relation of relations) {
const changDescrs = await new RelationSplitHandler({
relation: relation,
allWayIdsInOrder: allWayIdsInOrder,
originalNodes: originalNodes,
allWaysNodesInOrder: allWaysNodesInOrder,
originalWayId: originalElement.id
}).CreateChangeDescriptions(changes)
changeDescription.push(...changDescrs)
}
// And we have our objects!
// Time to upload
@ -158,75 +163,96 @@ export default class SplitAction extends OsmChangeAction {
* Calculates the actual points to split
* If another point is closer then ~5m, we reuse that point
*/
private CalculateSplitCoordinates(
splitPoints: any[],
toleranceInM = 5): SplitInfo[] {
private CalculateSplitCoordinates(osmWay: OsmWay, toleranceInM = 5): SplitInfo[] {
const wayGeoJson = osmWay.asGeoJson()
// Should be [lon, lat][]
const originalPoints = osmWay.coordinates.map(c => <[number, number]>c.reverse())
const allPoints: {
coordinates: [number, number],
isSplitPoint: boolean,
originalIndex?: number, // Original index
dist: number, // Distance from the nearest point on the original line
location: number // Distance from the start of the way
}[] = this._splitPointsCoordinates.map(c => {
// From the turf.js docs:
// The properties object will contain three values:
// - `index`: closest point was found on nth line part,
// - `dist`: distance between pt and the closest point,
// `location`: distance along the line between start and the closest point.
let projected = GeoOperations.nearestPoint(wayGeoJson, c)
return ({
coordinates: c,
isSplitPoint: true,
dist: projected.properties.dist,
location: projected.properties.location
});
})
const allPoints = [...splitPoints];
// We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ...
const originalPoints: [number, number][] = this.roadObject.geometry.coordinates
// We project them onto the line (which should yield pretty much the same point
// We have a bunch of coordinates here: [ [lon, lon], [lat, lon], ...] ...
// We project them onto the line (which should yield pretty much the same point and add them to allPoints
for (let i = 0; i < originalPoints.length; i++) {
let originalPoint = originalPoints[i];
let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint)
projected.properties["_is_split_point"] = false
projected.properties["_original_index"] = i
allPoints.push(projected)
let projected = GeoOperations.nearestPoint(wayGeoJson, originalPoint)
allPoints.push({
coordinates: originalPoint,
isSplitPoint: false,
location: projected.properties.location,
originalIndex: i,
dist: projected.properties.dist
})
}
// At this point, we have a list of both the split point and the old points, with some properties to discriminate between them
// We sort this list so that the new points are at the same location
allPoints.sort((a, b) => a.properties.location - b.properties.location)
allPoints.sort((a, b) => a.location - b.location)
// When this is done, we check that no now point is too close to an already existing point and no very small segments get created
/* for (let i = allPoints.length - 1; i > 0; i--) {
const point = allPoints[i];
if (point.properties._original_index !== undefined) {
// This point is already in OSM - we have to keep it!
continue;
}
if (i != allPoints.length - 1) {
const prevPoint = allPoints[i + 1]
const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000
if (diff <= toleranceInM) {
// To close to the previous point! We delete this point...
allPoints.splice(i, 1)
// ... and mark the previous point as a split point
prevPoint.properties._is_split_point = true
continue;
}
}
if (i > 0) {
const nextPoint = allPoints[i - 1]
const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000
if (diff <= toleranceInM) {
// To close to the next point! We delete this point...
allPoints.splice(i, 1)
// ... and mark the next point as a split point
nextPoint.properties._is_split_point = true
// noinspection UnnecessaryContinueJS
continue;
}
}
// We don't have to remove this point...
}*/
for (let i = allPoints.length - 2; i >= 1; i--) {
// We 'merge' points with already existing nodes if they are close enough to avoid closeby elements
// Note the loop bounds: we skip the first two and last two elements:
// The first and last element are always part of the original way and should be kept
// Furthermore, we run in reverse order as we'll delete elements on the go
const point = allPoints[i]
if (point.originalIndex !== undefined) {
// We keep the original points
continue
}
if (point.dist * 1000 >= toleranceInM) {
// No need to remove this one
continue
}
// At this point, 'dist' told us the point is pretty close to an already existing point.
// Lets see which (already existing) point is closer and mark it as splitpoint
const nextPoint = allPoints[i + 1]
const prevPoint = allPoints[i - 1]
const distToNext = nextPoint.location - point.location
const distToPrev = prevPoint.location - point.location
let closest = nextPoint
if (distToNext > distToPrev) {
closest = prevPoint
}
// Ok, we have a closest point!
closest.isSplitPoint = true;
allPoints.splice(i, 1)
}
const splitInfo: SplitInfo[] = []
let nextId = -1
let nextId = -1 // Note: these IDs are overwritten later on, no need to use a global counter here
for (const p of allPoints) {
let index = p.properties._original_index
let index = p.originalIndex
if (index === undefined) {
index = nextId;
nextId--;
}
const splitInfoElement = {
originalIndex: index,
lngLat: p.geometry.coordinates,
doSplit: p.properties._is_split_point
lngLat: p.coordinates,
doSplit: p.isSplitPoint
}
splitInfo.push(splitInfoElement)
}

View file

@ -21,12 +21,15 @@ export class Changes {
*/
public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
public readonly pendingChanges = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly pendingChanges: UIEventSource<ChangeDescription[]> = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
private readonly isUploading = new UIEventSource(false);
private readonly previouslyCreated: OsmObject[] = []
constructor() {
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
}
private static createChangesetFor(csId: string,
@ -146,10 +149,13 @@ export class Changes {
}
public applyAction(action: OsmChangeAction) {
const changes = action.Perform(this)
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
action.Perform(this).then(changes => {
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
this.allChanges.data.push(...changes)
this.allChanges.ping()
})
}
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {

View file

@ -1,6 +1,7 @@
import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json";
import {UIEventSource} from "../UIEventSource";
import {BBox} from "../GeoOperations";
export abstract class OsmObject {
@ -9,11 +10,12 @@ export abstract class OsmObject {
protected static backendURL = OsmObject.defaultBackend;
private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>();
private static referencingRelationsCache = new Map<string, UIEventSource<OsmRelation[]>>();
private static historyCache = new Map<string, UIEventSource<OsmObject[]>>();
type: string;
id: number;
/**
* The OSM tags as simple object
*/
tags: {} = {};
version: number;
public changed: boolean = false;
@ -37,7 +39,7 @@ export abstract class OsmObject {
this.backendURL = url;
}
static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
public static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
let src: UIEventSource<OsmObject>;
if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id)
@ -47,80 +49,62 @@ export abstract class OsmObject {
return src;
}
} else {
src = new UIEventSource<OsmObject>(undefined)
src = UIEventSource.FromPromise(OsmObject.DownloadObjectAsync(id))
}
OsmObject.objectCache.set(id, src);
return src;
}
static async DownloadObjectAsync(id: string): Promise<OsmObject> {
const splitted = id.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
if (idN < 0) {
return;
return undefined;
}
OsmObject.objectCache.set(id, src);
const newContinuation = (element: OsmObject) => {
src.setData(element)
}
switch (type) {
case("node"):
new OsmNode(idN).Download(newContinuation);
break;
return await new OsmNode(idN).Download();
case("way"):
new OsmWay(idN).Download(newContinuation);
break;
return await new OsmWay(idN).Download();
case("relation"):
new OsmRelation(idN).Download(newContinuation);
break;
return await new OsmRelation(idN).Download();
default:
throw "Invalid object type:" + type + id;
throw ("Invalid object type:" + type + id);
}
return src;
}
/**
* Downloads the ways that are using this node.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingWays(id: string): UIEventSource<OsmWay[]> {
if (OsmObject.referencingWaysCache.has(id)) {
return OsmObject.referencingWaysCache.get(id);
}
const waysSrc = new UIEventSource<OsmWay[]>([])
OsmObject.referencingWaysCache.set(id, waysSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`)
.then(data => {
const ways = data.elements.map(wayInfo => {
public static DownloadReferencingWays(id: string): Promise<OsmWay[]> {
return Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`).then(
data => {
return data.elements.map(wayInfo => {
const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo)
return way
})
waysSrc.setData(ways)
})
return waysSrc;
}
)
}
/**
* Downloads the relations that are using this feature.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingRelations(id: string): UIEventSource<OsmRelation[]> {
if (OsmObject.referencingRelationsCache.has(id)) {
return OsmObject.referencingRelationsCache.get(id);
}
const relsSrc = new UIEventSource<OsmRelation[]>(undefined)
OsmObject.referencingRelationsCache.set(id, relsSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
.then(data => {
const rels = data.elements.map(wayInfo => {
const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo)
return rel
})
relsSrc.setData(rels)
})
return relsSrc;
public static async DownloadReferencingRelations(id: string): Promise<OsmRelation[]> {
const data = await Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
return data.elements.map(wayInfo => {
const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo)
return rel
})
}
public static DownloadHistory(id: string): UIEventSource<OsmObject []> {
@ -158,18 +142,11 @@ export abstract class OsmObject {
}
// bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds)
public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) {
const minlon = bounds[0][1]
const maxlon = bounds[1][1]
const minlat = bounds[1][0]
const maxlat = bounds[0][0];
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
Utils.downloadJson(url).then(data => {
const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements)
callback(objects);
})
public static async LoadArea(bbox: BBox): Promise<OsmObject[]> {
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
const data = await Utils.downloadJson(url)
const elements: any[] = data.elements;
return OsmObject.ParseObjects(elements);
}
public static DownloadAll(neededIds, forceRefresh = true): UIEventSource<OsmObject[]> {
@ -283,39 +260,34 @@ export abstract class OsmObject {
return tags;
}
Download(continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) {
/**
* Downloads the object, a full download for ways and relations
* @constructor
*/
async Download(): Promise<OsmObject> {
const self = this;
const full = this.type !== "way" ? "" : "/full";
const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`;
Utils.downloadJson(url).then(data => {
return await Utils.downloadJson(url).then(data => {
const element = data.elements.pop();
let nodes = []
if (self.type === "way" && data.elements.length >= 0) {
nodes = OsmObject.ParseObjects(data.elements)
}
if (self.type === "rellation") {
throw "We should save some extra data"
}
self.LoadData(element)
self.SaveExtraData(element, nodes);
const meta = {
"_last_edit:contributor": element.user,
"_last_edit:contributor:uid": element.uid,
"_last_edit:changeset": element.changeset,
"_last_edit:timestamp": new Date(element.timestamp),
"_version_number": element.version
}
if (OsmObject.backendURL !== OsmObject.defaultBackend) {
self.tags["_backend"] = OsmObject.backendURL
meta["_backend"] = OsmObject.backendURL;
}
continuation(self, meta);
return this;
}
);
return this;
}
@ -389,18 +361,10 @@ export class OsmNode extends OsmObject {
}
}
export interface OsmObjectMeta {
"_last_edit:contributor": string,
"_last_edit:contributor:uid": number,
"_last_edit:changeset": number,
"_last_edit:timestamp": Date,
"_version_number": number
}
export class OsmWay extends OsmObject {
nodes: number[];
// The coordinates of the way, [lat, lon][]
coordinates: [number, number][] = []
lat: number;
lon: number;
@ -455,12 +419,16 @@ export class OsmWay extends OsmObject {
}
public asGeoJson() {
let coordinates : ([number, number][] | [number, number][][]) = this.coordinates.map(c => <[number, number]>c.reverse());
if(this.isPolygon()){
coordinates = [coordinates]
}
return {
"type": "Feature",
"properties": this.tags,
"geometry": {
"type": this.isPolygon() ? "Polygon" : "LineString",
"coordinates": this.coordinates.map(c => [c[1], c[0]])
"coordinates": coordinates
}
}
}
@ -511,7 +479,7 @@ ${members}${tags} </relation>
this.members = element.members;
}
asGeoJson() {
asGeoJson(): any {
throw "Not Implemented"
}
}

View file

@ -60,7 +60,12 @@ export class UIEventSource<T> {
run();
return source;
}
public static FromPromise<T>(promise : Promise<T>): UIEventSource<T>{
const src = new UIEventSource<T>(undefined)
promise.then(d => src.setData(d))
return src
}
/**
@ -191,6 +196,14 @@ export class UIEventSource<T> {
}
})
}
addCallbackD(callback: (data: T) => void) {
this.addCallback(data => {
if (data !== undefined && data !== null) {
return callback(data)
}
})
}
}
export class UIEventSourceTools {

View file

@ -9,7 +9,7 @@ export default class Hash {
public static hash: UIEventSource<string> = Hash.Get();
/**
* Gets the current string, including the pound sign
* Gets the current string, including the pound sign if there is any
* @constructor
*/
public static Current(): string {

View file

@ -127,7 +127,6 @@ export class QueryParameters {
parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data))
}
// Don't pollute the history every time a parameter changes
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current());
}

View file

@ -2,7 +2,7 @@ import {Utils} from "../Utils";
export default class Constants {
public static vNumber = "0.10.0";
public static vNumber = "0.10.0-alpha-0";
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {

View file

@ -433,7 +433,7 @@ export default class State {
})
.ping();
new TitleHandler(this.layoutToUse, this.selectedElement, this.allElements);
new TitleHandler(this);
}
private static asFloat(source: UIEventSource<string>): UIEventSource<number> {

View file

@ -16,6 +16,11 @@ export interface MinimapOptions {
lastClickLocation?: UIEventSource<{ lat: number, lon: number }>
}
export interface MinimapObj {
readonly leafletMap: UIEventSource<any>,
installBounds(factor: number | BBox, showRange?: boolean) : void
}
export default class Minimap {
/**
* A stub implementation. The actual implementation is injected later on, but only in the browser.
@ -25,6 +30,6 @@ export default class Minimap {
/**
* Construct a minimap
*/
public static createMiniMap: (options: MinimapOptions) => BaseUIElement & { readonly leafletMap: UIEventSource<any> }
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj)
}

View file

@ -7,9 +7,9 @@ import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox} from "../../Logic/GeoOperations";
import * as L from "leaflet";
import {Map} from "leaflet";
import Minimap, {MinimapOptions} from "./Minimap";
import Minimap, {MinimapObj, MinimapOptions} from "./Minimap";
export default class MinimapImplementation extends BaseUIElement {
export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0;
public readonly leafletMap: UIEventSource<Map>
private readonly _id: string;
@ -44,6 +44,65 @@ export default class MinimapImplementation extends BaseUIElement {
Minimap.createMiniMap = options => new MinimapImplementation(options)
}
public installBounds(factor: number | BBox, showRange?: boolean) {
this.leafletMap.addCallbackD(leaflet => {
console.log("Installing max bounds")
let bounds;
if (typeof factor === "number") {
bounds = leaflet.getBounds()
leaflet.setMaxBounds(bounds.pad(factor))
}else{
// @ts-ignore
leaflet.setMaxBounds(factor.toLeaflet())
bounds = leaflet.getBounds()
}
if (showRange) {
const data = {
type: "FeatureCollection",
features: [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
bounds.getEast(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getNorth()
]
]
}
}]
}
// @ts-ignore
L.geoJSON(data, {
style: {
color: "#f00",
weight: 2,
opacity: 0.4
}
}).addTo(leaflet)
}
})
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;

View file

@ -65,7 +65,8 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)]
const now = new Date()
const date = now.getFullYear()+"-"+Utils.TwoDigits(now.getMonth()+1)+"-"+Utils.TwoDigits(now.getDate())
const lastWeek = new Date(now.getDate() - 7 * 24 * 60 * 60 * 1000)
const date = lastWeek.getFullYear()+"-"+Utils.TwoDigits(lastWeek.getMonth()+1)+"-"+Utils.TwoDigits(lastWeek.getDate())
const osmcha_link = `https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%22${date}%22%2C%22value%22%3A%222021-01-01%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D`
tabsWithAboutMc.push({

View file

@ -20,6 +20,7 @@ import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {And} from "../../Logic/Tags/And";
import {BBox} from "../../Logic/GeoOperations";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -39,8 +40,6 @@ interface PresetInfo extends PresetConfig {
export default class SimpleAddUI extends Toggle {
constructor(isShown: UIEventSource<boolean>) {
const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone())
.onClick(() => State.state.osmConnection.AttemptLogin());
const readYourMessages = new Combine([
@ -52,7 +51,8 @@ export default class SimpleAddUI extends Toggle {
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
State.state.LastClickLocation.addCallback( _ => selectedPreset.setData(undefined))
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
@ -82,11 +82,7 @@ export default class SimpleAddUI extends Toggle {
return true;
})
}
},
() => {
selectedPreset.setData(undefined)
})
@ -98,9 +94,9 @@ export default class SimpleAddUI extends Toggle {
new Toggle(
new Toggle(
new Toggle(
Translations.t.general.add.stillLoading.Clone().SetClass("alert"),
addUi,
State.state.featurePipeline.runningQuery
Translations.t.general.add.stillLoading.Clone().SetClass("alert"),
State.state.featurePipeline.somethingLoaded
),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"),
State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
@ -126,6 +122,7 @@ export default class SimpleAddUI extends Toggle {
let location = State.state.LastClickLocation;
let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) {
// We uncouple the event source
const locationSrc = new UIEventSource({
lat: location.data.lat,
lon: location.data.lon,
@ -137,24 +134,48 @@ export default class SimpleAddUI extends Toggle {
backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
let features: UIEventSource<{ feature: any }[]> = undefined
let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined
let mapBounds: UIEventSource<BBox> = undefined
if (preset.preciseInput.snapToLayers) {
// We have to snap to certain layers.
// Lets fetch tehm
const asSet = new Set(preset.preciseInput.snapToLayers)
features = State.state.featurePipeline.features.map(f => f.filter(feat => asSet.has(feat.feature._matching_layer_id)))
snapToFeatures = new UIEventSource<{ feature: any }[]>([])
mapBounds = new UIEventSource<BBox>(undefined)
}
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation: locationSrc,
snapTo: features,
snapTo: snapToFeatures,
snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance
maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds
})
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
if (preset.preciseInput.snapToLayers) {
// We have to snap to certain layers.
// Lets fetch them
let loadedBbox : BBox= undefined
mapBounds?.addCallbackAndRunD(bbox => {
if(loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)){
// All is already there
// return;
}
bbox = bbox.pad(2);
loadedBbox = bbox;
const allFeatures: {feature: any}[] = []
preset.preciseInput.snapToLayers.forEach(layerId => {
State.state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature :f}))))
})
snapToFeatures.setData(allFeatures)
})
}
}

View file

@ -7,7 +7,7 @@ import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {GeoOperations} from "../../Logic/GeoOperations";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import * as L from "leaflet";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
@ -38,6 +38,8 @@ export default class LocationInput extends InputElement<Loc> {
private readonly _snappedPoint: UIEventSource<any>
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: UIEventSource<LayerConfig>;
constructor(options: {
mapBackground?: UIEventSource<BaseLayer>,
@ -46,32 +48,33 @@ export default class LocationInput extends InputElement<Loc> {
snappedPointTags?: any,
requiresSnapping?: boolean,
centerLocation: UIEventSource<Loc>,
bounds?: UIEventSource<BBox>
}) {
super();
this._snapTo = options.snapTo?.map(features => features?.filter(feat => feat.feature.geometry.type !== "Point"))
this._maxSnapDistance = options.maxSnapDistance
this._centerLocation = options.centerLocation;
this._snappedPointTags = options.snappedPointTags
this._bounds = options.bounds;
if (this._snapTo === undefined) {
this._value = this._centerLocation;
} else {
const self = this;
let matching_layer: UIEventSource<string>
if (self._snappedPointTags !== undefined) {
matching_layer = State.state.layoutToUse.map(layout => {
this._matching_layer = State.state.layoutToUse.map(layout => {
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
return layer.id
return layer
}
}
console.error("No matching layer found for tags ", self._snappedPointTags)
return "matchpoint"
return LocationInput.matchLayer
})
} else {
matching_layer = new UIEventSource<string>("matchpoint")
this._matching_layer = new UIEventSource<LayerConfig>(LocationInput.matchLayer)
}
this._snappedPoint = options.centerLocation.map(loc => {
@ -83,7 +86,7 @@ export default class LocationInput extends InputElement<Loc> {
let min = undefined;
let matchedWay = undefined;
for (const feature of self._snapTo.data) {
for (const feature of self._snapTo.data ?? []) {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat])
if (min === undefined) {
min = nearestPointOnLine
@ -98,19 +101,17 @@ export default class LocationInput extends InputElement<Loc> {
}
}
if (min.properties.dist * 1000 > self._maxSnapDistance) {
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options.requiresSnapping) {
return undefined
} else {
return {
"type": "Feature",
"_matching_layer_id": matching_layer.data,
"properties": options.snappedPointTags ?? min.properties,
"geometry": {"type": "Point", "coordinates": [loc.lon, loc.lat]}
}
}
}
min._matching_layer_id = matching_layer?.data ?? "matchpoint"
min.properties = options.snappedPointTags ?? min.properties
self.snappedOnto.setData(matchedWay)
return min
@ -144,84 +145,40 @@ export default class LocationInput extends InputElement<Loc> {
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state.backgroundLayer,
lastClickLocation: clickLocation
lastClickLocation: clickLocation,
bounds: this._bounds
}
)
clickLocation.addCallbackAndRunD(location => this._centerLocation.setData(location))
map.leafletMap.addCallbackAndRunD(leaflet => {
const bounds = leaflet.getBounds()
leaflet.setMaxBounds(bounds.pad(0.15))
const data = {
type: "FeatureCollection",
features: [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
bounds.getEast(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getNorth()
]
]
}
}]
}
// @ts-ignore
L.geoJSON(data, {
style: {
color: "#f00",
weight: 2,
opacity: 0.4
}
}).addTo(leaflet)
})
map.installBounds(0.15, true);
if (this._snapTo !== undefined) {
// Show the lines to snap to
new ShowDataMultiLayer({
features: new StaticFeatureSource(this._snapTo, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layers: State.state.filteredLayers
}
)
// Show the central point
const matchPoint = this._snappedPoint.map(loc => {
if (loc === undefined) {
return []
}
return [{feature: loc}];
})
if (this._snapTo) {
if (this._snappedPointTags === undefined) {
// No special tags - we show a default crosshair
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: LocationInput.matchLayer
})
}else{
new ShowDataMultiLayer({
features: new StaticFeatureSource(matchPoint),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layers: State.state.filteredLayers
}
)
}
}
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: this._matching_layer.data
})
}
this.mapBackground.map(layer => {

View file

@ -130,7 +130,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
if (!userbadge) {
return undefined
}
return new Combine(editElements)
return new Combine(editElements).SetClass("flex flex-col")
}
))
renderings.push(editors)

View file

@ -5,13 +5,12 @@ import {SubtleButton} from "../Base/SubtleButton";
import Minimap from "../Base/Minimap";
import State from "../../State";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import {GeoOperations} from "../../Logic/GeoOperations";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import {LeafletMouseEvent} from "leaflet";
import Combine from "../Base/Combine";
import {Button} from "../Base/Button";
import Translations from "../i18n/Translations";
import SplitAction from "../../Logic/Osm/Actions/SplitAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import Title from "../Base/Title";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
@ -21,9 +20,12 @@ export default class SplitRoadWizard extends Toggle {
private static splitLayerStyling = new LayerConfig({
id: "splitpositions",
source: {osmTags: "_cutposition=yes"},
icon: "./assets/svg/plus.svg"
icon: {render: "circle:white;./assets/svg/scissors.svg"},
iconSize: {render: "30,30,center"},
}, "(BUILTIN) SplitRoadWizard.ts", true)
public dialogIsOpened: UIEventSource<boolean>
/**
* A UI Element used for splitting roads
*
@ -40,30 +42,40 @@ export default class SplitRoadWizard extends Toggle {
// Toggle variable between show split button and map
const splitClicked = new UIEventSource<boolean>(false);
// Load the road with given id on the minimap
const roadElement = State.state.allElements.ContainingFeatures.get(id)
// Minimap on which you can select the points to be splitted
const miniMap = Minimap.createMiniMap({background: State.state.backgroundLayer, allowMoving: false});
miniMap.SetStyle("width: 100%; height: 24rem;");
const miniMap = Minimap.createMiniMap(
{
background: State.state.backgroundLayer,
allowMoving: true,
leafletOptions: {
minZoom: 14
}
});
miniMap.SetStyle("width: 100%; height: 24rem")
.SetClass("rounded-xl overflow-hidden");
miniMap.installBounds(BBox.get(roadElement))
// Define how a cut is displayed on the map
// Load the road with given id on the minimap
const roadElement = State.state.allElements.ContainingFeatures.get(id)
const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]);
// Datalayer displaying the road and the cut points (if any)
new ShowDataMultiLayer({
features: new StaticFeatureSource(roadEventSource, true),
layers: State.state.filteredLayers,
leafletMap: miniMap.leafletMap,
enablePopups: false,
zoomToFeatures: true
})
new ShowDataLayer({
features: new StaticFeatureSource(splitPoints, true),
leafletMap: miniMap.leafletMap,
zoomToFeatures: false,
enablePopups: false,
layerToShow: SplitRoadWizard.splitLayerStyling
layerToShow: SplitRoadWizard.splitLayerStyling
})
new ShowDataMultiLayer({
features: new StaticFeatureSource([roadElement]),
layers: State.state.filteredLayers,
leafletMap: miniMap.leafletMap,
enablePopups: false,
zoomToFeatures: true
})
/**
@ -72,12 +84,25 @@ export default class SplitRoadWizard extends Toggle {
* @param coordinates Clicked location, [lon, lat]
*/
function onMapClick(coordinates) {
// First, we check if there is another, already existing point nearby
const points = splitPoints.data.map((f, i) => [f.feature, i])
.filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5)
.map(p => p[1])
.sort()
.reverse()
if (points.length > 0) {
for (const point of points) {
splitPoints.data.splice(point, 1)
}
splitPoints.ping()
return;
}
// Get nearest point on the road
const pointOnRoad = GeoOperations.nearestPoint(roadElement, coordinates); // pointOnRoad is a geojson
// Update point properties to let it match the layer
pointOnRoad.properties._cutposition = "yes";
pointOnRoad["_matching_layer_id"] = "splitpositions";
// let the state remember the point, to be able to retrieve it later by id
State.state.allElements.addOrGetElement(pointOnRoad);
@ -94,7 +119,7 @@ export default class SplitRoadWizard extends Toggle {
}))
// Toggle between splitmap
const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone());
const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone().SetClass("text-lg font-bold"));
splitButton.onClick(
() => {
splitClicked.setData(true)
@ -110,27 +135,9 @@ export default class SplitRoadWizard extends Toggle {
// Save button
const saveButton = new Button(t.split.Clone(), () => {
hasBeenSplit.setData(true)
const way = OsmObject.DownloadObject(id)
const partOfSrc = OsmObject.DownloadReferencingRelations(id);
let hasRun = false
way.map(way => {
const partOf = partOfSrc.data
if (way === undefined || partOf === undefined) {
return;
}
if (hasRun) {
return
}
hasRun = true
const splitAction = new SplitAction(
<OsmWay>way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature)
)
State.state.changes.applyAction(splitAction)
State.state.changes.applyAction(new SplitAction(id, splitPoints.data.map(ff => ff.feature.geometry.coordinates)))
})
}, [partOfSrc])
});
saveButton.SetClass("btn btn-primary mr-3");
const disabledSaveButton = new Button("Split", undefined);
disabledSaveButton.SetClass("btn btn-disabled mr-3");
@ -152,5 +159,6 @@ export default class SplitRoadWizard extends Toggle {
mapView.SetClass("question")
const confirm = new Toggle(mapView, splitToggle, splitClicked);
super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit)
this.dialogIsOpened = splitClicked
}
}

View file

@ -37,8 +37,8 @@ export default class ShowDataLayer {
this._layerToShow = options.layerToShow;
const self = this;
features.addCallback(() => self.update(options));
options.leafletMap.addCallback(() => self.update(options));
features.addCallback(_ => self.update(options));
options.leafletMap.addCallback(_ => self.update(options));
this.update(options);
@ -83,13 +83,17 @@ export default class ShowDataLayer {
mp.removeLayer(this.geoLayer);
}
this.geoLayer= this.CreateGeojsonLayer()
const allFeats = this._features.data;
this.geoLayer = this.CreateGeojsonLayer();
for (const feat of allFeats) {
if (feat === undefined) {
continue
}
this.geoLayer.addData(feat);
try{
this.geoLayer.addData(feat);
}catch(e){
console.error("Could not add ", feat, "to the geojson layer in leaflet")
}
}
mp.addLayer(this.geoLayer)
@ -122,7 +126,8 @@ export default class ShowDataLayer {
}
const tagSource = feature.properties.id === undefined ? new UIEventSource<any>(feature.properties) : State.state.allElements.getEventSourceById(feature.properties.id)
const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0));
const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)
const style = layer.GenerateLeafletStyle(tagSource, clickable);
const baseElement = style.icon.html;
if (!this._enablePopups) {
baseElement.SetStyle("cursor: initial !important")
@ -132,7 +137,7 @@ export default class ShowDataLayer {
html: baseElement.ConstructElement(),
className: style.icon.className,
iconAnchor: style.icon.iconAnchor,
iconUrl: style.icon.iconUrl,
iconUrl: style.icon.iconUrl ?? "./assets/svg/bug.svg",
popupAnchor: style.icon.popupAnchor,
iconSize: style.icon.iconSize
})

View file

@ -8,7 +8,7 @@ export class Utils {
* However, ts-node crashes when it sees 'document'. When running from console, we flag this and disable all code where document is needed.
* This is a workaround and yet another hack
*/
public static runningFromConsole = false;
public static runningFromConsole = typeof window === "undefined";
public static readonly assets_path = "./assets/svg/";
public static externalDownloadFunction: (url: string) => Promise<any>;
private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"]

View file

@ -606,7 +606,7 @@
"fi": "Lisää uusi penkki",
"pl": "Dodaj nową ławkę"
},
"presiceInput": {
"preciseInput": {
"preferredBackground": "photo"
}
}

View file

@ -97,6 +97,7 @@
}
],
"tagRenderings": [
"images",
{
"question": {
"en": "What kind of crossing is this?",

View file

@ -49,7 +49,7 @@
},
"calculatedTags": [
"_closest_other_drinking_water_id=feat.closest('drinking_water')?.id",
"_closest_other_drinking_water_distance=Math.floor(feat.distanceTo(feat.closest('drinking_water')) * 1000)"
"_closest_other_drinking_water_distance=Math.floor(feat.distanceTo(feat.closest('drinking_water')).distance * 1000)"
],
"minzoom": 13,
"wayHandling": 1,

View file

@ -1153,10 +1153,10 @@
"path": "scissors.svg",
"license": "CC-BY 3.0",
"authors": [
"The noun project - Basith Ibrahi"
"The noun project - Icons8"
],
"sources": [
"https://commons.wikimedia.org/wiki/File:Media-floppy.svg"
"https://commons.wikimedia.org/wiki/File:Scissors_-_The_Noun_Project.svg"
]
},
{
@ -1377,4 +1377,4 @@
"https://www.wikipedia.org/"
]
}
]
]

View file

@ -1,20 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="97.287025"
height="97.287033"
viewBox="0 0 97.287025 97.287033"
version="1.1"
id="svg132"
style="fill:none">
<defs
id="defs8"/>
<path
id="path815"
d="m 12.078125,36.966797 a 11.909361,11.909361 0 1 0 0,23.816406 h 73.130859 a 11.909361,11.909361 0 1 0 0,-23.816406 z"
style="color:#000000;"/>
<path
id="path815-3"
d="M 48.34375,0.00195313 A 11.909361,11.909361 0 0 0 36.613281,12.078125 v 73.130859 a 11.909361,11.909361 0 1 0 23.816407,0 V 12.078125 A 11.909361,11.909361 0 0 0 48.34375,0.00195313 Z"
style="color:#000000;"/>
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="97.287025"
height="97.287033"
viewBox="0 0 97.287025 97.287033"
version="1.1"
id="svg132"
style="fill:none"
sodipodi:docname="plus.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata8">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="999"
id="namedview6"
showgrid="false"
inkscape:zoom="2.4258115"
inkscape:cx="6.5957272"
inkscape:cy="-32.536483"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg132" />
<defs
id="defs8" />
<path
id="path815"
d="m 12.078125,36.966797 a 11.909361,11.909361 0 1 0 0,23.816406 h 73.130859 a 11.909361,11.909361 0 1 0 0,-23.816406 z"
style="color:#000000;fill:#000000;fill-opacity:1" />
<path
id="path815-3"
d="M 48.34375,0.00195313 A 11.909361,11.909361 0 0 0 36.613281,12.078125 v 73.130859 a 11.909361,11.909361 0 1 0 23.816407,0 V 12.078125 A 11.909361,11.909361 0 0 0 48.34375,0.00195313 Z"
style="color:#000000;fill:#000000;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,69 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
x="0px"
y="0px"
viewBox="0 0 64 62"
xml:space="preserve"
id="svg18"
sodipodi:docname="scissors.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
width="64"
height="62"><metadata
id="metadata24"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title></dc:title></cc:Work></rdf:RDF></metadata>
<defs
id="defs22"/>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1053"
id="namedview20"
showgrid="false"
units="in"
height="0.8"
inkscape:zoom="2.95"
inkscape:cx="-35.288136"
inkscape:cy="40"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg18"/>
<path
d="m 31.850939,35.56215 c -1.123789,0 -2.038585,-0.918488 -2.038585,-2.045617 0,-1.129524 0.914796,-2.048011 2.038585,-2.048011 1.123791,0 2.037396,0.918487 2.037396,2.048011 0,1.127129 -0.913605,2.045617 -2.037396,2.045617 z m 0,-2.86578 c -0.450231,0 -0.815672,0.366916 -0.815672,0.820163 0,0.450852 0.365441,0.817768 0.815672,0.817768 0.44904,0 0.81448,-0.366916 0.81448,-0.817768 0,-0.453247 -0.36544,-0.820163 -0.81448,-0.820163 z"
id="path2"
inkscape:connector-curvature="0"/>
<path
d="m 16.746047,56.399613 c -2.171149,0 -4.172715,-0.872925 -5.862581,-2.604385 -1.7651031,-2.472485 -3.0035427,-6.391049 0.585183,-11.654973 2.216531,-3.244686 8.030148,-6.249561 11.901911,-5.46297 L 44.668845,5.1920716 c 0.259151,-0.384902 0.763126,-0.515601 1.177528,-0.300967 2.548535,1.312983 3.566036,4.3634207 2.315655,6.9450214 -2.161594,4.455748 -5.032574,10.411536 -7.553639,15.641885 -2.484043,5.153608 -4.628921,9.602163 -5.419513,11.216112 -0.298565,0.611526 -0.929131,0.628315 -1.345925,0.640304 -0.73088,0.02158 -1.640899,0.04557 -2.412387,1.654721 l -3.560063,7.266372 c -1.507147,3.069622 -3.686655,5.429394 -6.304455,6.822716 -1.658815,0.882515 -3.281803,1.321377 -4.819999,1.321377 z m 5.288147,-18.010052 c -3.069228,0 -7.427051,2.410134 -9.051234,4.791489 -2.402834,3.522869 -2.636907,6.698013 -0.697443,9.434292 2.242805,2.280635 5.106621,2.59719 8.421862,0.836952 2.273855,-1.213459 4.18227,-3.290253 5.517443,-6.012142 l 3.556482,-7.256783 c 1.221718,-2.549225 3.094305,-2.661938 3.950582,-2.688316 0.907631,-1.863358 2.919945,-6.036125 5.226044,-10.820419 2.521069,-5.230349 5.393242,-11.187336 7.553642,-15.644284 0.690277,-1.4232984 0.314091,-3.0684235 -0.833587,-4.0516624 L 24.520625,38.255265 c -0.235268,0.342934 -0.668782,0.484425 -1.060496,0.352528 -0.426347,-0.151086 -0.907631,-0.218232 -1.425935,-0.218232 z"
id="path4"
inkscape:connector-curvature="0"/>
<path
d="m 16.740077,52.672898 c -1.177533,0 -2.234446,-0.390896 -2.989212,-1.20147 -1.846314,-1.980865 -1.089158,-5.604458 1.724498,-8.247211 2.816046,-2.645153 6.468067,-3.160751 8.310797,-1.179886 1.846314,1.980865 1.089157,5.604458 -1.723305,8.247211 0,0 0,0 -0.0012,0.0024 -1.662399,1.561188 -3.618582,2.378956 -5.321584,2.378956 z m 4.067619,-9.988262 c -1.234856,0 -2.787382,0.671478 -4.078367,1.882541 -1.994401,1.875347 -2.744392,4.460545 -1.638515,5.647625 1.108266,1.187081 3.724872,0.601933 5.718078,-1.26622 v 0 c 1.993206,-1.875347 2.743195,-4.460544 1.637318,-5.647625 -0.389327,-0.417278 -0.967343,-0.616321 -1.638514,-0.616321 z m 0.628178,6.935424 h 0.02269 z"
id="path6"
inkscape:connector-curvature="0"/>
<path
d="m 46.54143,57.788136 c -1.620602,0 -3.312855,-0.501213 -5.026604,-1.496441 -2.56406,-1.489246 -4.656387,-3.928158 -6.048885,-7.052937 l -3.286579,-7.38388 c -0.206608,-0.465241 0.0012,-1.009621 0.463367,-1.218258 0.459786,-0.201443 1.004368,0.0024 1.210972,0.46524 l 3.286579,7.383881 c 1.234856,2.769856 3.06445,4.918591 5.292923,6.213589 3.245976,1.882541 6.121735,1.671505 8.542481,-0.630715 1.941856,-2.556419 1.824818,-5.738754 -0.445454,-9.345562 -1.793766,-2.85139 -7.324344,-5.889835 -10.301613,-4.961753 -0.396492,0.119903 -0.826423,-0.03837 -1.047359,-0.3909 l -1.437882,-2.304615 c -0.268703,-0.431664 -0.13853,-1.000027 0.290203,-1.268616 0.431126,-0.270992 0.996006,-0.139091 1.264714,0.290173 l 1.091545,1.750644 c 3.890873,-0.673877 9.597008,2.573208 11.690529,5.901829 3.392872,5.391026 2.01112,9.261627 0.253181,11.559048 -1.740024,1.661915 -3.706955,2.489273 -5.792118,2.489273 z"
id="path8"
inkscape:connector-curvature="0"/>
<path
d="m 27.243519,33.454182 c -0.352304,0 -0.689083,-0.206238 -0.839559,-0.551571 -1.329203,-3.038448 -3.294941,-7.067327 -5.194994,-10.965507 -1.81168,-3.714724 -3.523043,-7.224406 -4.677883,-9.846774 -1.154843,-2.6247684 -0.02627,-5.6356364 2.570028,-6.8538924 0.4156,-0.196648 0.917185,-0.04796 1.16559,0.345332 L 32.383578,25.003128 c 0.268707,0.431665 0.138531,1.000025 -0.290203,1.268618 -0.43232,0.27099 -0.996006,0.139091 -1.264714,-0.290177 L 19.191874,7.3288165 c -1.183502,0.9400721 -1.619404,2.5696101 -0.982868,4.0180885 1.139315,2.588795 2.843511,6.08169 4.646831,9.780825 1.909609,3.913769 3.882513,7.959434 5.22724,11.036253 0.204218,0.46524 -0.0072,1.007217 -0.470535,1.213459 -0.120619,0.05276 -0.246016,0.07674 -0.369023,0.07674 z"
id="path10"
inkscape:connector-curvature="0"/>
<path
d="m 46.636969,54.068615 c -0.385742,0 -0.789398,-0.04556 -1.204998,-0.134296 -1.488037,-0.316551 -2.992794,-1.184681 -4.237205,-2.446102 -1.245606,-1.259026 -2.098301,-2.774655 -2.401642,-4.271095 -0.333195,-1.637928 0.01672,-3.069622 0.982872,-4.033673 0.968536,-0.961658 2.400444,-1.290203 4.023433,-0.956859 1.488038,0.31895 2.993988,1.184681 4.238396,2.443706 2.712147,2.748272 3.335545,6.398243 1.418774,8.307167 -0.720135,0.719441 -1.695839,1.091152 -2.81963,1.091152 z M 42.601592,43.938862 c -0.518303,0 -1.101098,0.122307 -1.533417,0.551575 -0.692665,0.690662 -0.597126,1.772224 -0.477699,2.357372 0.232875,1.148713 0.910018,2.335793 1.906025,3.34301 0,0.0024 0,0.0024 0,0.0024 0.996006,1.007221 2.173538,1.697883 3.316436,1.940097 0.578019,0.119908 1.65762,0.225426 2.351479,-0.462841 1.147678,-1.143917 0.493229,-3.755493 -1.428322,-5.702785 -0.996006,-1.007217 -2.174732,-1.695484 -3.316437,-1.937698 -0.218548,-0.04796 -0.506363,-0.09113 -0.818065,-0.09113 z"
id="path12"
inkscape:connector-curvature="0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" x="0px" y="0px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve"><path d="M6.5,3C4.6,3,3,4.6,3,6.5S4.6,10,6.5,10S10,8.4,10,6.5S8.4,3,6.5,3z M6.5,8C5.7,8,5,7.3,5,6.5S5.7,5,6.5,5S8,5.7,8,6.5 S7.3,8,6.5,8z"></path><path d="M6.5,14C4.6,14,3,15.6,3,17.5S4.6,21,6.5,21s3.5-1.6,3.5-3.5S8.4,14,6.5,14z M6.5,19C5.7,19,5,18.3,5,17.5S5.7,16,6.5,16 S8,16.7,8,17.5S7.3,19,6.5,19z"></path><polygon points="13.5,9.5 15.5,11.5 22,5 18,5 "></polygon><polygon points="10.8,12.2 12.8,14.2 9.7,17.3 8,15 "></polygon><path d="M9.1,7.1L7,9l11,11h4L9.1,7.1z M12.5,13c-0.3,0-0.5-0.2-0.5-0.5c0-0.3,0.2-0.5,0.5-0.5s0.5,0.2,0.5,0.5 C13,12.8,12.8,13,12.5,13z"></path></svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 771 B

View file

@ -24,7 +24,7 @@
"startLat": 51,
"startLon": 3.75,
"startZoom": 11,
"widenFactor": 0.05,
"widenFactor": 1,
"socialImage": "./assets/themes/cycle_infra/cycle-infra.svg",
"enableDownload": true,
"layers": [

View file

@ -68,7 +68,7 @@
]
},
"then": {
"nl": "Deze straat i een fietsstraat",
"nl": "Deze straat is een fietsstraat",
"en": "This street is a cyclestreet",
"ja": "この通りはcyclestreetだ",
"nb_NO": "Denne gaten er en sykkelvei"
@ -276,8 +276,10 @@
},
"tagRenderings": [
"images"
],
"allowSplit": false
]
}
]
],
"overrideAll": {
"allowSplit": true
}
}

View file

@ -39,7 +39,7 @@ Contains tweaks for small screens
}
@media only screen and (max-width: 768px) {
.leaflet-control-attribution {
#leafletDiv .leaflet-control-attribution {
display: none;
}

View file

@ -10,7 +10,7 @@ writeFileSync("cycleHighwayFix.osc", "<osmChange version=\"0.6\" generator=\"Han
" <modify>", "utf8")
const ids = JSON.parse(readFileSync("export.geojson", "utf-8")).features.map(f => f.properties["@id"])
console.log(ids)
ids.map(id => OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(relations => {
ids.map(id => OsmObject.DownloadReferencingRelations(id).then(relations => {
console.log(relations)
const changeparts = relations.filter(relation => relation.tags["cycle_highway"] == "yes" && relation.tags["note:state"] == undefined)
.map(relation => {
@ -18,5 +18,4 @@ ids.map(id => OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rela
return relation.ChangesetXML(undefined)
})
appendFileSync("cycleHighwayFix.osc", changeparts.join("\n"), "utf8")
return true;
}))

View file

@ -56,7 +56,7 @@ export default class ScriptUtils {
const headers = options?.headers ?? {}
headers.accept = "application/json"
console.log("Fetching", url)
const urlObj = new URL(url)
https.get({
host: urlObj.host,
@ -75,6 +75,7 @@ export default class ScriptUtils {
res.addListener('end', function () {
const result = parts.join("")
try {
console.log("Fetched", result)
resolve(JSON.parse(result))
} catch (e) {
console.error("Could not parse the following as JSON:", result)

View file

@ -201,7 +201,10 @@ function postProcess(allFeatures: FeatureSource, theme: LayoutConfig, relationsT
}
},
layer,
false);
{
includeDates: false,
includeNonDates: true
});
const createdTiles = []
// At this point, we have all the features of the entire area.

62
test.ts
View file

@ -1,16 +1,50 @@
const client_token = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"
import SplitRoadWizard from "./UI/Popup/SplitRoadWizard";
import State from "./State";
import {AllKnownLayouts} from "./Customizations/AllKnownLayouts";
import MinimapImplementation from "./UI/Base/MinimapImplementation";
import {UIEventSource} from "./Logic/UIEventSource";
import FilteredLayer from "./Models/FilteredLayer";
import {And} from "./Logic/Tags/And";
const image_id = '196804715753265';
const api_url = 'https://graph.mapillary.com/' + image_id + '?fields=thumb_1024_url&&access_token=' + client_token;
fetch(api_url,
{
headers: {'Authorization': 'OAuth ' + client_token}
const layout = AllKnownLayouts.allKnownLayouts.get("cyclestreets")
State.state = new State(layout)
MinimapImplementation.initialize()
const feature = {
"type": "Feature",
"properties": {
id: "way/1234",
"highway":"residential",
"cyclestreet":"yes"
},
"geometry": {
"type": "LineString",
"coordinates": [
[
3.2207107543945312,
51.21978729870313
],
[
3.2198524475097656,
51.21899435057332
],
[
3.2155394554138184,
51.21617188199714
]
]
}
).then(response => {
return response.json()
}).then(
json => {
const thumbnail_url = json["thumb_1024"]
console.log(thumbnail_url)
}
)
}
State.state.allElements.addOrGetElement(feature)
State.state.filteredLayers = new UIEventSource<FilteredLayer[]>(
layout.layers.map( l => ({
layerDef :l,
appliedFilters: new UIEventSource<And>(undefined),
isDisplayed: new UIEventSource<boolean>(undefined)
}))
)
const splitroad = new SplitRoadWizard("way/1234")
splitroad.AttachTo("maindiv")
splitroad.dialogIsOpened.setData(true)

View file

@ -10,7 +10,7 @@ Utils.runningFromConsole = true;
export default class ImageAttributionSpec extends T {
constructor() {
super(
"ImageAttribution Tests", [
"imageattribution", [
[
"Should find all the images",
() => {

View file

@ -8,7 +8,7 @@ Utils.runningFromConsole = true;
export default class ImageSearcherSpec extends T {
constructor() {
super("ImageSearcher", [
super("imagesearcher", [
[
"Should find images",
() => {

View file

@ -12,7 +12,7 @@ export default class OsmConnectionSpec extends T {
private static _osm_token = "LJFmv2nUicSNmBNsFeyCHx5KKx6Aiesx8pXPbX4n"
constructor() {
super("OsmConnectionSpec-test", [
super("osmconnection", [
["login on dev",
() => {
const osmConn = new OsmConnection(false, false,

View file

@ -1,27 +1,26 @@
import T from "./TestHelper";
import {OsmObject} from "../Logic/Osm/OsmObject";
import ScriptUtils from "../scripts/ScriptUtils";
import {UIEventSource} from "../Logic/UIEventSource";
export default class OsmObjectSpec extends T {
private static async runTest(){
const ways = await OsmObject.DownloadReferencingWays("node/1124134958")
if(ways === undefined){
throw "Did not get the ways"
}
if (ways.length !== 4) {
throw "Expected 4 ways but got "+ways.length
}
}
constructor() {
super("OsmObject", [
super("osmobject", [
[
"Download referencing ways",
() => {
let downloaded = false;
OsmObject.DownloadReferencingWays("node/1124134958").addCallbackAndRunD(ways => {
downloaded = true;
console.log(ways)
})
let timeout = 10
while (!downloaded && timeout >= 0) {
ScriptUtils.sleep(1000)
timeout--;
}
if (!downloaded) {
throw "Timeout: referencing ways not found"
}
OsmObjectSpec.runTest().then(_ => console.log("Referencing ways test is done (async)"))
}
]

View file

@ -0,0 +1,66 @@
import T from "./TestHelper";
import {InPlaceReplacedmentRTSH} from "../Logic/Osm/Actions/RelationSplitHandler";
import {OsmObject, OsmRelation} from "../Logic/Osm/OsmObject";
import {Changes} from "../Logic/Osm/Changes";
import {equal} from "assert";
export default class RelationSplitHandlerSpec extends T {
private static async split(): Promise<void> {
// Lets mimick a split action of https://www.openstreetmap.org/way/295132739
const relation: OsmRelation = <OsmRelation>await OsmObject.DownloadObjectAsync("relation/9572808")
const originalNodeIds = [5273988967,
170497153,
1507524582,
4524321710,
170497155,
170497157,
170497158,
3208166179,
1507524610,
170497160,
3208166178,
1507524573,
1575932830,
6448669326]
const withSplit = [[5273988967,
170497153,
1507524582,
4524321710,
170497155,
170497157,
170497158],
[
3208166179,
1507524610,
170497160,
3208166178,
1507524573,
1575932830,
6448669326]]
const splitter = new InPlaceReplacedmentRTSH(
{
relation: relation,
originalWayId: 295132739,
allWayIdsInOrder: [295132739, -1],
originalNodes: originalNodeIds,
allWaysNodesInOrder: withSplit
})
const changeDescription = await splitter.CreateChangeDescriptions(new Changes())
const allIds = changeDescription[0].changes["members"].map(m => m.ref).join(",")
const expected = "687866206,295132739,-1,690497698"
if (allIds.indexOf(expected) < 0) {
throw "Invalid order or the split ways. If this suddenly breaks, the parent relation at https://osm.org/relation/9572808 has probably changed and the test must be updated"
}
}
constructor() {
super("relationsplithandler", [
["split 295132739",
() => RelationSplitHandlerSpec.split().then(_ => console.log("OK"))]
]);
}
}

View file

@ -16,7 +16,7 @@ Utils.runningFromConsole = true;
export default class TagSpec extends T {
constructor() {
super("Tags", [
super("tag", [
["Tag replacement works in translation", () => {
const tr = new Translation({
"en": "Test {key} abc"

View file

@ -1,36 +1,16 @@
import {Utils} from "../Utils";
Utils.runningFromConsole = true;
import TagSpec from "./Tag.spec";
import ImageAttributionSpec from "./ImageAttribution.spec";
import GeoOperationsSpec from "./GeoOperations.spec";
import ImageSearcherSpec from "./ImageSearcher.spec";
import ThemeSpec from "./Theme.spec";
import UtilsSpec from "./Utils.spec";
import OsmConnectionSpec from "./OsmConnection.spec";
import T from "./TestHelper";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
import Combine from "../UI/Base/Combine";
import OsmObjectSpec from "./OsmObject.spec";
import ScriptUtils from "../scripts/ScriptUtils";
import UnitsSpec from "./Units.spec";
import RelationSplitHandlerSpec from "./RelationSplitHandler.spec";
export default class TestAll {
private needsBrowserTests: T[] = [new OsmConnectionSpec()]
public testAll(): void {
Utils.runningFromConsole = false
for (const test of this.needsBrowserTests.concat(allTests)) {
if (test.failures.length > 0) {
new Combine([new FixedUiElement("TEST FAILED: " + test.name).SetStyle("background: red"),
...test.failures])
.AttachTo("maindiv")
throw "Some test failed"
}
}
}
}
ScriptUtils.fixUtils()
const allTests = [
new OsmObjectSpec(),
@ -40,12 +20,34 @@ const allTests = [
new ImageSearcherSpec(),
new ThemeSpec(),
new UtilsSpec(),
new UnitsSpec()
new UnitsSpec(),
new RelationSplitHandlerSpec()
]
let args = [...process.argv]
args.splice(0, 2)
args = args.map(a => a.toLowerCase())
for (const test of allTests) {
if (test.failures.length > 0) {
throw "Some test failed: " + test.failures.join(", ")
const allFailures: { testsuite: string, name: string, msg: string } [] = []
let testsToRun = allTests
if (args.length > 0) {
testsToRun = allTests.filter(t => args.indexOf(t.name) >= 0)
}
if(testsToRun.length == 0){
throw "No tests found"
}
for (let i = 0; i < testsToRun.length; i++){
const test = testsToRun[i];
ScriptUtils.erasableLog(" Running test", i, "/", allTests.length)
allFailures.push(...(test.Run() ?? []))
}
if (allFailures.length > 0) {
for (const failure of allFailures) {
console.error(" !! " + failure.testsuite + "." + failure.name + " failed due to: " + failure.msg)
}
}
throw "Some test failed"
}
console.log("All tests successful: ", allTests.map(t => t.name).join(", "))

View file

@ -1,23 +1,31 @@
export default class T {
public readonly failures: string[] = []
public readonly name: string;
private readonly _tests: [string, (() => void)][];
constructor(testsuite: string, tests: [string, () => void][]) {
this.name = testsuite
for (const [name, test] of tests) {
this._tests = tests;
}
/**
* RUns the test, returns the error messages.
* Returns an empty list if successful
* @constructor
*/
public Run() : ({testsuite: string, name: string, msg: string} []) {
const failures: {testsuite: string, name: string, msg: string} [] = []
for (const [name, test] of this._tests) {
try {
test();
} catch (e) {
this.failures.push(name);
console.warn(`>>> Failed test in ${this.name}: ${name}because${e}`);
failures.push({testsuite: this.name, name: name, msg: ""+e});
}
}
if (this.failures.length == 0) {
console.log(`All tests of ${testsuite} done!`)
if (failures.length == 0) {
return undefined
} else {
console.warn(this.failures.length, `tests of ${testsuite} failed :(`)
console.log("Failed tests: ", this.failures.join(","))
return failures
}
}

View file

@ -8,7 +8,7 @@ Utils.runningFromConsole = true;
export default class ThemeSpec extends T {
constructor() {
super("Theme tests",
super("theme",
[
["Nested overrides work", () => {

View file

@ -6,7 +6,7 @@ import {Denomination} from "../Models/Denomination";
export default class UnitsSpec extends T {
constructor() {
super("Units", [
super("units", [
["Simple canonicalize", () => {
const unit = new Denomination({

View file

@ -39,7 +39,7 @@ export default class UtilsSpec extends T {
}
constructor() {
super("Utils", [
super("utils", [
["Sort object keys", () => {
const o = {
x: 'x',