forked from MapComplete/MapComplete
Merge master
This commit is contained in:
commit
be2816bd0e
1396 changed files with 1287846 additions and 69687 deletions
|
@ -1,14 +1,14 @@
|
|||
import BaseLayer from "../../Models/BaseLayer";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {ImmutableStore, Store, UIEventSource} from "../UIEventSource";
|
||||
import Loc from "../../Models/Loc";
|
||||
|
||||
export interface AvailableBaseLayersObj {
|
||||
readonly osmCarto: BaseLayer;
|
||||
layerOverview: BaseLayer[];
|
||||
|
||||
AvailableLayersAt(location: UIEventSource<Loc>): UIEventSource<BaseLayer[]>
|
||||
AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]>
|
||||
|
||||
SelectBestLayerAccordingTo(location: UIEventSource<Loc>, preferedCategory: UIEventSource<string | string[]>): UIEventSource<BaseLayer>;
|
||||
SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: Store<string | string[]>): Store<BaseLayer>;
|
||||
|
||||
}
|
||||
|
||||
|
@ -24,12 +24,12 @@ export default class AvailableBaseLayers {
|
|||
|
||||
private static implementation: AvailableBaseLayersObj
|
||||
|
||||
static AvailableLayersAt(location: UIEventSource<Loc>): UIEventSource<BaseLayer[]> {
|
||||
return AvailableBaseLayers.implementation?.AvailableLayersAt(location) ?? new UIEventSource<BaseLayer[]>([]);
|
||||
static AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> {
|
||||
return AvailableBaseLayers.implementation?.AvailableLayersAt(location) ?? new ImmutableStore<BaseLayer[]>([]);
|
||||
}
|
||||
|
||||
static SelectBestLayerAccordingTo(location: UIEventSource<Loc>, preferedCategory: UIEventSource<string | string[]>): UIEventSource<BaseLayer> {
|
||||
return AvailableBaseLayers.implementation?.SelectBestLayerAccordingTo(location, preferedCategory) ?? new UIEventSource<BaseLayer>(undefined);
|
||||
static SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: UIEventSource<string | string[]>): Store<BaseLayer> {
|
||||
return AvailableBaseLayers.implementation?.SelectBestLayerAccordingTo(location, preferedCategory) ?? new ImmutableStore<BaseLayer>(undefined);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import BaseLayer from "../../Models/BaseLayer";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, Stores} from "../UIEventSource";
|
||||
import Loc from "../../Models/Loc";
|
||||
import {GeoOperations} from "../GeoOperations";
|
||||
import * as editorlayerindex from "../../assets/editor-layer-index.json";
|
||||
|
@ -29,7 +29,7 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
|
||||
public readonly layerOverview = AvailableBaseLayersImplementation.LoadRasterIndex().concat(AvailableBaseLayersImplementation.LoadProviderIndex());
|
||||
public readonly globalLayers = this.layerOverview.filter(layer => layer.feature?.geometry === undefined || layer.feature?.geometry === null)
|
||||
public readonly localLayers = this.layerOverview.filter(layer => layer.feature?.geometry !== undefined && layer.featuer?.geometry !== null)
|
||||
public readonly localLayers = this.layerOverview.filter(layer => layer.feature?.geometry !== undefined && layer.feature?.geometry !== null)
|
||||
|
||||
private static LoadRasterIndex(): BaseLayer[] {
|
||||
const layers: BaseLayer[] = []
|
||||
|
@ -202,8 +202,8 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
});
|
||||
}
|
||||
|
||||
public AvailableLayersAt(location: UIEventSource<Loc>): UIEventSource<BaseLayer[]> {
|
||||
return UIEventSource.ListStabilized(location.map(
|
||||
public AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> {
|
||||
return Stores.ListStabilized(location.map(
|
||||
(currentLocation) => {
|
||||
if (currentLocation === undefined) {
|
||||
return this.layerOverview;
|
||||
|
@ -212,7 +212,7 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
}));
|
||||
}
|
||||
|
||||
public SelectBestLayerAccordingTo(location: UIEventSource<Loc>, preferedCategory: UIEventSource<string | string[]>): UIEventSource<BaseLayer> {
|
||||
public SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: Store<string | string[]>): Store<BaseLayer> {
|
||||
return this.AvailableLayersAt(location)
|
||||
.map(available => {
|
||||
// First float all 'best layers' to the top
|
||||
|
@ -239,7 +239,7 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
prefered = preferedCategory.data;
|
||||
}
|
||||
|
||||
prefered.reverse();
|
||||
prefered.reverse(/*New list, inplace reverse is fine*/);
|
||||
for (const category of prefered) {
|
||||
//Then sort all 'photo'-layers to the top. Stability of the sorting will force a 'best' photo layer on top
|
||||
available.sort((a, b) => {
|
||||
|
@ -264,7 +264,7 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
|
|||
if (lon === undefined || lat === undefined) {
|
||||
return availableLayers.concat(this.globalLayers);
|
||||
}
|
||||
const lonlat = [lon, lat];
|
||||
const lonlat : [number, number] = [lon, lat];
|
||||
for (const layerOverviewItem of this.localLayers) {
|
||||
const layer = layerOverviewItem;
|
||||
const bbox = BBox.get(layer.feature)
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import Svg from "../../Svg";
|
||||
import {LocalStorageSource} from "../Web/LocalStorageSource";
|
||||
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import {QueryParameters} from "../Web/QueryParameters";
|
||||
import FeatureSource from "../FeatureSource/FeatureSource";
|
||||
import {BBox} from "../BBox";
|
||||
import Constants from "../../Models/Constants";
|
||||
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource";
|
||||
|
||||
export interface GeoLocationPointProperties {
|
||||
export interface GeoLocationPointProperties {
|
||||
id: "gps",
|
||||
"user:location": "yes",
|
||||
"date": string,
|
||||
|
@ -20,17 +22,15 @@ export interface GeoLocationPointProperties {
|
|||
|
||||
export default class GeoLocationHandler extends VariableUiElement {
|
||||
|
||||
private readonly currentLocation: FeatureSource
|
||||
private readonly currentLocation?: SimpleFeatureSource
|
||||
|
||||
/**
|
||||
* Wether or not the geolocation is active, aka the user requested the current location
|
||||
* @private
|
||||
*/
|
||||
private readonly _isActive: UIEventSource<boolean>;
|
||||
|
||||
/**
|
||||
* Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user
|
||||
* @private
|
||||
*/
|
||||
private readonly _isLocked: UIEventSource<boolean>;
|
||||
|
||||
|
@ -43,7 +43,7 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
* Literally: _currentGPSLocation.data != undefined
|
||||
* @private
|
||||
*/
|
||||
private readonly _hasLocation: UIEventSource<boolean>;
|
||||
private readonly _hasLocation: Store<boolean>;
|
||||
private readonly _currentGPSLocation: UIEventSource<Coordinates>;
|
||||
/**
|
||||
* Kept in order to update the marker
|
||||
|
@ -53,9 +53,8 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
|
||||
/**
|
||||
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
|
||||
* @private
|
||||
*/
|
||||
private _lastUserRequest: Date;
|
||||
private _lastUserRequest: UIEventSource<Date>;
|
||||
|
||||
/**
|
||||
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
|
||||
|
@ -71,7 +70,7 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
constructor(
|
||||
state: {
|
||||
selectedElement: UIEventSource<any>;
|
||||
currentUserLocation: FeatureSource,
|
||||
currentUserLocation?: SimpleFeatureSource,
|
||||
leafletMap: UIEventSource<any>,
|
||||
layoutToUse: LayoutConfig,
|
||||
featureSwitchGeolocation: UIEventSource<boolean>
|
||||
|
@ -79,6 +78,8 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
) {
|
||||
const currentGPSLocation = new UIEventSource<Coordinates>(undefined, "GPS-coordinate")
|
||||
const leafletMap = state.leafletMap
|
||||
const initedAt = new Date()
|
||||
let autozoomDone = false;
|
||||
const hasLocation = currentGPSLocation.map(
|
||||
(location) => location !== undefined
|
||||
);
|
||||
|
@ -96,13 +97,28 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
|
||||
return timeDiff <= 3
|
||||
})
|
||||
|
||||
const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
|
||||
const willFocus = lastClick.map(lastUserRequest => {
|
||||
const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000
|
||||
if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) {
|
||||
return true
|
||||
}
|
||||
if (lastUserRequest === undefined) {
|
||||
return false;
|
||||
}
|
||||
const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000
|
||||
return timeDiff <= Constants.zoomToLocationTimeout
|
||||
})
|
||||
|
||||
lastClick.addCallbackAndRunD(_ => {
|
||||
window.setTimeout(() => {
|
||||
if (lastClickWithinThreeSecs.data) {
|
||||
if (lastClickWithinThreeSecs.data || willFocus.data) {
|
||||
lastClick.ping()
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
super(
|
||||
hasLocation.map(
|
||||
(hasLocationData) => {
|
||||
|
@ -115,7 +131,8 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
}
|
||||
if (!hasLocationData) {
|
||||
// Position not yet found but we are active: we spin to indicate activity
|
||||
const icon = Svg.location_empty_svg()
|
||||
// If will focus is active too, we indicate this differently
|
||||
const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg()
|
||||
icon.SetStyle("animation: spin 4s linear infinite;")
|
||||
return icon;
|
||||
}
|
||||
|
@ -129,7 +146,7 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
// We have a location, so we show a dot in the center
|
||||
return Svg.location_svg();
|
||||
},
|
||||
[isActive, isLocked, permission, lastClickWithinThreeSecs]
|
||||
[isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus]
|
||||
)
|
||||
);
|
||||
this.SetClass("mapcontrol")
|
||||
|
@ -141,6 +158,7 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
this._leafletMap = leafletMap;
|
||||
this._layoutToUse = state.layoutToUse;
|
||||
this._hasLocation = hasLocation;
|
||||
this._lastUserRequest = lastClick
|
||||
const self = this;
|
||||
|
||||
const currentPointer = this._isActive.map(
|
||||
|
@ -182,7 +200,6 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
self.init(true, true);
|
||||
});
|
||||
|
||||
const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
|
||||
|
||||
const doAutoZoomToLocation = !latLonGiven && state.featureSwitchGeolocation.data && state.selectedElement.data !== undefined
|
||||
this.init(false, doAutoZoomToLocation);
|
||||
|
@ -217,14 +234,15 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
}
|
||||
}
|
||||
|
||||
self.currentLocation.features.setData([{feature, freshness: new Date()}])
|
||||
self.currentLocation?.features?.setData([{feature, freshness: new Date()}])
|
||||
|
||||
const timeSinceRequest =
|
||||
(new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
|
||||
if (timeSinceRequest < 30) {
|
||||
self.MoveToCurrentLoction(16);
|
||||
if (willFocus.data) {
|
||||
console.log("Zooming to user location: willFocus is set")
|
||||
lastClick.setData(undefined);
|
||||
autozoomDone = true;
|
||||
self.MoveToCurrentLocation(16);
|
||||
} else if (self._isLocked.data) {
|
||||
self.MoveToCurrentLoction();
|
||||
self.MoveToCurrentLocation();
|
||||
}
|
||||
|
||||
});
|
||||
|
@ -235,10 +253,14 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
const self = this;
|
||||
|
||||
if (self._isActive.data) {
|
||||
self.MoveToCurrentLoction(16);
|
||||
self.MoveToCurrentLocation(16);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
navigator?.permissions
|
||||
?.query({name: "geolocation"})
|
||||
|
@ -264,9 +286,59 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
}
|
||||
}
|
||||
|
||||
private MoveToCurrentLoction(targetZoom?: number) {
|
||||
/**
|
||||
* Moves to the currently loaded location.
|
||||
*
|
||||
* // Should move to any location
|
||||
* let resultingLocation = undefined
|
||||
* let resultingzoom = 1
|
||||
* const state = {
|
||||
* selectedElement: new UIEventSource<any>(undefined);
|
||||
* currentUserLocation: undefined ,
|
||||
* leafletMap: new UIEventSource<any>({getZoom: () => resultingzoom; setView: (loc, zoom) => {resultingLocation = loc; resultingzoom = zoom}),
|
||||
* layoutToUse: new LayoutConfig(<any>{
|
||||
* id: 'test',
|
||||
* title: {"en":"test"}
|
||||
* description: "A testing theme",
|
||||
* layers: []
|
||||
* }),
|
||||
* featureSwitchGeolocation : new UIEventSource<boolean>(true)
|
||||
* }
|
||||
* const handler = new GeoLocationHandler(state)
|
||||
* handler._currentGPSLocation.setData(<any> {latitude : 51.3, longitude: 4.1})
|
||||
* handler.MoveToCurrentLocation()
|
||||
* resultingLocation // => [51.3, 4.1]
|
||||
* handler._currentGPSLocation.setData(<any> {latitude : 60, longitude: 60) // out of bounds
|
||||
* handler.MoveToCurrentLocation()
|
||||
* resultingLocation // => [60, 60]
|
||||
*
|
||||
* // should refuse to move if out of bounds
|
||||
* let resultingLocation = undefined
|
||||
* let resultingzoom = 1
|
||||
* const state = {
|
||||
* selectedElement: new UIEventSource<any>(undefined);
|
||||
* currentUserLocation: undefined ,
|
||||
* leafletMap: new UIEventSource<any>({getZoom: () => resultingzoom; setView: (loc, zoom) => {resultingLocation = loc; resultingzoom = zoom}),
|
||||
* layoutToUse: new LayoutConfig(<any>{
|
||||
* id: 'test',
|
||||
* title: {"en":"test"}
|
||||
* "lockLocation": [ [ 2.1, 50.4], [6.4, 51.54 ]],
|
||||
* description: "A testing theme",
|
||||
* layers: []
|
||||
* }),
|
||||
* featureSwitchGeolocation : new UIEventSource<boolean>(true)
|
||||
* }
|
||||
* const handler = new GeoLocationHandler(state)
|
||||
* handler._currentGPSLocation.setData(<any> {latitude : 51.3, longitude: 4.1})
|
||||
* handler.MoveToCurrentLocation()
|
||||
* resultingLocation // => [51.3, 4.1]
|
||||
* handler._currentGPSLocation.setData(<any> {latitude : 60, longitude: 60) // out of bounds
|
||||
* handler.MoveToCurrentLocation()
|
||||
* resultingLocation // => [51.3, 4.1]
|
||||
*/
|
||||
private MoveToCurrentLocation(targetZoom?: number) {
|
||||
const location = this._currentGPSLocation.data;
|
||||
this._lastUserRequest = undefined;
|
||||
this._lastUserRequest.setData(undefined);
|
||||
|
||||
if (
|
||||
this._currentGPSLocation.data.latitude === 0 &&
|
||||
|
@ -282,22 +354,13 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
if (b) {
|
||||
if (b !== true) {
|
||||
// B is an array with our locklocation
|
||||
inRange =
|
||||
b[0][0] <= location.latitude &&
|
||||
location.latitude <= b[1][0] &&
|
||||
b[0][1] <= location.longitude &&
|
||||
location.longitude <= b[1][1];
|
||||
inRange = new BBox(b).contains([location.longitude, location.latitude])
|
||||
}
|
||||
}
|
||||
if (!inRange) {
|
||||
console.log(
|
||||
"Not zooming to GPS location: out of bounds",
|
||||
b,
|
||||
location
|
||||
);
|
||||
console.log("Not zooming to GPS location: out of bounds", b, location);
|
||||
} else {
|
||||
const currentZoom = this._leafletMap.data.getZoom()
|
||||
|
||||
this._leafletMap.data.setView([location.latitude, location.longitude], Math.max(targetZoom ?? 0, currentZoom));
|
||||
}
|
||||
}
|
||||
|
@ -305,14 +368,14 @@ export default class GeoLocationHandler extends VariableUiElement {
|
|||
private StartGeolocating(zoomToGPS = true) {
|
||||
const self = this;
|
||||
|
||||
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
|
||||
this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0))
|
||||
if (self._permission.data === "denied") {
|
||||
self._previousLocationGrant.setData("");
|
||||
self._isActive.setData(false)
|
||||
return "";
|
||||
}
|
||||
if (this._currentGPSLocation.data !== undefined) {
|
||||
this.MoveToCurrentLoction(16);
|
||||
this.MoveToCurrentLocation(16);
|
||||
}
|
||||
|
||||
if (self._isActive.data) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import {Or} from "../Tags/Or";
|
||||
import {Overpass} from "../Osm/Overpass";
|
||||
import FeatureSource from "../FeatureSource/FeatureSource";
|
||||
|
@ -34,13 +34,13 @@ export default class OverpassFeatureSource implements FeatureSource {
|
|||
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0);
|
||||
|
||||
private readonly state: {
|
||||
readonly locationControl: UIEventSource<Loc>,
|
||||
readonly locationControl: Store<Loc>,
|
||||
readonly layoutToUse: LayoutConfig,
|
||||
readonly overpassUrl: UIEventSource<string[]>;
|
||||
readonly overpassTimeout: UIEventSource<number>;
|
||||
readonly currentBounds: UIEventSource<BBox>
|
||||
readonly overpassUrl: Store<string[]>;
|
||||
readonly overpassTimeout: Store<number>;
|
||||
readonly currentBounds: Store<BBox>
|
||||
}
|
||||
private readonly _isActive: UIEventSource<boolean>
|
||||
private readonly _isActive: Store<boolean>
|
||||
/**
|
||||
* Callback to handle all the data
|
||||
*/
|
||||
|
@ -54,16 +54,16 @@ export default class OverpassFeatureSource implements FeatureSource {
|
|||
|
||||
constructor(
|
||||
state: {
|
||||
readonly locationControl: UIEventSource<Loc>,
|
||||
readonly locationControl: Store<Loc>,
|
||||
readonly layoutToUse: LayoutConfig,
|
||||
readonly overpassUrl: UIEventSource<string[]>;
|
||||
readonly overpassTimeout: UIEventSource<number>;
|
||||
readonly overpassMaxZoom: UIEventSource<number>,
|
||||
readonly currentBounds: UIEventSource<BBox>
|
||||
readonly overpassUrl: Store<string[]>;
|
||||
readonly overpassTimeout: Store<number>;
|
||||
readonly overpassMaxZoom: Store<number>,
|
||||
readonly currentBounds: Store<BBox>
|
||||
},
|
||||
options: {
|
||||
padToTiles: UIEventSource<number>,
|
||||
isActive?: UIEventSource<boolean>,
|
||||
padToTiles: Store<number>,
|
||||
isActive?: Store<boolean>,
|
||||
relationTracker: RelationsTracker,
|
||||
onBboxLoaded?: (bbox: BBox, date: Date, layers: LayerConfig[], zoomlevel: number) => void,
|
||||
freshnesses?: Map<string, TileFreshnessCalculator>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import Translations from "../../UI/i18n/Translations";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import Locale from "../../UI/i18n/Locale";
|
||||
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
|
||||
import Combine from "../../UI/Base/Combine";
|
||||
|
@ -9,14 +8,14 @@ import {Utils} from "../../Utils";
|
|||
|
||||
export default class TitleHandler {
|
||||
constructor(state: {
|
||||
selectedElement: UIEventSource<any>,
|
||||
selectedElement: Store<any>,
|
||||
layoutToUse: LayoutConfig,
|
||||
allElements: ElementStorage
|
||||
}) {
|
||||
const currentTitle: UIEventSource<string> = state.selectedElement.map(
|
||||
const currentTitle: Store<string> = state.selectedElement.map(
|
||||
selected => {
|
||||
const layout = state.layoutToUse
|
||||
const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete"
|
||||
const defaultTitle = layout?.title?.txt ?? "MapComplete"
|
||||
|
||||
if (selected === undefined) {
|
||||
return defaultTitle
|
||||
|
@ -30,7 +29,7 @@ export default class TitleHandler {
|
|||
if (layer.source.osmTags.matchesProperties(tags)) {
|
||||
const tagsSource = state.allElements.getEventSourceById(tags.id) ?? new UIEventSource<any>(tags)
|
||||
const title = new TagRenderingAnswer(tagsSource, layer.title, {})
|
||||
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.innerText ?? defaultTitle;
|
||||
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.textContent ?? defaultTitle;
|
||||
}
|
||||
}
|
||||
return defaultTitle
|
||||
|
|
|
@ -64,6 +64,15 @@ export class BBox {
|
|||
return new BBox([[maxLon, maxLat], [minLon, minLat]])
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the BBox based on a slippy map tile number
|
||||
*
|
||||
* const bbox = BBox.fromTile(16, 32754, 21785)
|
||||
* bbox.minLon // => -0.076904296875
|
||||
* bbox.maxLon // => -0.0714111328125
|
||||
* bbox.minLat // => 51.5292513551899
|
||||
* bbox.maxLat // => 51.53266860674158
|
||||
*/
|
||||
static fromTile(z: number, x: number, y: number): BBox {
|
||||
return new BBox(Tiles.tile_bounds_lon_lat(z, x, y))
|
||||
}
|
||||
|
@ -209,9 +218,9 @@ export class BBox {
|
|||
|
||||
}
|
||||
|
||||
private check() {
|
||||
private check() {
|
||||
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
|
||||
console.log(this);
|
||||
console.trace("BBox with NaN detected:", this);
|
||||
throw "BBOX has NAN";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/// Given a feature source, calculates a list of OSM-contributors who mapped the latest versions
|
||||
import {UIEventSource} from "./UIEventSource";
|
||||
import {Store, UIEventSource} from "./UIEventSource";
|
||||
import FeaturePipeline from "./FeatureSource/FeaturePipeline";
|
||||
import Loc from "../Models/Loc";
|
||||
import {BBox} from "./BBox";
|
||||
|
@ -7,10 +7,10 @@ import {BBox} from "./BBox";
|
|||
export default class ContributorCount {
|
||||
|
||||
public readonly Contributors: UIEventSource<Map<string, number>> = new UIEventSource<Map<string, number>>(new Map<string, number>());
|
||||
private readonly state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource<BBox>, locationControl: UIEventSource<Loc> };
|
||||
private readonly state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> };
|
||||
private lastUpdate: Date = undefined;
|
||||
|
||||
constructor(state: { featurePipeline: FeaturePipeline, currentBounds: UIEventSource<BBox>, locationControl: UIEventSource<Loc> }) {
|
||||
constructor(state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> }) {
|
||||
this.state = state;
|
||||
const self = this;
|
||||
state.currentBounds.map(bbox => {
|
||||
|
|
|
@ -9,12 +9,10 @@ import BaseUIElement from "../UI/BaseUIElement";
|
|||
import {UIEventSource} from "./UIEventSource";
|
||||
import {LocalStorageSource} from "./Web/LocalStorageSource";
|
||||
import LZString from "lz-string";
|
||||
import * as personal from "../assets/themes/personal/personal.json";
|
||||
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
|
||||
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
|
||||
import SharedTagRenderings from "../Customizations/SharedTagRenderings";
|
||||
import * as known_layers from "../assets/generated/known_layers.json"
|
||||
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
|
||||
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
|
||||
import * as licenses from "../assets/generated/license_info.json"
|
||||
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig";
|
||||
|
@ -43,10 +41,6 @@ export default class DetermineLayout {
|
|||
}
|
||||
|
||||
let layoutId: string = undefined
|
||||
if (location.href.indexOf("buurtnatuur.be") >= 0) {
|
||||
layoutId = "buurtnatuur"
|
||||
}
|
||||
|
||||
|
||||
const path = window.location.pathname.split("/").slice(-1)[0];
|
||||
if (path !== "theme.html" && path !== "") {
|
||||
|
@ -57,22 +51,12 @@ export default class DetermineLayout {
|
|||
console.log("Using layout", layoutId);
|
||||
}
|
||||
layoutId = QueryParameters.GetQueryParameter("layout", layoutId, "The layout to load into MapComplete").data;
|
||||
const layoutToUse: LayoutConfig = AllKnownLayouts.allKnownLayouts.get(layoutId?.toLowerCase());
|
||||
|
||||
if (layoutToUse?.id === personal.id) {
|
||||
layoutToUse.layers = AllKnownLayouts.AllPublicLayers()
|
||||
for (const layer of layoutToUse.layers) {
|
||||
layer.minzoomVisible = Math.max(layer.minzoomVisible, layer.minzoom)
|
||||
layer.minzoom = Math.max(16, layer.minzoom)
|
||||
}
|
||||
}
|
||||
|
||||
return layoutToUse
|
||||
return AllKnownLayouts.allKnownLayouts.get(layoutId?.toLowerCase())
|
||||
}
|
||||
|
||||
public static LoadLayoutFromHash(
|
||||
userLayoutParam: UIEventSource<string>
|
||||
): (LayoutConfig & {definition: LayoutConfigJson}) | null {
|
||||
): LayoutConfig | null {
|
||||
let hash = location.hash.substr(1);
|
||||
let json: any;
|
||||
|
||||
|
@ -113,9 +97,7 @@ export default class DetermineLayout {
|
|||
|
||||
const layoutToUse = DetermineLayout.prepCustomTheme(json)
|
||||
userLayoutParam.setData(layoutToUse.id);
|
||||
const config = new LayoutConfig(layoutToUse, false);
|
||||
config["definition"] = json
|
||||
return <any> config
|
||||
return layoutToUse
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (hash === undefined || hash.length < 10) {
|
||||
|
@ -135,7 +117,7 @@ export default class DetermineLayout {
|
|||
error.SetClass("alert"),
|
||||
new SubtleButton(Svg.back_svg(),
|
||||
"Go back to the theme overview",
|
||||
{url: window.location.protocol + "//" + window.location.hostname + "/index.html", newTab: false}),
|
||||
{url: window.location.protocol + "//" + window.location.host + "/index.html", newTab: false}),
|
||||
json !== undefined ? new SubtleButton(Svg.download_svg(),"Download the JSON file").onClick(() => {
|
||||
Utils.offerContentsAsDownloadableFile(JSON.stringify(json, null, " "), "theme_definition.json")
|
||||
}) : undefined
|
||||
|
@ -144,7 +126,7 @@ export default class DetermineLayout {
|
|||
.AttachTo("centermessage");
|
||||
}
|
||||
|
||||
private static prepCustomTheme(json: any): LayoutConfigJson {
|
||||
private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig {
|
||||
|
||||
if(json.layers === undefined && json.tagRenderings !== undefined){
|
||||
const iconTr = json.mapRendering.map(mr => mr.icon).find(icon => icon !== undefined)
|
||||
|
@ -161,7 +143,6 @@ export default class DetermineLayout {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
const knownLayersDict = new Map<string, LayerConfigJson>()
|
||||
for (const key in known_layers.layers) {
|
||||
const layer = known_layers.layers[key]
|
||||
|
@ -169,13 +150,23 @@ export default class DetermineLayout {
|
|||
}
|
||||
const converState = {
|
||||
tagRenderings: SharedTagRenderings.SharedTagRenderingJson,
|
||||
sharedLayers: knownLayersDict
|
||||
sharedLayers: knownLayersDict,
|
||||
publicLayers: new Set<string>()
|
||||
}
|
||||
json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme")
|
||||
const raw = json;
|
||||
|
||||
json = new FixImages(DetermineLayout._knownImages).convertStrict(json, "While fixing the images")
|
||||
json.enableNoteImports = json.enableNoteImports ?? false;
|
||||
json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme")
|
||||
console.log("The layoutconfig is ", json)
|
||||
return json
|
||||
|
||||
json.id = forceId ?? json.id
|
||||
|
||||
return new LayoutConfig(json, false, {
|
||||
definitionRaw: JSON.stringify(raw, null, " "),
|
||||
definedAtUrl: sourceUrl
|
||||
})
|
||||
}
|
||||
|
||||
private static async LoadRemoteTheme(link: string): Promise<LayoutConfig | null> {
|
||||
|
@ -188,10 +179,13 @@ export default class DetermineLayout {
|
|||
|
||||
let parsed = await Utils.downloadJson(link)
|
||||
try {
|
||||
parsed.id = link;
|
||||
let forcedId = parsed.id
|
||||
const url = new URL(link)
|
||||
if(!(url.hostname === "localhost" || url.hostname === "127.0.0.1")){
|
||||
forcedId = link;
|
||||
}
|
||||
console.log("Loaded remote link:", link)
|
||||
const layoutToUse = DetermineLayout.prepCustomTheme(parsed)
|
||||
return new LayoutConfig(layoutToUse, false)
|
||||
return DetermineLayout.prepCustomTheme(parsed, link, forcedId);
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
DetermineLayout.ShowErrorOnCustomTheme(
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* Keeps track of a dictionary 'elementID' -> UIEventSource<tags>
|
||||
*/
|
||||
import {UIEventSource} from "./UIEventSource";
|
||||
import {GeoJSONObject} from "@turf/turf";
|
||||
|
||||
export class ElementStorage {
|
||||
|
||||
|
@ -49,6 +50,29 @@ export class ElementStorage {
|
|||
return this._elements.has(id);
|
||||
}
|
||||
|
||||
addAlias(oldId: string, newId: string){
|
||||
if (newId === undefined) {
|
||||
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
|
||||
const element = this.getEventSourceById(oldId);
|
||||
element.data._deleted = "yes"
|
||||
element.ping();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldId == newId) {
|
||||
return undefined;
|
||||
}
|
||||
const element = this.getEventSourceById( oldId);
|
||||
if (element === undefined) {
|
||||
// Element to rewrite not found, probably a node or relation that is not rendered
|
||||
return undefined
|
||||
}
|
||||
element.data.id = newId;
|
||||
this.addElementById(newId, element);
|
||||
this.ContainingFeatures.set(newId, this.ContainingFeatures.get( oldId))
|
||||
element.ping();
|
||||
}
|
||||
|
||||
private addOrGetById(elementId: string, newProperties: any): UIEventSource<any> {
|
||||
if (!this._elements.has(elementId)) {
|
||||
const eventSource = new UIEventSource<any>(newProperties, "tags of " + elementId);
|
||||
|
|
|
@ -5,6 +5,7 @@ import BaseUIElement from "../UI/BaseUIElement";
|
|||
import List from "../UI/Base/List";
|
||||
import Title from "../UI/Base/Title";
|
||||
import {BBox} from "./BBox";
|
||||
import {Feature, Geometry, MultiPolygon, Polygon} from "@turf/turf";
|
||||
|
||||
export interface ExtraFuncParams {
|
||||
/**
|
||||
|
@ -12,9 +13,9 @@ export interface ExtraFuncParams {
|
|||
* Note that more features then requested can be given back.
|
||||
* Format: [ [ geojson, geojson, geojson, ... ], [geojson, ...], ...]
|
||||
*/
|
||||
getFeaturesWithin: (layerId: string, bbox: BBox) => any[][],
|
||||
getFeaturesWithin: (layerId: string, bbox: BBox) => Feature<Geometry, { id: string }>[][],
|
||||
memberships: RelationsTracker
|
||||
getFeatureById: (id: string) => any
|
||||
getFeatureById: (id: string) => Feature<Geometry, { id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,42 +25,94 @@ interface ExtraFunction {
|
|||
readonly _name: string;
|
||||
readonly _args: string[];
|
||||
readonly _doc: string;
|
||||
readonly _f: (params: ExtraFuncParams, feat: any) => any;
|
||||
readonly _f: (params: ExtraFuncParams, feat: Feature<Geometry, any>) => any;
|
||||
|
||||
}
|
||||
|
||||
class EnclosingFunc implements ExtraFunction {
|
||||
_name = "enclosingFeatures"
|
||||
_doc = ["Gives a list of all features in the specified layers which fully contain this object. Returned features will always be (multi)polygons. (LineStrings and Points from the other layers are ignored)", "",
|
||||
"The result is a list of features: `{feat: Polygon}[]`",
|
||||
"This function will never return the feature itself."].join("\n")
|
||||
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"]
|
||||
|
||||
_f(params: ExtraFuncParams, feat: Feature<Geometry, any>) {
|
||||
return (...layerIds: string[]) => {
|
||||
const result: { feat: any }[] = []
|
||||
const bbox = BBox.get(feat)
|
||||
const seenIds = new Set<string>()
|
||||
seenIds.add(feat.properties.id)
|
||||
for (const layerId of layerIds) {
|
||||
const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
|
||||
if (otherFeaturess === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (otherFeaturess.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const otherFeatures of otherFeaturess) {
|
||||
for (const otherFeature of otherFeatures) {
|
||||
if (seenIds.has(otherFeature.properties.id)) {
|
||||
continue
|
||||
}
|
||||
seenIds.add(otherFeature.properties.id)
|
||||
if (otherFeature.geometry.type !== "Polygon" && otherFeature.geometry.type !== "MultiPolygon") {
|
||||
continue;
|
||||
}
|
||||
if (GeoOperations.completelyWithin(feat, <Feature<Polygon | MultiPolygon, any>>otherFeature)) {
|
||||
result.push({feat: otherFeature})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OverlapFunc implements ExtraFunction {
|
||||
|
||||
|
||||
_name = "overlapWith";
|
||||
_doc = "Gives a list of features from the specified layer which this feature (partly) overlaps with. A point which is embedded in the feature is detected as well." +
|
||||
"If the current feature is a point, all features that this point is embeded in are given.\n\n" +
|
||||
"The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point.\n" +
|
||||
"The resulting list is sorted in descending order by overlap. The feature with the most overlap will thus be the first in the list\n" +
|
||||
"\n" +
|
||||
"For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`"
|
||||
_doc = ["Gives a list of features from the specified layer which this feature (partly) overlaps with. A point which is embedded in the feature is detected as well.",
|
||||
"If the current feature is a point, all features that this point is embeded in are given.",
|
||||
"",
|
||||
"The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point.",
|
||||
"The resulting list is sorted in descending order by overlap. The feature with the most overlap will thus be the first in the list.",
|
||||
"",
|
||||
"For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`",
|
||||
"",
|
||||
"Also see [enclosingFeatures](#enclosingFeatures) which can be used to get all objects which fully contain this feature"
|
||||
].join("\n")
|
||||
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"]
|
||||
|
||||
_f(params, feat) {
|
||||
return (...layerIds: string[]) => {
|
||||
const result: { feat: any, overlap: number }[] = []
|
||||
const seenIds = new Set<string>()
|
||||
const bbox = BBox.get(feat)
|
||||
for (const layerId of layerIds) {
|
||||
const otherLayers = params.getFeaturesWithin(layerId, bbox)
|
||||
if (otherLayers === undefined) {
|
||||
const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
|
||||
if (otherFeaturess === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (otherLayers.length === 0) {
|
||||
if (otherFeaturess.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const otherLayer of otherLayers) {
|
||||
result.push(...GeoOperations.calculateOverlap(feat, otherLayer));
|
||||
for (const otherFeatures of otherFeaturess) {
|
||||
const overlap = GeoOperations.calculateOverlap(feat, otherFeatures)
|
||||
for (const overlappingFeature of overlap) {
|
||||
if(seenIds.has(overlappingFeature.feat.properties.id)){
|
||||
continue
|
||||
}
|
||||
seenIds.add(overlappingFeature.feat.properties.id)
|
||||
result.push(overlappingFeature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) => b.overlap - a.overlap)
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -142,7 +195,7 @@ class DistanceToFunc implements ExtraFunction {
|
|||
|
||||
class ClosestObjectFunc implements ExtraFunction {
|
||||
_name = "closest"
|
||||
_doc = "Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. Returns a single geojson feature or undefined if nothing is found (or not yet laoded)"
|
||||
_doc = "Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. Returns a single geojson feature or undefined if nothing is found (or not yet loaded)"
|
||||
|
||||
_args = ["list of features or a layer name or '*' to get all features"]
|
||||
|
||||
|
@ -392,6 +445,7 @@ export class ExtraFunctions {
|
|||
private static readonly allFuncs: ExtraFunction[] = [
|
||||
new DistanceToFunc(),
|
||||
new OverlapFunc(),
|
||||
new EnclosingFunc(),
|
||||
new IntersectionFunc(),
|
||||
new ClosestObjectFunc(),
|
||||
new ClosestNObjectFunc(),
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import FeatureSource from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import {Store} from "../../UIEventSource";
|
||||
import {ElementStorage} from "../../ElementStorage";
|
||||
|
||||
/**
|
||||
* Makes sure that every feature is added to the ElementsStorage, so that the tags-eventsource can be retrieved
|
||||
*/
|
||||
export default class RegisteringAllFromFeatureSourceActor {
|
||||
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
|
||||
public readonly features: Store<{ feature: any; freshness: Date }[]>;
|
||||
public readonly name;
|
||||
|
||||
constructor(source: FeatureSource, allElements: ElementStorage) {
|
||||
|
|
|
@ -79,6 +79,9 @@ export default class SaveTileToLocalStorageActor {
|
|||
}
|
||||
loadedTiles.add(key)
|
||||
this.GetIdb(key).then((features: { feature: any, freshness: Date }[]) => {
|
||||
if(features === undefined){
|
||||
return;
|
||||
}
|
||||
console.debug("Loaded tile " + self._layer.id + "_" + key + " from disk")
|
||||
const src = new SimpleFeatureSource(self._flayer, key, new UIEventSource<{ feature: any; freshness: Date }[]>(features))
|
||||
registerTile(src)
|
||||
|
|
|
@ -3,7 +3,7 @@ import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
|
|||
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter";
|
||||
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "./FeatureSource";
|
||||
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
|
||||
import RememberingSource from "./Sources/RememberingSource";
|
||||
import OverpassFeatureSource from "../Actors/OverpassFeatureSource";
|
||||
|
@ -23,6 +23,11 @@ import TileFreshnessCalculator from "./TileFreshnessCalculator";
|
|||
import FullNodeDatabaseSource from "./TiledFeatureSource/FullNodeDatabaseSource";
|
||||
import MapState from "../State/MapState";
|
||||
import {ElementStorage} from "../ElementStorage";
|
||||
import {OsmFeature} from "../../Models/OsmFeature";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
import {FilterState} from "../../Models/FilteredLayer";
|
||||
import {GeoOperations} from "../GeoOperations";
|
||||
import {Utils} from "../../Utils";
|
||||
|
||||
|
||||
/**
|
||||
|
@ -38,8 +43,8 @@ import {ElementStorage} from "../ElementStorage";
|
|||
*/
|
||||
export default class FeaturePipeline {
|
||||
|
||||
public readonly sufficientlyZoomed: UIEventSource<boolean>;
|
||||
public readonly runningQuery: UIEventSource<boolean>;
|
||||
public readonly sufficientlyZoomed: Store<boolean>;
|
||||
public readonly runningQuery: Store<boolean>;
|
||||
public readonly timeout: UIEventSource<number>;
|
||||
public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
|
||||
|
@ -75,7 +80,7 @@ export default class FeaturePipeline {
|
|||
this.state = state;
|
||||
|
||||
const self = this
|
||||
const expiryInSeconds = Math.min(...state.layoutToUse.layers.map(l => l.maxAgeOfCache))
|
||||
const expiryInSeconds = Math.min(...state.layoutToUse?.layers?.map(l => l.maxAgeOfCache) ?? [])
|
||||
this.oldestAllowedDate = new Date(new Date().getTime() - expiryInSeconds);
|
||||
this.osmSourceZoomLevel = state.osmApiTileSize.data;
|
||||
const useOsmApi = state.locationControl.map(l => l.zoom > (state.overpassMaxZoom.data ?? 12))
|
||||
|
@ -314,7 +319,7 @@ export default class FeaturePipeline {
|
|||
// We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
|
||||
perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer)
|
||||
// AT last, we always apply the metatags whenever possible
|
||||
perLayer.features.addCallbackAndRunD(feats => {
|
||||
perLayer.features.addCallbackAndRunD(_ => {
|
||||
self.onNewDataLoaded(perLayer);
|
||||
})
|
||||
|
||||
|
@ -337,15 +342,39 @@ export default class FeaturePipeline {
|
|||
|
||||
}
|
||||
|
||||
public GetAllFeaturesWithin(bbox: BBox): any[][] {
|
||||
public GetAllFeaturesWithin(bbox: BBox): OsmFeature[][] {
|
||||
const self = this
|
||||
const tiles = []
|
||||
const tiles: OsmFeature[][] = []
|
||||
Array.from(this.perLayerHierarchy.keys())
|
||||
.forEach(key => tiles.push(...self.GetFeaturesWithin(key, bbox)))
|
||||
.forEach(key => {
|
||||
const fetched : OsmFeature[][] = self.GetFeaturesWithin(key, bbox)
|
||||
tiles.push(...fetched);
|
||||
})
|
||||
return tiles;
|
||||
}
|
||||
|
||||
public GetFeaturesWithin(layerId: string, bbox: BBox): any[][] {
|
||||
public GetAllFeaturesAndMetaWithin(bbox: BBox, layerIdWhitelist?: Set<string>):
|
||||
{features: OsmFeature[], layer: string}[] {
|
||||
const self = this
|
||||
const tiles :{features: any[], layer: string}[]= []
|
||||
Array.from(this.perLayerHierarchy.keys())
|
||||
.forEach(key => {
|
||||
if(layerIdWhitelist !== undefined && !layerIdWhitelist.has(key)){
|
||||
return;
|
||||
}
|
||||
return tiles.push({
|
||||
layer: key,
|
||||
features: [].concat(...self.GetFeaturesWithin(key, bbox))
|
||||
});
|
||||
})
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the tiles which overlap with the given BBOX.
|
||||
* This might imply that extra features might be shown
|
||||
*/
|
||||
public GetFeaturesWithin(layerId: string, bbox: BBox): OsmFeature[][] {
|
||||
if (layerId === "*") {
|
||||
return this.GetAllFeaturesWithin(bbox)
|
||||
}
|
||||
|
@ -401,7 +430,7 @@ export default class FeaturePipeline {
|
|||
/*
|
||||
* Gives an UIEventSource containing the tileIndexes of the tiles that should be loaded from OSM
|
||||
* */
|
||||
private getNeededTilesFromOsm(isSufficientlyZoomed: UIEventSource<boolean>): UIEventSource<number[]> {
|
||||
private getNeededTilesFromOsm(isSufficientlyZoomed: Store<boolean>): Store<number[]> {
|
||||
const self = this
|
||||
return this.state.currentBounds.map(bbox => {
|
||||
if (bbox === undefined) {
|
||||
|
@ -434,12 +463,12 @@ export default class FeaturePipeline {
|
|||
private initOverpassUpdater(state: {
|
||||
allElements: ElementStorage;
|
||||
layoutToUse: LayoutConfig,
|
||||
currentBounds: UIEventSource<BBox>,
|
||||
locationControl: UIEventSource<Loc>,
|
||||
readonly overpassUrl: UIEventSource<string[]>;
|
||||
readonly overpassTimeout: UIEventSource<number>;
|
||||
readonly overpassMaxZoom: UIEventSource<number>,
|
||||
}, useOsmApi: UIEventSource<boolean>): OverpassFeatureSource {
|
||||
currentBounds: Store<BBox>,
|
||||
locationControl: Store<Loc>,
|
||||
readonly overpassUrl: Store<string[]>;
|
||||
readonly overpassTimeout: Store<number>;
|
||||
readonly overpassMaxZoom: Store<number>,
|
||||
}, useOsmApi: Store<boolean>): OverpassFeatureSource {
|
||||
const minzoom = Math.min(...state.layoutToUse.layers.map(layer => layer.minzoom))
|
||||
const overpassIsActive = state.currentBounds.map(bbox => {
|
||||
if (bbox === undefined) {
|
||||
|
@ -488,6 +517,62 @@ export default class FeaturePipeline {
|
|||
return updater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds upon 'GetAllFeaturesAndMetaWithin', but does stricter BBOX-checking and applies the filters
|
||||
*/
|
||||
public getAllVisibleElementsWithmeta(bbox: BBox): { center: [number, number], element: OsmFeature, layer: LayerConfig }[] {
|
||||
if (bbox === undefined) {
|
||||
console.warn("No bbox")
|
||||
return []
|
||||
}
|
||||
|
||||
const layers = Utils.toIdRecord(this.state.layoutToUse.layers)
|
||||
const elementsWithMeta: { features: OsmFeature[], layer: string }[] = this.GetAllFeaturesAndMetaWithin(bbox)
|
||||
|
||||
let elements: {center: [number, number], element: OsmFeature, layer: LayerConfig }[] = []
|
||||
let seenElements = new Set<string>()
|
||||
for (const elementsWithMetaElement of elementsWithMeta) {
|
||||
const layer = layers[elementsWithMetaElement.layer]
|
||||
if(layer.title === undefined){
|
||||
continue
|
||||
}
|
||||
const filtered = this.state.filteredLayers.data.find(fl => fl.layerDef == layer);
|
||||
for (let i = 0; i < elementsWithMetaElement.features.length; i++) {
|
||||
const element = elementsWithMetaElement.features[i];
|
||||
if (!filtered.isDisplayed.data) {
|
||||
continue
|
||||
}
|
||||
if (seenElements.has(element.properties.id)) {
|
||||
continue
|
||||
}
|
||||
seenElements.add(element.properties.id)
|
||||
if (!bbox.overlapsWith(BBox.get(element))) {
|
||||
continue
|
||||
}
|
||||
if (layer?.isShown !== undefined && !layer.isShown.matchesProperties(element)) {
|
||||
continue
|
||||
}
|
||||
const activeFilters: FilterState[] = Array.from(filtered.appliedFilters.data.values());
|
||||
if (!activeFilters.every(filter => filter?.currentFilter === undefined || filter?.currentFilter?.matchesProperties(element.properties))) {
|
||||
continue
|
||||
}
|
||||
const center = GeoOperations.centerpointCoordinates(element);
|
||||
elements.push({
|
||||
element,
|
||||
center,
|
||||
layer: layers[elementsWithMetaElement.layer],
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inject a new point
|
||||
*/
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import FilteredLayer from "../../Models/FilteredLayer";
|
||||
import {BBox} from "../BBox";
|
||||
import {Feature, Geometry} from "@turf/turf";
|
||||
import {OsmFeature} from "../../Models/OsmFeature";
|
||||
|
||||
export default interface FeatureSource {
|
||||
features: UIEventSource<{ feature: any, freshness: Date }[]>;
|
||||
features: Store<{ feature: OsmFeature, freshness: Date }[]>;
|
||||
/**
|
||||
* Mainly used for debuging
|
||||
*/
|
||||
|
@ -26,14 +28,5 @@ export interface FeatureSourceForLayer extends FeatureSource {
|
|||
* A feature source which is aware of the indexes it contains
|
||||
*/
|
||||
export interface IndexedFeatureSource extends FeatureSource {
|
||||
readonly containedIds: UIEventSource<Set<string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature source which has some extra data about it's state
|
||||
*/
|
||||
export interface FeatureSourceState {
|
||||
readonly sufficientlyZoomed: UIEventSource<boolean>;
|
||||
readonly runningQuery: UIEventSource<boolean>;
|
||||
readonly timeout: UIEventSource<number>;
|
||||
readonly containedIds: Store<Set<string>>
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import FeatureSource, {FeatureSourceForLayer, Tiled} from "./FeatureSource";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store} from "../UIEventSource";
|
||||
import FilteredLayer from "../../Models/FilteredLayer";
|
||||
import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
|
||||
|
||||
|
@ -11,7 +11,7 @@ import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
|
|||
*/
|
||||
export default class PerLayerFeatureSourceSplitter {
|
||||
|
||||
constructor(layers: UIEventSource<FilteredLayer[]>,
|
||||
constructor(layers: Store<FilteredLayer[]>,
|
||||
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
|
||||
upstream: FeatureSource,
|
||||
options?: {
|
||||
|
@ -19,7 +19,7 @@ export default class PerLayerFeatureSourceSplitter {
|
|||
handleLeftovers?: (featuresWithoutLayer: any[]) => void
|
||||
}) {
|
||||
|
||||
const knownLayers = new Map<string, FeatureSourceForLayer & Tiled>()
|
||||
const knownLayers = new Map<string, SimpleFeatureSource>()
|
||||
|
||||
function update() {
|
||||
const features = upstream.features?.data;
|
||||
|
@ -41,17 +41,21 @@ export default class PerLayerFeatureSourceSplitter {
|
|||
}
|
||||
|
||||
for (const f of features) {
|
||||
let foundALayer = false;
|
||||
for (const layer of layers.data) {
|
||||
if (layer.layerDef.source.osmTags.matchesProperties(f.feature.properties)) {
|
||||
// We have found our matching layer!
|
||||
featuresPerLayer.get(layer.layerDef.id).push(f)
|
||||
foundALayer = true;
|
||||
if (!layer.layerDef.passAllFeatures) {
|
||||
// If not 'passAllFeatures', we are done for this feature
|
||||
break;
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
noLayerFound.push(f)
|
||||
if(!foundALayer){
|
||||
noLayerFound.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we have our features per layer as a list
|
||||
|
|
|
@ -74,7 +74,7 @@ export default class ChangeGeometryApplicator implements FeatureSourceForLayer {
|
|||
// 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)
|
||||
console.log("Applying a geometry change onto ", feature, change, copy)
|
||||
console.log("Applying a geometry change onto:", feature,"The change is:", change,"which becomes:", copy)
|
||||
newFeatures.push(copy)
|
||||
}
|
||||
this.features.setData(newFeatures)
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
/**
|
||||
* Merges features from different featureSources for a single layer
|
||||
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
|
||||
*/
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {Tiles} from "../../../Models/TileRange";
|
||||
import {BBox} from "../../BBox";
|
||||
|
||||
|
||||
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
|
||||
|
||||
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
|
||||
|
@ -17,7 +14,10 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled
|
|||
public readonly bbox: BBox;
|
||||
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(new Set())
|
||||
private readonly _sources: UIEventSource<FeatureSource[]>;
|
||||
|
||||
/**
|
||||
* Merges features from different featureSources for a single layer
|
||||
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
|
||||
*/
|
||||
constructor(layer: FilteredLayer, tileIndex: number, bbox: BBox, sources: UIEventSource<FeatureSource[]>) {
|
||||
this.tileIndex = tileIndex;
|
||||
this.bbox = bbox;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import {UIEventSource} from "../../UIEventSource";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {Store, UIEventSource} from "../../UIEventSource";
|
||||
import FilteredLayer, {FilterState} from "../../../Models/FilteredLayer";
|
||||
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
|
||||
import {BBox} from "../../BBox";
|
||||
import {ElementStorage} from "../../ElementStorage";
|
||||
import {TagsFilter} from "../../Tags/TagsFilter";
|
||||
import {OsmFeature} from "../../../Models/OsmFeature";
|
||||
|
||||
export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled {
|
||||
public features: UIEventSource<{ feature: any; freshness: Date }[]> =
|
||||
|
@ -14,7 +15,9 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
|
|||
public readonly bbox: BBox
|
||||
private readonly upstream: FeatureSourceForLayer;
|
||||
private readonly state: {
|
||||
locationControl: UIEventSource<{ zoom: number }>; selectedElement: UIEventSource<any>,
|
||||
locationControl: Store<{ zoom: number }>;
|
||||
selectedElement: Store<any>,
|
||||
globalFilters: Store<{ filter: FilterState }[]>,
|
||||
allElements: ElementStorage
|
||||
};
|
||||
private readonly _alreadyRegistered = new Set<UIEventSource<any>>();
|
||||
|
@ -23,9 +26,10 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
|
|||
|
||||
constructor(
|
||||
state: {
|
||||
locationControl: UIEventSource<{ zoom: number }>,
|
||||
selectedElement: UIEventSource<any>,
|
||||
allElements: ElementStorage
|
||||
locationControl: Store<{ zoom: number }>,
|
||||
selectedElement: Store<any>,
|
||||
allElements: ElementStorage,
|
||||
globalFilters: Store<{ filter: FilterState }[]>
|
||||
},
|
||||
tileIndex,
|
||||
upstream: FeatureSourceForLayer,
|
||||
|
@ -49,7 +53,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
|
|||
self.update()
|
||||
})
|
||||
|
||||
this._is_dirty.stabilized(250).addCallbackAndRunD(dirty => {
|
||||
this._is_dirty.stabilized(1000).addCallbackAndRunD(dirty => {
|
||||
if (dirty) {
|
||||
self.update()
|
||||
}
|
||||
|
@ -58,6 +62,10 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
|
|||
metataggingUpdated?.addCallback(_ => {
|
||||
self._is_dirty.setData(true)
|
||||
})
|
||||
|
||||
state.globalFilters.addCallback(_ => {
|
||||
self.update()
|
||||
})
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
@ -65,28 +73,32 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
|
|||
private update() {
|
||||
const self = this;
|
||||
const layer = this.upstream.layer;
|
||||
const features: { feature: any; freshness: Date }[] = (this.upstream.features.data ?? []);
|
||||
const features: { feature: OsmFeature; freshness: Date }[] = (this.upstream.features.data ?? []);
|
||||
const includedFeatureIds = new Set<string>();
|
||||
const globalFilters = self.state.globalFilters.data.map(f => f.filter);
|
||||
const newFeatures = (features ?? []).filter((f) => {
|
||||
|
||||
self.registerCallback(f.feature)
|
||||
|
||||
const isShown = layer.layerDef.isShown;
|
||||
const isShown: TagsFilter = layer.layerDef.isShown;
|
||||
const tags = f.feature.properties;
|
||||
if (isShown.IsKnown(tags)) {
|
||||
const result = layer.layerDef.isShown.GetRenderValue(
|
||||
f.feature.properties
|
||||
).txt;
|
||||
if (result !== "yes") {
|
||||
return false;
|
||||
}
|
||||
if (isShown !== undefined && !isShown.matchesProperties(tags) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagsFilter = Array.from(layer.appliedFilters?.data?.values() ?? [])
|
||||
for (const filter of tagsFilter) {
|
||||
const neededTags: TagsFilter = filter?.currentFilter
|
||||
if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) {
|
||||
// Hidden by the filter on the layer itself - we want to hide it no matter wat
|
||||
// Hidden by the filter on the layer itself - we want to hide it no matter what
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of globalFilters) {
|
||||
const neededTags: TagsFilter = filter?.currentFilter
|
||||
if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) {
|
||||
// Hidden by the filter on the layer itself - we want to hide it no matter what
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {GeoOperations} from "../../GeoOperations";
|
|||
export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
|
||||
|
||||
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
|
||||
public readonly state = new UIEventSource<undefined | {error: string} | "loaded">(undefined)
|
||||
public readonly name;
|
||||
public readonly isOsmCache: boolean
|
||||
public readonly layer: FilteredLayer;
|
||||
|
@ -78,8 +79,31 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
|
|||
private LoadJSONFrom(url: string) {
|
||||
const eventSource = this.features;
|
||||
const self = this;
|
||||
Utils.downloadJson(url)
|
||||
Utils.downloadJsonCached(url, 60 * 60)
|
||||
.then(json => {
|
||||
self.state.setData("loaded")
|
||||
// TODO: move somewhere else, just for testing
|
||||
// Check for maproulette data
|
||||
if (url.startsWith("https://maproulette.org/api/v2/tasks/box/")) {
|
||||
console.log("MapRoulette data detected")
|
||||
const data = json;
|
||||
let maprouletteFeatures: any[] = [];
|
||||
data.forEach(element => {
|
||||
maprouletteFeatures.push({
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [element.point.lng, element.point.lat]
|
||||
},
|
||||
properties: {
|
||||
// Map all properties to the feature
|
||||
...element,
|
||||
}
|
||||
});
|
||||
});
|
||||
json.features = maprouletteFeatures;
|
||||
}
|
||||
|
||||
if (json.features === undefined || json.features === null) {
|
||||
return;
|
||||
}
|
||||
|
@ -135,7 +159,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
|
|||
|
||||
eventSource.setData(eventSource.data.concat(newFeatures))
|
||||
|
||||
}).catch(msg => console.debug("Could not load geojson layer", url, "due to", msg))
|
||||
}).catch(msg => {
|
||||
console.debug("Could not load geojson layer", url, "due to", msg);
|
||||
self.state.setData({error: msg})
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,7 +9,10 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
|
|||
// This class name truly puts the 'Java' into 'Javascript'
|
||||
|
||||
/**
|
||||
* A feature source containing exclusively new elements
|
||||
* A feature source containing exclusively new elements.
|
||||
*
|
||||
* These elements are probably created by the 'SimpleAddUi' which generates a new point, but the import functionality might create a line or polygon too.
|
||||
* Other sources of new points are e.g. imports from nodes
|
||||
*/
|
||||
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
|
||||
public readonly name: string = "newFeatures";
|
||||
|
@ -54,8 +57,8 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
|
|||
// In _most_ of the cases, this means that this _isn't_ a new object
|
||||
// However, when a point is snapped to an already existing point, we have to create a representation for this point!
|
||||
// For this, we introspect the change
|
||||
if (allElementStorage.has(change.id)) {
|
||||
// const currentTags = allElementStorage.getEventSourceById(change.id).data
|
||||
if (allElementStorage.has(change.type + "/" + change.id)) {
|
||||
// The current point already exists, we don't have to do anything here
|
||||
continue;
|
||||
}
|
||||
console.debug("Detected a reused point")
|
||||
|
|
|
@ -3,12 +3,12 @@
|
|||
* Data coming from upstream will always overwrite a previous value
|
||||
*/
|
||||
import FeatureSource, {Tiled} from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import {Store, UIEventSource} from "../../UIEventSource";
|
||||
import {BBox} from "../../BBox";
|
||||
|
||||
export default class RememberingSource implements FeatureSource, Tiled {
|
||||
|
||||
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>;
|
||||
public readonly features: Store<{ feature: any, freshness: Date }[]>;
|
||||
public readonly name;
|
||||
public readonly tileIndex: number
|
||||
public readonly bbox: BBox
|
||||
|
@ -20,17 +20,15 @@ export default class RememberingSource implements FeatureSource, Tiled {
|
|||
this.bbox = source.bbox;
|
||||
|
||||
const empty = [];
|
||||
this.features = source.features.map(features => {
|
||||
const featureSource = new UIEventSource<{feature: any, freshness: Date}[]>(empty)
|
||||
this.features = featureSource
|
||||
source.features.addCallbackAndRunD(features => {
|
||||
const oldFeatures = self.features?.data ?? empty;
|
||||
if (features === undefined) {
|
||||
return oldFeatures;
|
||||
}
|
||||
|
||||
// Then new ids
|
||||
const ids = new Set<string>(features.map(f => f.feature.properties.id + f.feature.geometry.type));
|
||||
// the old data
|
||||
const oldData = oldFeatures.filter(old => !ids.has(old.feature.properties.id + old.feature.geometry.type))
|
||||
return [...features, ...oldData];
|
||||
featureSource.setData([...features, ...oldData])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,35 +1,103 @@
|
|||
/**
|
||||
* This feature source helps the ShowDataLayer class: it introduces the necessary extra features and indiciates with what renderConfig it should be rendered.
|
||||
* This feature source helps the ShowDataLayer class: it introduces the necessary extra features and indicates with what renderConfig it should be rendered.
|
||||
*/
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import {Store} from "../../UIEventSource";
|
||||
import {GeoOperations} from "../../GeoOperations";
|
||||
import FeatureSource from "../FeatureSource";
|
||||
import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig";
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
|
||||
import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig";
|
||||
|
||||
export default class RenderingMultiPlexerFeatureSource {
|
||||
public readonly features: UIEventSource<(any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined })[]>;
|
||||
public readonly features: Store<(any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined })[]>;
|
||||
private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[];
|
||||
private centroidRenderings: { rendering: PointRenderingConfig; index: number }[];
|
||||
private projectedCentroidRenderings: { rendering: PointRenderingConfig; index: number }[];
|
||||
private startRenderings: { rendering: PointRenderingConfig; index: number }[];
|
||||
private endRenderings: { rendering: PointRenderingConfig; index: number }[];
|
||||
private hasCentroid: boolean;
|
||||
private lineRenderObjects: LineRenderingConfig[];
|
||||
|
||||
|
||||
private inspectFeature(feat, addAsPoint: (feat, rendering, centerpoint: [number, number]) => void, withIndex: any[]){
|
||||
if (feat.geometry.type === "Point") {
|
||||
|
||||
for (const rendering of this.pointRenderings) {
|
||||
withIndex.push({
|
||||
...feat,
|
||||
pointRenderingIndex: rendering.index
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// This is a a line: add the centroids
|
||||
let centerpoint: [number, number] = undefined;
|
||||
let projectedCenterPoint : [number, number] = undefined
|
||||
if(this.hasCentroid){
|
||||
centerpoint = GeoOperations.centerpointCoordinates(feat)
|
||||
if(this.projectedCentroidRenderings.length > 0){
|
||||
projectedCenterPoint = <[number,number]> GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates
|
||||
}
|
||||
}
|
||||
for (const rendering of this.centroidRenderings) {
|
||||
addAsPoint(feat, rendering, centerpoint)
|
||||
}
|
||||
|
||||
|
||||
if (feat.geometry.type === "LineString") {
|
||||
|
||||
for (const rendering of this.projectedCentroidRenderings) {
|
||||
addAsPoint(feat, rendering, projectedCenterPoint)
|
||||
}
|
||||
|
||||
// Add start- and endpoints
|
||||
const coordinates = feat.geometry.coordinates
|
||||
for (const rendering of this.startRenderings) {
|
||||
addAsPoint(feat, rendering, coordinates[0])
|
||||
}
|
||||
for (const rendering of this.endRenderings) {
|
||||
const coordinate = coordinates[coordinates.length - 1]
|
||||
addAsPoint(feat, rendering, coordinate)
|
||||
}
|
||||
|
||||
}else{
|
||||
for (const rendering of this.projectedCentroidRenderings) {
|
||||
addAsPoint(feat, rendering, centerpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// AT last, add it 'as is' to what we should render
|
||||
for (let i = 0; i < this.lineRenderObjects.length; i++) {
|
||||
withIndex.push({
|
||||
...feat,
|
||||
lineRenderingIndex: i
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
constructor(upstream: FeatureSource, layer: LayerConfig) {
|
||||
|
||||
const pointRenderObjects: { rendering: PointRenderingConfig, index: number }[] = layer.mapRendering.map((r, i) => ({
|
||||
rendering: r,
|
||||
index: i
|
||||
}))
|
||||
this.pointRenderings = pointRenderObjects.filter(r => r.rendering.location.has("point"))
|
||||
this.centroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("centroid"))
|
||||
this.projectedCentroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("projected_centerpoint"))
|
||||
this.startRenderings = pointRenderObjects.filter(r => r.rendering.location.has("start"))
|
||||
this.endRenderings = pointRenderObjects.filter(r => r.rendering.location.has("end"))
|
||||
this.hasCentroid = this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0
|
||||
this.lineRenderObjects = layer.lineRendering
|
||||
|
||||
this.features = upstream.features.map(
|
||||
features => {
|
||||
if (features === undefined) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pointRenderObjects: { rendering: PointRenderingConfig, index: number }[] = layer.mapRendering.map((r, i) => ({
|
||||
rendering: r,
|
||||
index: i
|
||||
}))
|
||||
const pointRenderings = pointRenderObjects.filter(r => r.rendering.location.has("point"))
|
||||
const centroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("centroid"))
|
||||
const startRenderings = pointRenderObjects.filter(r => r.rendering.location.has("start"))
|
||||
const endRenderings = pointRenderObjects.filter(r => r.rendering.location.has("end"))
|
||||
|
||||
const lineRenderObjects = layer.lineRendering
|
||||
|
||||
const withIndex: (any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined, multiLineStringIndex: number | undefined })[] = [];
|
||||
|
||||
const withIndex: any[] = [];
|
||||
|
||||
function addAsPoint(feat, rendering, coordinate) {
|
||||
const patched = {
|
||||
|
@ -42,46 +110,14 @@ export default class RenderingMultiPlexerFeatureSource {
|
|||
}
|
||||
withIndex.push(patched)
|
||||
}
|
||||
|
||||
|
||||
for (const f of features) {
|
||||
const feat = f.feature;
|
||||
if (feat.geometry.type === "Point") {
|
||||
|
||||
for (const rendering of pointRenderings) {
|
||||
withIndex.push({
|
||||
...feat,
|
||||
pointRenderingIndex: rendering.index
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// This is a a line: add the centroids
|
||||
for (const rendering of centroidRenderings) {
|
||||
addAsPoint(feat, rendering, GeoOperations.centerpointCoordinates(feat))
|
||||
}
|
||||
|
||||
if (feat.geometry.type === "LineString") {
|
||||
|
||||
// Add start- and endpoints
|
||||
const coordinates = feat.geometry.coordinates
|
||||
for (const rendering of startRenderings) {
|
||||
addAsPoint(feat, rendering, coordinates[0])
|
||||
}
|
||||
for (const rendering of endRenderings) {
|
||||
const coordinate = coordinates[coordinates.length - 1]
|
||||
addAsPoint(feat, rendering, coordinate)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// AT last, add it 'as is' to what we should render
|
||||
for (let i = 0; i < lineRenderObjects.length; i++) {
|
||||
withIndex.push({
|
||||
...feat,
|
||||
lineRenderingIndex: i
|
||||
})
|
||||
}
|
||||
|
||||
if(feat === undefined){
|
||||
continue
|
||||
}
|
||||
this.inspectFeature(feat, addAsPoint, withIndex)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled
|
|||
public readonly bbox: BBox = BBox.global;
|
||||
public readonly tileIndex: number;
|
||||
|
||||
constructor(layer: FilteredLayer, tileIndex: number, featureSource?: UIEventSource<{ feature: any; freshness: Date }[]>) {
|
||||
constructor(layer: FilteredLayer, tileIndex: number, featureSource?: UIEventSource<{ feature: any; freshness: Date }[]> ) {
|
||||
this.name = "SimpleFeatureSource(" + layer.layerDef.id + ")"
|
||||
this.layer = layer
|
||||
this.tileIndex = tileIndex ?? 0;
|
||||
|
|
|
@ -1,31 +1,62 @@
|
|||
import FeatureSource from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
|
||||
import {ImmutableStore, Store, UIEventSource} from "../../UIEventSource";
|
||||
import {stat} from "fs";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {BBox} from "../../BBox";
|
||||
import {Feature} from "@turf/turf";
|
||||
|
||||
/**
|
||||
* A simple dummy implementation for whenever it is needed
|
||||
* A simple, read only feature store.
|
||||
*/
|
||||
export default class StaticFeatureSource implements FeatureSource {
|
||||
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
|
||||
public readonly name: string = "StaticFeatureSource"
|
||||
public readonly features: Store<{ feature: any; freshness: Date }[]>;
|
||||
public readonly name: string
|
||||
|
||||
constructor(features: any[] | UIEventSource<any[] | UIEventSource<{ feature: any, freshness: Date }>>, useFeaturesDirectly) {
|
||||
const now = new Date();
|
||||
if(features === undefined){
|
||||
constructor(features: Store<{ feature: Feature, freshness: Date }[]>, name = "StaticFeatureSource") {
|
||||
if (features === undefined) {
|
||||
throw "Static feature source received undefined as source"
|
||||
}
|
||||
if (useFeaturesDirectly) {
|
||||
// @ts-ignore
|
||||
this.features = features
|
||||
} else if (features instanceof UIEventSource) {
|
||||
// @ts-ignore
|
||||
this.features = features.map(features => features?.map(f => ({feature: f, freshness: now}) ?? []))
|
||||
} else {
|
||||
this.features = new UIEventSource(features?.map(f => ({
|
||||
feature: f,
|
||||
freshness: now
|
||||
}))??[])
|
||||
}
|
||||
this.name = name;
|
||||
this.features = features;
|
||||
}
|
||||
|
||||
public static fromGeojsonAndDate(features: { feature: Feature, freshness: Date }[], name = "StaticFeatureSourceFromGeojsonAndDate"): StaticFeatureSource {
|
||||
return new StaticFeatureSource(new ImmutableStore(features), name);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
public static fromGeojson(geojson: Feature[], name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource {
|
||||
const now = new Date();
|
||||
return StaticFeatureSource.fromGeojsonAndDate(geojson.map(feature => ({feature, freshness: now})), name);
|
||||
}
|
||||
|
||||
public static fromGeojsonStore(geojson: Store<Feature[]>, name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource {
|
||||
const now = new Date();
|
||||
const mapped : Store<{feature: Feature, freshness: Date}[]> = geojson.map(features => features.map(feature => ({feature, freshness: now})))
|
||||
return new StaticFeatureSource(mapped, name);
|
||||
}
|
||||
|
||||
static fromDateless(featureSource: Store<{ feature: Feature }[]>, name = "StaticFeatureSourceFromDateless") {
|
||||
const now = new Date();
|
||||
return new StaticFeatureSource(featureSource.map(features => features.map(feature => ({
|
||||
feature: feature.feature,
|
||||
freshness: now
|
||||
}))), name);
|
||||
}
|
||||
}
|
||||
|
||||
export class TiledStaticFeatureSource extends StaticFeatureSource implements Tiled, FeatureSourceForLayer{
|
||||
|
||||
public readonly bbox: BBox = BBox.global;
|
||||
public readonly tileIndex: number;
|
||||
public readonly layer: FilteredLayer;
|
||||
|
||||
constructor(features: Store<{ feature: any, freshness: Date }[]>, layer: FilteredLayer ,tileIndex : number = 0) {
|
||||
super(features);
|
||||
this.tileIndex = tileIndex ;
|
||||
this.layer= layer;
|
||||
this.bbox = BBox.fromTileIndex(this.tileIndex)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import Loc from "../../../Models/Loc";
|
||||
import DynamicTileSource from "./DynamicTileSource";
|
||||
import {Utils} from "../../../Utils";
|
||||
import GeoJsonSource from "../Sources/GeoJsonSource";
|
||||
|
@ -14,7 +13,7 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
|
|||
constructor(layer: FilteredLayer,
|
||||
registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
|
||||
state: {
|
||||
locationControl: UIEventSource<Loc>
|
||||
locationControl?: UIEventSource<{zoom?: number}>
|
||||
currentBounds: UIEventSource<BBox>
|
||||
}) {
|
||||
const source = layer.layerDef.source
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import Loc from "../../../Models/Loc";
|
||||
import TileHierarchy from "./TileHierarchy";
|
||||
import {Tiles} from "../../../Models/TileRange";
|
||||
import {BBox} from "../../BBox";
|
||||
|
@ -19,30 +18,29 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
|
|||
constructTile: (zxy: [number, number, number]) => (FeatureSourceForLayer & Tiled),
|
||||
state: {
|
||||
currentBounds: UIEventSource<BBox>;
|
||||
locationControl: UIEventSource<Loc>
|
||||
locationControl?: UIEventSource<{zoom?: number}>
|
||||
}
|
||||
) {
|
||||
const self = this;
|
||||
|
||||
this.loadedTiles = new Map<number, FeatureSourceForLayer & Tiled>()
|
||||
const neededTiles = state.locationControl.map(
|
||||
location => {
|
||||
const neededTiles = state.currentBounds.map(
|
||||
bounds => {
|
||||
if (bounds === undefined) {
|
||||
// We'll retry later
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) {
|
||||
// No need to download! - the layer is disabled
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (location.zoom < layer.layerDef.minzoom) {
|
||||
if (state.locationControl?.data?.zoom !== undefined && state.locationControl.data.zoom < layer.layerDef.minzoom) {
|
||||
// No need to download! - the layer is disabled
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Yup, this is cheating to just get the bounds here
|
||||
const bounds = state.currentBounds.data
|
||||
if (bounds === undefined) {
|
||||
// We'll retry later
|
||||
return undefined
|
||||
}
|
||||
const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
|
||||
if (tileRange.total > 10000) {
|
||||
console.error("Got a really big tilerange, bounds and location might be out of sync")
|
||||
|
@ -55,7 +53,7 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
|
|||
}
|
||||
return needed
|
||||
}
|
||||
, [layer.isDisplayed, state.currentBounds]).stabilized(250);
|
||||
, [layer.isDisplayed, state.locationControl]).stabilized(250);
|
||||
|
||||
neededTiles.addCallbackAndRunD(neededIndexes => {
|
||||
console.log("Tiled geojson source ", layer.layerDef.id, " needs", neededIndexes)
|
||||
|
|
|
@ -2,15 +2,16 @@ import {Utils} from "../../../Utils";
|
|||
import * as OsmToGeoJson from "osmtogeojson";
|
||||
import StaticFeatureSource from "../Sources/StaticFeatureSource";
|
||||
import PerLayerFeatureSourceSplitter from "../PerLayerFeatureSourceSplitter";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import {Store, UIEventSource} from "../../UIEventSource";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
|
||||
import {Tiles} from "../../../Models/TileRange";
|
||||
import {BBox} from "../../BBox";
|
||||
import {OsmConnection} from "../../Osm/OsmConnection";
|
||||
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig";
|
||||
import {Or} from "../../Tags/Or";
|
||||
import {TagsFilter} from "../../Tags/TagsFilter";
|
||||
import {OsmObject} from "../../Osm/OsmObject";
|
||||
import {FeatureCollection} from "@turf/turf";
|
||||
|
||||
/**
|
||||
* If a tile is needed (requested via the UIEventSource in the constructor), will download the appropriate tile and pass it via 'handleTile'
|
||||
|
@ -20,73 +21,105 @@ export default class OsmFeatureSource {
|
|||
public readonly downloadedTiles = new Set<number>()
|
||||
public rawDataHandlers: ((osmJson: any, tileId: number) => void)[] = []
|
||||
private readonly _backend: string;
|
||||
private readonly filteredLayers: UIEventSource<FilteredLayer[]>;
|
||||
private readonly filteredLayers: Store<FilteredLayer[]>;
|
||||
private readonly handleTile: (fs: (FeatureSourceForLayer & Tiled)) => void;
|
||||
private isActive: UIEventSource<boolean>;
|
||||
private isActive: Store<boolean>;
|
||||
private options: {
|
||||
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
|
||||
isActive: UIEventSource<boolean>,
|
||||
neededTiles: UIEventSource<number[]>,
|
||||
state: {
|
||||
readonly osmConnection: OsmConnection;
|
||||
},
|
||||
isActive: Store<boolean>,
|
||||
neededTiles: Store<number[]>,
|
||||
markTileVisited?: (tileId: number) => void
|
||||
};
|
||||
private readonly allowedTags: TagsFilter;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param options: allowedFeatures is normally calculated from the layoutToUse
|
||||
*/
|
||||
constructor(options: {
|
||||
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
|
||||
isActive: UIEventSource<boolean>,
|
||||
neededTiles: UIEventSource<number[]>,
|
||||
isActive: Store<boolean>,
|
||||
neededTiles: Store<number[]>,
|
||||
state: {
|
||||
readonly filteredLayers: UIEventSource<FilteredLayer[]>;
|
||||
readonly osmConnection: OsmConnection;
|
||||
readonly layoutToUse: LayoutConfig
|
||||
readonly osmConnection: {
|
||||
Backend(): string
|
||||
};
|
||||
readonly layoutToUse?: LayoutConfig
|
||||
},
|
||||
readonly allowedFeatures?: TagsFilter,
|
||||
markTileVisited?: (tileId: number) => void
|
||||
}) {
|
||||
this.options = options;
|
||||
this._backend = options.state.osmConnection._oauth_config.url;
|
||||
this._backend = options.state.osmConnection.Backend();
|
||||
this.filteredLayers = options.state.filteredLayers.map(layers => layers.filter(layer => layer.layerDef.source.geojsonSource === undefined))
|
||||
this.handleTile = options.handleTile
|
||||
this.isActive = options.isActive
|
||||
const self = this
|
||||
options.neededTiles.addCallbackAndRunD(neededTiles => {
|
||||
if (options.isActive?.data === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
neededTiles = neededTiles.filter(tile => !self.downloadedTiles.has(tile))
|
||||
|
||||
if (neededTiles.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.isRunning.setData(true)
|
||||
try {
|
||||
|
||||
for (const neededTile of neededTiles) {
|
||||
self.downloadedTiles.add(neededTile)
|
||||
self.LoadTile(...Tiles.tile_from_index(neededTile)).then(_ => {
|
||||
console.debug("Tile ", Tiles.tile_from_index(neededTile).join("/"), "loaded from OSM")
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
self.isRunning.setData(false)
|
||||
}
|
||||
self.Update(neededTiles)
|
||||
})
|
||||
|
||||
|
||||
const neededLayers = options.state.layoutToUse.layers
|
||||
const neededLayers = (options.state.layoutToUse?.layers ?? [])
|
||||
.filter(layer => !layer.doNotDownload)
|
||||
.filter(layer => layer.source.geojsonSource === undefined || layer.source.isOsmCacheLayer)
|
||||
this.allowedTags = new Or(neededLayers.map(l => l.source.osmTags))
|
||||
this.allowedTags = options.allowedFeatures ?? new Or(neededLayers.map(l => l.source.osmTags))
|
||||
}
|
||||
|
||||
private async Update(neededTiles: number[]) {
|
||||
if (this.options.isActive?.data === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
neededTiles = neededTiles.filter(tile => !this.downloadedTiles.has(tile))
|
||||
|
||||
if (neededTiles.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning.setData(true)
|
||||
try {
|
||||
|
||||
for (const neededTile of neededTiles) {
|
||||
this.downloadedTiles.add(neededTile)
|
||||
await this.LoadTile(...Tiles.tile_from_index(neededTile))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.isRunning.setData(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The requested tile might only contain part of the relation.
|
||||
*
|
||||
* This method will download the full relation and return it as geojson if it was incomplete.
|
||||
* If the feature is already complete (or is not a relation), the feature will be returned
|
||||
*/
|
||||
private async patchIncompleteRelations(feature: {properties: {id: string}},
|
||||
originalJson: {elements: {type: "node" | "way" | "relation", id: number, } []}): Promise<any> {
|
||||
if(!feature.properties.id.startsWith("relation")){
|
||||
return feature
|
||||
}
|
||||
const relationSpec = originalJson.elements.find(f => "relation/"+f.id === feature.properties.id)
|
||||
const members : {type: string, ref: number}[] = relationSpec["members"]
|
||||
for (const member of members) {
|
||||
const isFound = originalJson.elements.some(f => f.id === member.ref && f.type === member.type)
|
||||
if (isFound) {
|
||||
continue
|
||||
}
|
||||
|
||||
// This member is missing. We redownload the entire relation instead
|
||||
console.debug("Fetching incomplete relation "+feature.properties.id)
|
||||
return (await OsmObject.DownloadObjectAsync(feature.properties.id)).asGeoJson()
|
||||
}
|
||||
return feature;
|
||||
}
|
||||
|
||||
private async LoadTile(z, x, y): Promise<void> {
|
||||
if (z > 20) {
|
||||
if (z >= 22) {
|
||||
throw "This is an absurd high zoom level"
|
||||
}
|
||||
|
||||
|
@ -96,22 +129,29 @@ export default class OsmFeatureSource {
|
|||
|
||||
const bbox = BBox.fromTile(z, x, y)
|
||||
const url = `${this._backend}/api/0.6/map?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
|
||||
try {
|
||||
|
||||
let error = undefined;
|
||||
try {
|
||||
const osmJson = await Utils.downloadJson(url)
|
||||
try {
|
||||
console.debug("Got tile", z, x, y, "from the osm api")
|
||||
|
||||
console.log("Got tile", z, x, y, "from the osm api")
|
||||
this.rawDataHandlers.forEach(handler => handler(osmJson, Tiles.tile_index(z, x, y)))
|
||||
const geojson = OsmToGeoJson.default(osmJson,
|
||||
const geojson = <FeatureCollection<any , {id: string}>> OsmToGeoJson.default(osmJson,
|
||||
// @ts-ignore
|
||||
{
|
||||
flatProperties: true
|
||||
});
|
||||
|
||||
|
||||
// The geojson contains _all_ features at the given location
|
||||
// We only keep what is needed
|
||||
|
||||
geojson.features = geojson.features.filter(feature => this.allowedTags.matchesProperties(feature.properties))
|
||||
|
||||
for (let i = 0; i < geojson.features.length; i++) {
|
||||
geojson.features[i] = await this.patchIncompleteRelations(geojson.features[i], osmJson)
|
||||
}
|
||||
geojson.features.forEach(f => {
|
||||
f.properties["_backend"] = this._backend
|
||||
})
|
||||
|
@ -119,7 +159,7 @@ export default class OsmFeatureSource {
|
|||
const index = Tiles.tile_index(z, x, y);
|
||||
new PerLayerFeatureSourceSplitter(this.filteredLayers,
|
||||
this.handleTile,
|
||||
new StaticFeatureSource(geojson.features, false),
|
||||
StaticFeatureSource.fromGeojson(geojson.features),
|
||||
{
|
||||
tileIndex: index
|
||||
}
|
||||
|
@ -127,9 +167,11 @@ export default class OsmFeatureSource {
|
|||
if (this.options.markTileVisited) {
|
||||
this.options.markTileVisited(index)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Weird error: ", e)
|
||||
}catch(e){
|
||||
console.error("PANIC: got the tile from the OSM-api, but something crashed handling this tile")
|
||||
error = e;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Could not download tile", z, x, y, "due to", e, "; retrying with smaller bounds")
|
||||
if (e === "rate limited") {
|
||||
|
@ -139,9 +181,11 @@ export default class OsmFeatureSource {
|
|||
await this.LoadTile(z + 1, 1 + x * 2, y * 2)
|
||||
await this.LoadTile(z + 1, x * 2, 1 + y * 2)
|
||||
await this.LoadTile(z + 1, 1 + x * 2, 1 + y * 2)
|
||||
return;
|
||||
}
|
||||
|
||||
if(error !== undefined){
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ export default interface TileHierarchy<T extends FeatureSource & Tiled> {
|
|||
export class TileHierarchyTools {
|
||||
|
||||
public static getTiles<T extends FeatureSource & Tiled>(hierarchy: TileHierarchy<T>, bbox: BBox): T[] {
|
||||
const result = []
|
||||
const result: T[] = []
|
||||
hierarchy.loadedTiles.forEach((tile) => {
|
||||
if (tile.bbox.overlapsWith(bbox)) {
|
||||
result.push(tile)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
|
||||
import {UIEventSource} from "../../UIEventSource";
|
||||
import {Store, UIEventSource} from "../../UIEventSource";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import TileHierarchy from "./TileHierarchy";
|
||||
import {Tiles} from "../../../Models/TileRange";
|
||||
|
@ -24,7 +24,7 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
|
|||
public readonly maxFeatureCount: number;
|
||||
public readonly name;
|
||||
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>
|
||||
public readonly containedIds: UIEventSource<Set<string>>
|
||||
public readonly containedIds: Store<Set<string>>
|
||||
|
||||
public readonly bbox: BBox;
|
||||
public readonly tileIndex: number;
|
||||
|
|
|
@ -3,6 +3,7 @@ import {BBox} from "./BBox";
|
|||
import togpx from "togpx"
|
||||
import Constants from "../Models/Constants";
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
|
||||
import {AllGeoJSON, booleanWithin, Coord, Feature, Geometry, MultiPolygon, Polygon, Properties} from "@turf/turf";
|
||||
|
||||
export class GeoOperations {
|
||||
|
||||
|
@ -24,7 +25,11 @@ export class GeoOperations {
|
|||
return newFeature;
|
||||
}
|
||||
|
||||
static centerpointCoordinates(feature: any): [number, number] {
|
||||
/**
|
||||
* Returns [lon,lat] coordinates
|
||||
* @param feature
|
||||
*/
|
||||
static centerpointCoordinates(feature: AllGeoJSON): [number, number] {
|
||||
return <[number, number]>turf.center(feature).geometry.coordinates;
|
||||
}
|
||||
|
||||
|
@ -50,6 +55,19 @@ export class GeoOperations {
|
|||
*
|
||||
* If 'feature' is a point, it will return every feature the point is embedded in. Overlap will be undefined
|
||||
*
|
||||
* const polygon = {"type": "Feature","properties": {},"geometry": {"type": "Polygon","coordinates": [[[1.8017578124999998,50.401515322782366],[-3.1640625,46.255846818480315],[5.185546875,44.74673324024678],[1.8017578124999998,50.401515322782366]]]}};
|
||||
* const point = {"type": "Feature", "properties": {}, "geometry": { "type": "Point", "coordinates": [2.274169921875, 46.76244305208004]}};
|
||||
* const overlap = GeoOperations.calculateOverlap(point, [polygon]);
|
||||
* overlap.length // => 1
|
||||
* overlap[0].feat == polygon // => true
|
||||
* const line = {"type": "Feature","properties": {},"geometry": {"type": "LineString","coordinates": [[3.779296875,48.777912755501845],[1.23046875,47.60616304386874]]}};
|
||||
* const lineOverlap = GeoOperations.calculateOverlap(line, [polygon]);
|
||||
* lineOverlap.length // => 1
|
||||
* lineOverlap[0].overlap // => 156745.3293320278
|
||||
* lineOverlap[0].feat == polygon // => true
|
||||
* const line0 = {"type": "Feature","properties": {},"geometry": {"type": "LineString","coordinates": [[0.0439453125,47.31648293428332],[0.6591796875,46.77749276376827]]}};
|
||||
* const overlap0 = GeoOperations.calculateOverlap(line0, [polygon]);
|
||||
* overlap.length // => 1
|
||||
*/
|
||||
static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any, overlap: number }[] {
|
||||
|
||||
|
@ -124,7 +142,10 @@ export class GeoOperations {
|
|||
return result;
|
||||
}
|
||||
|
||||
public static pointInPolygonCoordinates(x: number, y: number, coordinates: [number, number][][]) {
|
||||
/**
|
||||
* Helper function which does the heavy lifting for 'inside'
|
||||
*/
|
||||
private static pointInPolygonCoordinates(x: number, y: number, coordinates: [number, number][][]) {
|
||||
const inside = GeoOperations.pointWithinRing(x, y, /*This is the outer ring of the polygon */coordinates[0])
|
||||
if (!inside) {
|
||||
return false;
|
||||
|
@ -138,6 +159,28 @@ export class GeoOperations {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect wether or not the given point is located in the feature
|
||||
*
|
||||
* // Should work with a normal polygon
|
||||
* const polygon = {"type": "Feature","properties": {},"geometry": {"type": "Polygon","coordinates": [[[1.8017578124999998,50.401515322782366],[-3.1640625,46.255846818480315],[5.185546875,44.74673324024678],[1.8017578124999998,50.401515322782366]]]}};
|
||||
* GeoOperations.inside([3.779296875, 48.777912755501845], polygon) // => false
|
||||
* GeoOperations.inside([1.23046875, 47.60616304386874], polygon) // => true
|
||||
*
|
||||
* // should work with a multipolygon and detect holes
|
||||
* const multiPolygon = {"type": "Feature", "properties": {},
|
||||
* "geometry": {
|
||||
* "type": "MultiPolygon",
|
||||
* "coordinates": [[
|
||||
* [[1.8017578124999998,50.401515322782366],[-3.1640625,46.255846818480315],[5.185546875,44.74673324024678],[1.8017578124999998,50.401515322782366]],
|
||||
* [[1.0107421875,48.821332549646634],[1.329345703125,48.25394114463431],[1.988525390625,48.71271258145237],[0.999755859375,48.86471476180277],[1.0107421875,48.821332549646634]]
|
||||
* ]]
|
||||
* }
|
||||
* };
|
||||
* GeoOperations.inside([2.515869140625, 47.37603463349758], multiPolygon) // => true
|
||||
* GeoOperations.inside([1.42822265625, 48.61838518688487], multiPolygon) // => false
|
||||
* GeoOperations.inside([4.02099609375, 47.81315451752768], multiPolygon) // => false
|
||||
*/
|
||||
public static inside(pointCoordinate, feature): boolean {
|
||||
// ray-casting algorithm based on
|
||||
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
|
||||
|
@ -514,7 +557,10 @@ export class GeoOperations {
|
|||
/**
|
||||
* Removes points that do not contribute to the geometry from linestrings and the outer ring of polygons.
|
||||
* Returs a new copy of the feature
|
||||
* @param feature
|
||||
*
|
||||
* const feature = {"geometry": {"type": "Polygon","coordinates": [[[4.477944199999975,51.02783550000022],[4.477987899999996,51.027818800000034],[4.478004500000021,51.02783399999988],[4.478025499999962,51.02782489999994],[4.478079099999993,51.027873899999896],[4.47801040000006,51.027903799999955],[4.477964799999972,51.02785709999982],[4.477964699999964,51.02785690000006],[4.477944199999975,51.02783550000022]]]}}
|
||||
* const copy = GeoOperations.removeOvernoding(feature)
|
||||
* expect(copy.geometry.coordinates[0]).deep.equal([[4.477944199999975,51.02783550000022],[4.477987899999996,51.027818800000034],[4.478004500000021,51.02783399999988],[4.478025499999962,51.02782489999994],[4.478079099999993,51.027873899999896],[4.47801040000006,51.027903799999955],[4.477944199999975,51.02783550000022]])
|
||||
*/
|
||||
static removeOvernoding(feature: any) {
|
||||
if (feature.geometry.type !== "LineString" && feature.geometry.type !== "Polygon") {
|
||||
|
@ -687,7 +733,45 @@ export class GeoOperations {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes two points and finds the geographic bearing between them, i.e. the angle measured in degrees from the north line (0 degrees)
|
||||
*/
|
||||
public static bearing(a: Coord, b: Coord): number {
|
||||
return turf.bearing(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'true' if one feature contains the other feature
|
||||
*
|
||||
* const pond: Feature<Polygon, any> = {
|
||||
* "type": "Feature",
|
||||
* "properties": {"natural":"water","water":"pond"},
|
||||
* "geometry": {
|
||||
* "type": "Polygon",
|
||||
* "coordinates": [[
|
||||
* [4.362924098968506,50.8435422298544 ],
|
||||
* [4.363272786140442,50.8435219059949 ],
|
||||
* [4.363213777542114,50.8437420806679 ],
|
||||
* [4.362924098968506,50.8435422298544 ]
|
||||
* ]]}}
|
||||
* const park: Feature<Polygon, any> = {
|
||||
* "type": "Feature",
|
||||
* "properties": {"leisure":"park"},
|
||||
* "geometry": {
|
||||
* "type": "Polygon",
|
||||
* "coordinates": [[
|
||||
* [ 4.36073541641235,50.84323737103244 ],
|
||||
* [ 4.36469435691833, 50.8423905305197 ],
|
||||
* [ 4.36659336090087, 50.8458997374786 ],
|
||||
* [ 4.36254858970642, 50.8468007074916 ],
|
||||
* [ 4.36073541641235, 50.8432373710324 ]
|
||||
* ]]}}
|
||||
* GeoOperations.completelyWithin(pond, park) // => true
|
||||
* GeoOperations.completelyWithin(park, pond) // => false
|
||||
*/
|
||||
static completelyWithin(feature: Feature<Geometry, any>, possiblyEncloingFeature: Feature<Polygon | MultiPolygon, any>) : boolean {
|
||||
return booleanWithin(feature, possiblyEncloingFeature);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import {Mapillary} from "./Mapillary";
|
|||
import {WikimediaImageProvider} from "./WikimediaImageProvider";
|
||||
import {Imgur} from "./Imgur";
|
||||
import GenericImageProvider from "./GenericImageProvider";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import ImageProvider, {ProvidedImage} from "./ImageProvider";
|
||||
import {WikidataImageProvider} from "./WikidataImageProvider";
|
||||
|
||||
|
@ -19,15 +19,25 @@ export default class AllImageProviders {
|
|||
new GenericImageProvider(
|
||||
[].concat(...Imgur.defaultValuePrefix, ...WikimediaImageProvider.commonsPrefixes, ...Mapillary.valuePrefixes)
|
||||
)
|
||||
|
||||
]
|
||||
|
||||
private static providersByName= {
|
||||
"imgur": Imgur.singleton,
|
||||
"mapillary": Mapillary.singleton,
|
||||
"wikidata": WikidataImageProvider.singleton,
|
||||
"wikimedia": WikimediaImageProvider.singleton
|
||||
}
|
||||
|
||||
public static byName(name: string){
|
||||
return AllImageProviders.providersByName[name.toLowerCase()]
|
||||
}
|
||||
|
||||
public static defaultKeys = [].concat(AllImageProviders.ImageAttributionSource.map(provider => provider.defaultKeyPrefixes))
|
||||
|
||||
|
||||
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<string, UIEventSource<ProvidedImage[]>>()
|
||||
|
||||
public static LoadImagesFor(tags: UIEventSource<any>, tagKey?: string[]): UIEventSource<ProvidedImage[]> {
|
||||
public static LoadImagesFor(tags: Store<any>, tagKey?: string[]): Store<ProvidedImage[]> {
|
||||
if (tags.data.id === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ export default class GenericImageProvider extends ImageProvider {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
protected DownloadAttribution(url: string) {
|
||||
public DownloadAttribution(url: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import BaseUIElement from "../../UI/BaseUIElement";
|
||||
import {LicenseInfo} from "./LicenseInfo";
|
||||
import {Utils} from "../../Utils";
|
||||
|
@ -12,25 +12,13 @@ export interface ProvidedImage {
|
|||
export default abstract class ImageProvider {
|
||||
|
||||
public abstract readonly defaultKeyPrefixes: string[]
|
||||
|
||||
private _cache = new Map<string, UIEventSource<LicenseInfo>>()
|
||||
|
||||
GetAttributionFor(url: string): UIEventSource<LicenseInfo> {
|
||||
const cached = this._cache.get(url);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const src = UIEventSource.FromPromise(this.DownloadAttribution(url))
|
||||
this._cache.set(url, src)
|
||||
return src;
|
||||
}
|
||||
|
||||
|
||||
public abstract SourceIcon(backlinkSource?: string): BaseUIElement;
|
||||
|
||||
/**
|
||||
* Given a properies object, maps it onto _all_ the available pictures for this imageProvider
|
||||
*/
|
||||
public GetRelevantUrls(allTags: UIEventSource<any>, options?: {
|
||||
public GetRelevantUrls(allTags: Store<any>, options?: {
|
||||
prefixes?: string[]
|
||||
}): UIEventSource<ProvidedImage[]> {
|
||||
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
|
||||
|
@ -75,6 +63,6 @@ export default abstract class ImageProvider {
|
|||
|
||||
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>;
|
||||
|
||||
protected abstract DownloadAttribution(url: string): Promise<LicenseInfo>;
|
||||
public abstract DownloadAttribution(url: string): Promise<LicenseInfo>;
|
||||
|
||||
}
|
|
@ -99,18 +99,30 @@ export class Imgur extends ImageProvider {
|
|||
return []
|
||||
}
|
||||
|
||||
protected DownloadAttribution: (url: string) => Promise<LicenseInfo> = async (url: string) => {
|
||||
/**
|
||||
* Download the attribution from attribution
|
||||
*
|
||||
* const data = {"data":{"id":"I9t6B7B","title":"Station Knokke","description":"author:Pieter Vander Vennet\r\nlicense:CC-BY 4.0\r\nosmid:node\/9812712386","datetime":1655052078,"type":"image\/jpeg","animated":false,"width":2400,"height":1795,"size":910872,"views":2,"bandwidth":1821744,"vote":null,"favorite":false,"nsfw":false,"section":null,"account_url":null,"account_id":null,"is_ad":false,"in_most_viral":false,"has_sound":false,"tags":[],"ad_type":0,"ad_url":"","edited":"0","in_gallery":false,"link":"https:\/\/i.imgur.com\/I9t6B7B.jpg","ad_config":{"safeFlags":["not_in_gallery","share"],"highRiskFlags":[],"unsafeFlags":["sixth_mod_unsafe"],"wallUnsafeFlags":[],"showsAds":false,"showAdLevel":1}},"success":true,"status":200}
|
||||
* Utils.injectJsonDownloadForTests("https://api.imgur.com/3/image/E0RuAK3", data)
|
||||
* const licenseInfo = await Imgur.singleton.DownloadAttribution("https://i.imgur.com/E0RuAK3.jpg")
|
||||
* const expected = new LicenseInfo()
|
||||
* expected.licenseShortName = "CC-BY 4.0"
|
||||
* expected.artist = "Pieter Vander Vennet"
|
||||
* licenseInfo // => expected
|
||||
*/
|
||||
public async DownloadAttribution (url: string) : Promise<LicenseInfo> {
|
||||
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
|
||||
|
||||
const apiUrl = 'https://api.imgur.com/3/image/' + hash;
|
||||
const response = await Utils.downloadJson(apiUrl, {Authorization: 'Client-ID ' + Constants.ImgurApiKey})
|
||||
const response = await Utils.downloadJsonCached(apiUrl, 365*24*60*60,
|
||||
{Authorization: 'Client-ID ' + Constants.ImgurApiKey})
|
||||
|
||||
const descr: string = response.data.description ?? "";
|
||||
const data: any = {};
|
||||
for (const tag of descr.split("\n")) {
|
||||
const kv = tag.split(":");
|
||||
const k = kv[0];
|
||||
data[k] = kv[1]?.replace("\r", "");
|
||||
data[k] = kv[1]?.replace(/\r/g, "");
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export class LicenseInfo {
|
||||
title: string = ""
|
||||
artist: string = "";
|
||||
license: string = "";
|
||||
license: string = undefined;
|
||||
licenseShortName: string = "";
|
||||
usageTerms: string = "";
|
||||
attributionRequired: boolean = false;
|
||||
|
|
|
@ -12,11 +12,48 @@ export class Mapillary extends ImageProvider {
|
|||
public static readonly valuePrefixes = [Mapillary.valuePrefix, "http://mapillary.com", "https://mapillary.com", "http://www.mapillary.com", "https://www.mapillary.com"]
|
||||
defaultKeyPrefixes = ["mapillary", "image"]
|
||||
|
||||
/**
|
||||
* Indicates that this is the same URL
|
||||
* Ignores 'stp' parameter
|
||||
*
|
||||
* const a = "https://scontent-bru2-1.xx.fbcdn.net/m1/v/t6/An8xm5SGLt20ETziNqzhhBd8b8S5GHLiIu8N6BbyqHFohFAQoaJJPG8i5yQiSwjYmEqXSfVeoCmpiyBJICEkQK98JOB21kkJoBS8VdhYa-Ty93lBnznQyesJBtKcb32foGut2Hgt10hEMWJbE3dDgA?stp=s1024x768&ccb=10-5&oh=00_AT-ZGTXHzihoaQYBILmEiAEKR64z_IWiTlcAYq_D7Ka0-Q&oe=6278C456&_nc_sid=122ab1"
|
||||
* const b = "https://scontent-bru2-1.xx.fbcdn.net/m1/v/t6/An8xm5SGLt20ETziNqzhhBd8b8S5GHLiIu8N6BbyqHFohFAQoaJJPG8i5yQiSwjYmEqXSfVeoCmpiyBJICEkQK98JOB21kkJoBS8VdhYa-Ty93lBnznQyesJBtKcb32foGut2Hgt10hEMWJbE3dDgA?stp=s256x192&ccb=10-5&oh=00_AT9BZ1Rpc9zbY_uNu92A_4gj1joiy1b6VtgtLIu_7wh9Bg&oe=6278C456&_nc_sid=122ab1"
|
||||
* Mapillary.sameUrl(a, b) => true
|
||||
*/
|
||||
static sameUrl(a: string, b: string): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const aUrl = new URL(a)
|
||||
const bUrl = new URL(b)
|
||||
if (aUrl.host !== bUrl.host || aUrl.pathname !== bUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
let allSame = true;
|
||||
aUrl.searchParams.forEach((value, key) => {
|
||||
if (key === "stp") {
|
||||
// This is the key indicating the image size on mapillary; we ignore it
|
||||
return
|
||||
}
|
||||
if (value !== bUrl.searchParams.get(key)) {
|
||||
allSame = false
|
||||
return
|
||||
}
|
||||
})
|
||||
return allSame;
|
||||
|
||||
} catch (e) {
|
||||
console.debug("Could not compare ", a, "and", b, "due to", e)
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct key for API v4.0
|
||||
*/
|
||||
private static ExtractKeyFromURL(value: string): number {
|
||||
|
||||
let key: string;
|
||||
|
||||
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
|
||||
|
@ -24,6 +61,8 @@ export class Mapillary extends ImageProvider {
|
|||
key = newApiFormat[1]
|
||||
} else if (value.startsWith(Mapillary.valuePrefix)) {
|
||||
key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
|
||||
} else if (value.match("[0-9]*")) {
|
||||
key = value;
|
||||
}
|
||||
|
||||
const keyAsNumber = Number(key)
|
||||
|
@ -42,7 +81,7 @@ export class Mapillary extends ImageProvider {
|
|||
return [this.PrepareUrlAsync(key, value)]
|
||||
}
|
||||
|
||||
protected async DownloadAttribution(url: string): Promise<LicenseInfo> {
|
||||
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
|
||||
const license = new LicenseInfo()
|
||||
license.artist = "Contributor name unavailable";
|
||||
license.license = "CC BY-SA 4.0";
|
||||
|
@ -58,7 +97,7 @@ export class Mapillary extends ImageProvider {
|
|||
}
|
||||
|
||||
const metadataUrl = 'https://graph.mapillary.com/' + mapillaryId + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
|
||||
const response = await Utils.downloadJson(metadataUrl)
|
||||
const response = await Utils.downloadJsonCached(metadataUrl,60*60)
|
||||
const url = <string>response["thumb_1024_url"];
|
||||
return {
|
||||
url: url,
|
||||
|
|
|
@ -46,7 +46,7 @@ export class WikidataImageProvider extends ImageProvider {
|
|||
return allImages
|
||||
}
|
||||
|
||||
protected DownloadAttribution(url: string): Promise<any> {
|
||||
public DownloadAttribution(url: string): Promise<any> {
|
||||
throw new Error("Method not implemented; shouldn't be needed!");
|
||||
}
|
||||
|
||||
|
|
|
@ -112,7 +112,7 @@ export class WikimediaImageProvider extends ImageProvider {
|
|||
return [Promise.resolve(this.UrlForImage("File:" + value))]
|
||||
}
|
||||
|
||||
protected async DownloadAttribution(filename: string): Promise<LicenseInfo> {
|
||||
public async DownloadAttribution(filename: string): Promise<LicenseInfo> {
|
||||
filename = WikimediaImageProvider.ExtractFileName(filename)
|
||||
|
||||
if (filename === "") {
|
||||
|
@ -123,7 +123,7 @@ export class WikimediaImageProvider extends ImageProvider {
|
|||
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
|
||||
"titles=" + filename +
|
||||
"&format=json&origin=*";
|
||||
const data = await Utils.downloadJson(url)
|
||||
const data = await Utils.downloadJsonCached(url,365*24*60*60)
|
||||
const licenseInfo = new LicenseInfo();
|
||||
const pageInfo = data.query.pages[-1]
|
||||
if (pageInfo === undefined) {
|
||||
|
|
39
Logic/Maproulette.ts
Normal file
39
Logic/Maproulette.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import Constants from "../Models/Constants";
|
||||
|
||||
export default class Maproulette {
|
||||
/**
|
||||
* The API endpoint to use
|
||||
*/
|
||||
endpoint: string;
|
||||
|
||||
/**
|
||||
* The API key to use for all requests
|
||||
*/
|
||||
private apiKey: string;
|
||||
|
||||
/**
|
||||
* Creates a new Maproulette instance
|
||||
* @param endpoint The API endpoint to use
|
||||
*/
|
||||
constructor(endpoint: string = "https://maproulette.org/api/v2") {
|
||||
this.endpoint = endpoint;
|
||||
this.apiKey = Constants.MaprouletteApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a task
|
||||
* @param taskId The task to close
|
||||
*/
|
||||
async closeTask(taskId: number): Promise<void> {
|
||||
const response = await fetch(`${this.endpoint}/task/${taskId}/1`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"apiKey": this.apiKey,
|
||||
},
|
||||
});
|
||||
if (response.status !== 304) {
|
||||
console.log(`Failed to close task: ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -35,6 +35,7 @@ export default class MetaTagging {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log("Recalculating metatags...")
|
||||
const metatagsToApply: SimpleMetaTagger[] = []
|
||||
for (const metatag of SimpleMetaTaggers.metatags) {
|
||||
if (metatag.includesDates) {
|
||||
|
@ -155,7 +156,6 @@ export default class MetaTagging {
|
|||
|
||||
// Lazy function
|
||||
const f = (feature: any) => {
|
||||
const oldValue = feature.properties[key]
|
||||
delete feature.properties[key]
|
||||
Object.defineProperty(feature.properties, key, {
|
||||
configurable: true,
|
||||
|
|
|
@ -71,6 +71,110 @@ export interface ChangeDescription {
|
|||
|
||||
export class ChangeDescriptionTools {
|
||||
|
||||
/**
|
||||
* Rewrites all the ids in a changeDescription
|
||||
*
|
||||
* // should rewrite the id of the changed object
|
||||
* const change = <ChangeDescription> {
|
||||
* id: -1234,
|
||||
* type: "node",
|
||||
* meta:{
|
||||
* theme:"test",
|
||||
* changeType: "answer"
|
||||
* },
|
||||
* tags:[
|
||||
* {
|
||||
* k: "key",
|
||||
* v: "value"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* const mapping = new Map<string, string>([["node/-1234", "node/42"]])
|
||||
* const rewritten = ChangeDescriptionTools.rewriteIds(change, mapping)
|
||||
* rewritten.id // => 42
|
||||
*
|
||||
* // should rewrite ids in nodes of a way
|
||||
* const change = <ChangeDescription> {
|
||||
* type: "way",
|
||||
* id: 789,
|
||||
* changes: {
|
||||
* nodes: [-1, -2, -3, 68453],
|
||||
* coordinates: []
|
||||
* },
|
||||
* meta:{
|
||||
* theme:"test",
|
||||
* changeType: "create"
|
||||
* }
|
||||
* }
|
||||
* const mapping = new Map<string, string>([["node/-1", "node/42"],["node/-2", "node/43"],["node/-3", "node/44"]])
|
||||
* const rewritten = ChangeDescriptionTools.rewriteIds(change, mapping)
|
||||
* rewritten.id // => 789
|
||||
* rewritten.changes["nodes"] // => [42,43,44, 68453]
|
||||
*
|
||||
* // should rewrite ids in relationship members
|
||||
* const change = <ChangeDescription> {
|
||||
* type: "way",
|
||||
* id: 789,
|
||||
* changes: {
|
||||
* members: [{type: "way", ref: -1, role: "outer"},{type: "way", ref: 48, role: "outer"}],
|
||||
* },
|
||||
* meta:{
|
||||
* theme:"test",
|
||||
* changeType: "create"
|
||||
* }
|
||||
* }
|
||||
* const mapping = new Map<string, string>([["way/-1", "way/42"],["node/-2", "node/43"],["node/-3", "node/44"]])
|
||||
* const rewritten = ChangeDescriptionTools.rewriteIds(change, mapping)
|
||||
* rewritten.id // => 789
|
||||
* rewritten.changes["members"] // => [{type: "way", ref: 42, role: "outer"},{type: "way", ref: 48, role: "outer"}]
|
||||
*
|
||||
*/
|
||||
public static rewriteIds(change: ChangeDescription, mappings: Map<string, string>): ChangeDescription {
|
||||
const key = change.type + "/" + change.id
|
||||
|
||||
const wayHasChangedNode = ((change.changes ?? {})["nodes"] ?? []).some(id => mappings.has("node/" + id));
|
||||
const relationHasChangedMembers = ((change.changes ?? {})["members"] ?? [])
|
||||
.some((obj:{type: string, ref: number}) => mappings.has(obj.type+"/" + obj.ref));
|
||||
|
||||
const hasSomeChange = mappings.has(key)
|
||||
|| wayHasChangedNode || relationHasChangedMembers
|
||||
if(hasSomeChange){
|
||||
change = {...change}
|
||||
}
|
||||
|
||||
if (mappings.has(key)) {
|
||||
const [_, newId] = mappings.get(key).split("/")
|
||||
change.id = Number.parseInt(newId)
|
||||
}
|
||||
if(wayHasChangedNode){
|
||||
change.changes = {...change.changes}
|
||||
change.changes["nodes"] = change.changes["nodes"].map(id => {
|
||||
const key = "node/"+id
|
||||
if(!mappings.has(key)){
|
||||
return id
|
||||
}
|
||||
const [_, newId] = mappings.get(key).split("/")
|
||||
return Number.parseInt(newId)
|
||||
})
|
||||
}
|
||||
if(relationHasChangedMembers){
|
||||
change.changes = {...change.changes}
|
||||
change.changes["members"] = change.changes["members"].map(
|
||||
(obj:{type: string, ref: number}) => {
|
||||
const key = obj.type+"/"+obj.ref;
|
||||
if(!mappings.has(key)){
|
||||
return obj
|
||||
}
|
||||
const [_, newId] = mappings.get(key).split("/")
|
||||
return {...obj, ref: Number.parseInt(newId)}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return change
|
||||
}
|
||||
|
||||
public static getGeojsonGeometry(change: ChangeDescription): any {
|
||||
switch (change.type) {
|
||||
case "node":
|
||||
|
@ -81,7 +185,7 @@ export class ChangeDescriptionTools {
|
|||
case "way":
|
||||
const w = new OsmWay(change.id)
|
||||
w.nodes = change.changes["nodes"]
|
||||
w.coordinates = change.changes["coordinates"].map(coor => coor.reverse())
|
||||
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [lat, lon])
|
||||
return w.asGeoJson().geometry
|
||||
case "relation":
|
||||
const r = new OsmRelation(change.id)
|
||||
|
|
|
@ -9,7 +9,8 @@ export default class ChangeTagAction extends OsmChangeAction {
|
|||
private readonly _currentTags: any;
|
||||
private readonly _meta: { theme: string, changeType: string };
|
||||
|
||||
constructor(elementId: string, tagsFilter: TagsFilter, currentTags: any, meta: {
|
||||
constructor(elementId: string,
|
||||
tagsFilter: TagsFilter, currentTags: any, meta: {
|
||||
theme: string,
|
||||
changeType: "answer" | "soft-delete" | "add-image" | string
|
||||
}) {
|
||||
|
|
|
@ -33,12 +33,12 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct
|
|||
super(null, true);
|
||||
this._tags = [...tags, new Tag("type", "multipolygon")];
|
||||
this.changeType = changeType;
|
||||
this.theme = state.layoutToUse.id
|
||||
this.theme = state?.layoutToUse?.id ?? ""
|
||||
this.createOuterWay = new CreateWayWithPointReuseAction([], outerRingCoordinates, state, config)
|
||||
this.createInnerWays = innerRingsCoordinates.map(ringCoordinates =>
|
||||
new CreateNewWayAction([],
|
||||
ringCoordinates.map(([lon, lat]) => ({lat, lon})),
|
||||
{theme: state.layoutToUse.id}))
|
||||
{theme: state?.layoutToUse?.id}))
|
||||
|
||||
this.geojsonPreview = {
|
||||
type: "Feature",
|
||||
|
|
|
@ -51,22 +51,6 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
}
|
||||
}
|
||||
|
||||
public static registerIdRewrites(mappings: Map<string, string>) {
|
||||
const toAdd: [string, number][] = []
|
||||
|
||||
this.previouslyCreatedPoints.forEach((oldId, key) => {
|
||||
if (!mappings.has("node/" + oldId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = Number(mappings.get("node/" + oldId).substr("node/".length))
|
||||
toAdd.push([key, newId])
|
||||
})
|
||||
for (const [key, newId] of toAdd) {
|
||||
CreateNewNodeAction.previouslyCreatedPoints.set(key, newId)
|
||||
}
|
||||
}
|
||||
|
||||
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
|
||||
|
||||
if (this._reusePreviouslyCreatedPoint) {
|
||||
|
@ -88,7 +72,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
this.setElementId(id)
|
||||
for (const kv of this._basicTags) {
|
||||
if (typeof kv.value !== "string") {
|
||||
throw "Invalid value: don't use a regex in a preset"
|
||||
throw "Invalid value: don't use non-string value in a preset. The tag "+kv.key+"="+kv.value+" is not a string, the value is a "+typeof kv.value
|
||||
}
|
||||
properties[kv.key] = kv.value;
|
||||
}
|
||||
|
@ -109,19 +93,28 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
|
||||
|
||||
// Project the point onto the way
|
||||
|
||||
console.log("Snapping a node onto an existing way...")
|
||||
const geojson = this._snapOnto.asGeoJson()
|
||||
const projected = GeoOperations.nearestPoint(geojson, [this._lon, this._lat])
|
||||
const projectedCoor= <[number, number]>projected.geometry.coordinates
|
||||
const index = projected.properties.index
|
||||
// We check that it isn't close to an already existing point
|
||||
let reusedPointId = undefined;
|
||||
const prev = <[number, number]>geojson.geometry.coordinates[index]
|
||||
if (GeoOperations.distanceBetween(prev, <[number, number]>projected.geometry.coordinates) < this._reusePointDistance) {
|
||||
let outerring : [number,number][];
|
||||
|
||||
if(geojson.geometry.type === "LineString"){
|
||||
outerring = <[number, number][]> geojson.geometry.coordinates
|
||||
}else if(geojson.geometry.type === "Polygon"){
|
||||
outerring =<[number, number][]> geojson.geometry.coordinates[0]
|
||||
}
|
||||
|
||||
const prev= outerring[index]
|
||||
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._reusePointDistance) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index]
|
||||
}
|
||||
const next = <[number, number]>geojson.geometry.coordinates[index + 1]
|
||||
if (GeoOperations.distanceBetween(next, <[number, number]>projected.geometry.coordinates) < this._reusePointDistance) {
|
||||
const next = outerring[index + 1]
|
||||
if (GeoOperations.distanceBetween(next, projectedCoor) < this._reusePointDistance) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index + 1]
|
||||
}
|
||||
|
@ -135,8 +128,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
}]
|
||||
}
|
||||
|
||||
const locations = [...this._snapOnto.coordinates]
|
||||
locations.forEach(coor => coor.reverse())
|
||||
const locations = [...this._snapOnto.coordinates.map(([lat, lon]) =><[number,number]> [lon, lat])]
|
||||
const ids = [...this._snapOnto.nodes]
|
||||
|
||||
locations.splice(index + 1, 0, [this._lon, this._lat])
|
||||
|
|
|
@ -33,7 +33,7 @@ export default class CreateNewWayAction extends OsmCreateAction {
|
|||
We filter those here, as the CreateWayWithPointReuseAction delegates the actual creation to here.
|
||||
Filtering here also prevents similar bugs in other actions
|
||||
*/
|
||||
if(this.coordinates.length > 0 && this.coordinates[this.coordinates.length - 1].nodeId === coordinate.nodeId){
|
||||
if(this.coordinates.length > 0 && coordinate.nodeId !== undefined && this.coordinates[this.coordinates.length - 1].nodeId === coordinate.nodeId){
|
||||
// This is a duplicate id
|
||||
console.warn("Skipping a node in createWay to avoid a duplicate node:", coordinate,"\nThe previous coordinates are: ", this.coordinates)
|
||||
continue
|
||||
|
|
|
@ -182,11 +182,11 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
|
|||
features.push(newGeometry)
|
||||
|
||||
}
|
||||
return new StaticFeatureSource(features, false)
|
||||
return StaticFeatureSource.fromGeojson(features)
|
||||
}
|
||||
|
||||
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
|
||||
const theme = this._state.layoutToUse.id
|
||||
const theme = this._state?.layoutToUse?.id
|
||||
const allChanges: ChangeDescription[] = []
|
||||
const nodeIdsToUse: { lat: number, lon: number, nodeId?: number }[] = []
|
||||
for (let i = 0; i < this._coordinateInfo.length; i++) {
|
||||
|
@ -251,7 +251,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
|
|||
|
||||
const bbox = new BBox(coordinates)
|
||||
const state = this._state
|
||||
const allNodes = [].concat(...state.featurePipeline.GetFeaturesWithin("type_node", bbox.pad(1.2)))
|
||||
const allNodes = [].concat(...state?.featurePipeline?.GetFeaturesWithin("type_node", bbox.pad(1.2))??[])
|
||||
const maxDistance = Math.max(...this._config.map(c => c.withinRangeOfM))
|
||||
|
||||
// Init coordianteinfo with undefined but the same length as coordinates
|
||||
|
|
|
@ -11,7 +11,7 @@ import ChangeTagAction from "./ChangeTagAction";
|
|||
import {And} from "../../Tags/And";
|
||||
import {Utils} from "../../../Utils";
|
||||
import {OsmConnection} from "../OsmConnection";
|
||||
import {GeoJSONObject} from "@turf/turf";
|
||||
import {Feature} from "@turf/turf";
|
||||
import FeaturePipeline from "../../FeatureSource/FeaturePipeline";
|
||||
|
||||
export default class ReplaceGeometryAction extends OsmChangeAction {
|
||||
|
@ -28,6 +28,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
/**
|
||||
* The target coordinates that should end up in OpenStreetMap.
|
||||
* This is identical to either this.feature.geometry.coordinates or -in case of a polygon- feature.geometry.coordinates[0]
|
||||
* Format: [lon, lat]
|
||||
*/
|
||||
private readonly targetCoordinates: [number, number][];
|
||||
/**
|
||||
|
@ -82,7 +83,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
// noinspection JSUnusedGlobalSymbols
|
||||
public async getPreview(): Promise<FeatureSource> {
|
||||
const {closestIds, allNodesById, detachedNodes, reprojectedNodes} = await this.GetClosestIds();
|
||||
const preview: GeoJSONObject[] = closestIds.map((newId, i) => {
|
||||
const preview: Feature[] = closestIds.map((newId, i) => {
|
||||
if (this.identicalTo[i] !== undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -121,7 +122,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
reprojectedNodes.forEach(({newLat, newLon, nodeId}) => {
|
||||
|
||||
const origNode = allNodesById.get(nodeId);
|
||||
const feature = {
|
||||
const feature : Feature = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
"move": "yes",
|
||||
|
@ -141,7 +142,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
|
||||
detachedNodes.forEach(({reason}, id) => {
|
||||
const origNode = allNodesById.get(id);
|
||||
const feature = {
|
||||
const feature : Feature = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
"detach": "yes",
|
||||
|
@ -158,7 +159,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
})
|
||||
|
||||
|
||||
return new StaticFeatureSource(Utils.NoNull(preview), false)
|
||||
return StaticFeatureSource.fromGeojson(Utils.NoNull(preview))
|
||||
|
||||
}
|
||||
|
||||
|
@ -540,8 +541,6 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
|
|||
id: nodeId,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
return allChanges
|
||||
|
|
|
@ -2,7 +2,7 @@ import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject";
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import Constants from "../../Models/Constants";
|
||||
import OsmChangeAction from "./Actions/OsmChangeAction";
|
||||
import {ChangeDescription} from "./Actions/ChangeDescription";
|
||||
import {ChangeDescription, ChangeDescriptionTools} from "./Actions/ChangeDescription";
|
||||
import {Utils} from "../../Utils";
|
||||
import {LocalStorageSource} from "../Web/LocalStorageSource";
|
||||
import SimpleMetaTagger from "../SimpleMetaTagger";
|
||||
|
@ -11,7 +11,7 @@ import FeatureSource from "../FeatureSource/FeatureSource";
|
|||
import {ElementStorage} from "../ElementStorage";
|
||||
import {GeoLocationPointProperties} from "../Actors/GeoLocationHandler";
|
||||
import {GeoOperations} from "../GeoOperations";
|
||||
import {ChangesetTag} from "./ChangesetHandler";
|
||||
import {ChangesetHandler, ChangesetTag} from "./ChangesetHandler";
|
||||
import {OsmConnection} from "./OsmConnection";
|
||||
|
||||
/**
|
||||
|
@ -27,17 +27,19 @@ export class Changes {
|
|||
public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
|
||||
public readonly pendingChanges: UIEventSource<ChangeDescription[]> = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
|
||||
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
|
||||
public readonly state: { allElements: ElementStorage; historicalUserLocations: FeatureSource; osmConnection: OsmConnection }
|
||||
public readonly state: { allElements: ElementStorage; osmConnection: OsmConnection }
|
||||
public readonly extraComment: UIEventSource<string> = new UIEventSource(undefined)
|
||||
|
||||
private historicalUserLocations: FeatureSource
|
||||
private _nextId: number = -1; // Newly assigned ID's are negative
|
||||
private readonly isUploading = new UIEventSource(false);
|
||||
private readonly previouslyCreated: OsmObject[] = []
|
||||
private readonly _leftRightSensitive: boolean;
|
||||
private _changesetHandler: ChangesetHandler;
|
||||
|
||||
constructor(
|
||||
state?: {
|
||||
allElements: ElementStorage,
|
||||
historicalUserLocations: FeatureSource,
|
||||
osmConnection: OsmConnection
|
||||
},
|
||||
leftRightSensitive: boolean = false) {
|
||||
|
@ -47,13 +49,14 @@ export class Changes {
|
|||
// If a pending change contains a negative ID, we save that
|
||||
this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? [])
|
||||
this.state = state;
|
||||
this._changesetHandler = state?.osmConnection?.CreateChangesetHandler(state.allElements, this)
|
||||
|
||||
// Note: a changeset might be reused which was opened just before and might have already used some ids
|
||||
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
|
||||
}
|
||||
|
||||
private static createChangesetFor(csId: string,
|
||||
allChanges: {
|
||||
static createChangesetFor(csId: string,
|
||||
allChanges: {
|
||||
modifiedObjects: OsmObject[],
|
||||
newObjects: OsmObject[],
|
||||
deletedObjects: OsmObject[]
|
||||
|
@ -139,14 +142,10 @@ export class Changes {
|
|||
this.allChanges.data.push(...changes)
|
||||
this.allChanges.ping()
|
||||
}
|
||||
|
||||
public registerIdRewrites(mappings: Map<string, string>): void {
|
||||
CreateNewNodeAction.registerIdRewrites(mappings)
|
||||
}
|
||||
|
||||
|
||||
private calculateDistanceToChanges(change: OsmChangeAction, changeDescriptions: ChangeDescription[]) {
|
||||
|
||||
const locations = this.state?.historicalUserLocations?.features?.data
|
||||
const locations = this.historicalUserLocations?.features?.data
|
||||
if (locations === undefined) {
|
||||
// No state loaded or no locations -> we can't calculate...
|
||||
return;
|
||||
|
@ -160,7 +159,7 @@ export class Changes {
|
|||
const recentLocationPoints = locations.map(ff => ff.feature)
|
||||
.filter(feat => feat.geometry.type === "Point")
|
||||
.filter(feat => {
|
||||
const visitTime = new Date((<GeoLocationPointProperties>feat.properties).date)
|
||||
const visitTime = new Date((<GeoLocationPointProperties><any>feat.properties).date)
|
||||
// In seconds
|
||||
const diff = (now.getTime() - visitTime.getTime()) / 1000
|
||||
return diff < Constants.nearbyVisitTime;
|
||||
|
@ -223,17 +222,11 @@ export class Changes {
|
|||
}
|
||||
|
||||
console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
|
||||
const changes: {
|
||||
newObjects: OsmObject[],
|
||||
modifiedObjects: OsmObject[]
|
||||
deletedObjects: OsmObject[]
|
||||
} = self.CreateChangesetObjects(pending, osmObjects)
|
||||
if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) {
|
||||
console.log("No changes to be made")
|
||||
return true
|
||||
if(pending.length == 0){
|
||||
console.log("No pending changes...")
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const perType = Array.from(
|
||||
Utils.Hist(pending.filter(descr => descr.meta.changeType !== undefined && descr.meta.changeType !== null)
|
||||
.map(descr => descr.meta.changeType)), ([key, count]) => (
|
||||
|
@ -299,8 +292,20 @@ export class Changes {
|
|||
...perBinMessage
|
||||
]
|
||||
|
||||
await this.state.osmConnection.changesetHandler.UploadChangeset(
|
||||
(csId) => Changes.createChangesetFor("" + csId, changes),
|
||||
await this._changesetHandler.UploadChangeset(
|
||||
(csId, remappings) =>{
|
||||
if(remappings.size > 0){
|
||||
console.log("Rewriting pending changes from", pending, "with", remappings)
|
||||
pending = pending.map(ch => ChangeDescriptionTools.rewriteIds(ch, remappings))
|
||||
console.log("Result is", pending)
|
||||
}
|
||||
const changes: {
|
||||
newObjects: OsmObject[],
|
||||
modifiedObjects: OsmObject[]
|
||||
deletedObjects: OsmObject[]
|
||||
} = self.CreateChangesetObjects(pending, osmObjects)
|
||||
return Changes.createChangesetFor("" + csId, changes)
|
||||
},
|
||||
metatags,
|
||||
openChangeset
|
||||
)
|
||||
|
@ -327,7 +332,7 @@ export class Changes {
|
|||
const successes = await Promise.all(Array.from(pendingPerTheme,
|
||||
async ([theme, pendingChanges]) => {
|
||||
try {
|
||||
const openChangeset = this.state.osmConnection.GetPreference("current-open-changeset-" + theme).map(
|
||||
const openChangeset = this.state.osmConnection.GetPreference("current-open-changeset-" + theme).sync(
|
||||
str => {
|
||||
const n = Number(str);
|
||||
if (isNaN(n)) {
|
||||
|
@ -360,7 +365,7 @@ export class Changes {
|
|||
|
||||
}
|
||||
|
||||
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
|
||||
public CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
|
||||
newObjects: OsmObject[],
|
||||
modifiedObjects: OsmObject[]
|
||||
deletedObjects: OsmObject[]
|
||||
|
@ -518,4 +523,8 @@ export class Changes {
|
|||
console.debug("Calculated the pending changes: ", result.newObjects.length, "new; ", result.modifiedObjects.length, "modified;", result.deletedObjects, "deleted")
|
||||
return result
|
||||
}
|
||||
|
||||
public setHistoricalUserLocations(locations: FeatureSource ){
|
||||
this.historicalUserLocations = locations
|
||||
}
|
||||
}
|
|
@ -23,6 +23,22 @@ export class ChangesetHandler {
|
|||
private readonly auth: any;
|
||||
private readonly backend: string;
|
||||
|
||||
|
||||
/**
|
||||
* Contains previously rewritten IDs
|
||||
* @private
|
||||
*/
|
||||
private readonly _remappings = new Map<string, string>()
|
||||
|
||||
|
||||
/**
|
||||
* Use 'osmConnection.CreateChangesetHandler' instead
|
||||
* @param dryRun
|
||||
* @param osmConnection
|
||||
* @param allElements
|
||||
* @param changes
|
||||
* @param auth
|
||||
*/
|
||||
constructor(dryRun: UIEventSource<boolean>,
|
||||
osmConnection: OsmConnection,
|
||||
allElements: ElementStorage,
|
||||
|
@ -42,14 +58,34 @@ export class ChangesetHandler {
|
|||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new list which contains every key at most once
|
||||
*
|
||||
* ChangesetHandler.removeDuplicateMetaTags([{key: "k", value: "v"}, {key: "k0", value: "v0"}, {key: "k", value:"v"}] // => [{key: "k", value: "v"}, {key: "k0", value: "v0"}]
|
||||
*/
|
||||
public static removeDuplicateMetaTags(extraMetaTags: ChangesetTag[]): ChangesetTag[]{
|
||||
const r : ChangesetTag[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const extraMetaTag of extraMetaTags) {
|
||||
if(seen.has(extraMetaTag.key)){
|
||||
continue
|
||||
}
|
||||
r.push(extraMetaTag)
|
||||
seen.add(extraMetaTag.key)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Inplace rewrite of extraMetaTags
|
||||
* If the metatags contain a special motivation of the format "<change-type>:node/-<number>", this method will rewrite this negative number to the actual ID
|
||||
* The key is changed _in place_; true will be returned if a change has been applied
|
||||
* @param extraMetaTags
|
||||
* @param rewriteIds
|
||||
* @private
|
||||
*/
|
||||
private static rewriteMetaTags(extraMetaTags: ChangesetTag[], rewriteIds: Map<string, string>) {
|
||||
static rewriteMetaTags(extraMetaTags: ChangesetTag[], rewriteIds: Map<string, string>) {
|
||||
let hasChange = false;
|
||||
for (const tag of extraMetaTags) {
|
||||
const match = tag.key.match(/^([a-zA-Z0-9_]+):(node\/-[0-9])$/)
|
||||
|
@ -77,21 +113,23 @@ export class ChangesetHandler {
|
|||
*
|
||||
*/
|
||||
public async UploadChangeset(
|
||||
generateChangeXML: (csid: number) => string,
|
||||
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
|
||||
extraMetaTags: ChangesetTag[],
|
||||
openChangeset: UIEventSource<number>): Promise<void> {
|
||||
|
||||
if (!extraMetaTags.some(tag => tag.key === "comment") || !extraMetaTags.some(tag => tag.key === "theme")) {
|
||||
throw "The meta tags should at least contain a `comment` and a `theme`"
|
||||
}
|
||||
|
||||
|
||||
extraMetaTags = [...extraMetaTags, ...this.defaultChangesetTags()]
|
||||
extraMetaTags = ChangesetHandler.removeDuplicateMetaTags(extraMetaTags)
|
||||
if (this.userDetails.data.csCount == 0) {
|
||||
// The user became a contributor!
|
||||
this.userDetails.data.csCount = 1;
|
||||
this.userDetails.ping();
|
||||
}
|
||||
if (this._dryRun.data) {
|
||||
const changesetXML = generateChangeXML(123456);
|
||||
const changesetXML = generateChangeXML(123456, this._remappings);
|
||||
console.log("Metatags are", extraMetaTags)
|
||||
console.log(changesetXML);
|
||||
return;
|
||||
|
@ -102,9 +140,9 @@ export class ChangesetHandler {
|
|||
try {
|
||||
const csId = await this.OpenChangeset(extraMetaTags)
|
||||
openChangeset.setData(csId);
|
||||
const changeset = generateChangeXML(csId);
|
||||
const changeset = generateChangeXML(csId, this._remappings);
|
||||
console.trace("Opened a new changeset (openChangeset.data is undefined):", changeset);
|
||||
const changes = await this.AddChange(csId, changeset)
|
||||
const changes = await this.UploadChange(csId, changeset)
|
||||
const hasSpecialMotivationChanges = ChangesetHandler.rewriteMetaTags(extraMetaTags, changes)
|
||||
if(hasSpecialMotivationChanges){
|
||||
// At this point, 'extraMetaTags' will have changed - we need to set the tags again
|
||||
|
@ -131,11 +169,12 @@ export class ChangesetHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
const rewritings = await this.AddChange(
|
||||
const rewritings = await this.UploadChange(
|
||||
csId,
|
||||
generateChangeXML(csId))
|
||||
generateChangeXML(csId, this._remappings))
|
||||
|
||||
await this.RewriteTagsOf(extraMetaTags, rewritings, oldChangesetMeta)
|
||||
const rewrittenTags = this.RewriteTagsOf(extraMetaTags, rewritings, oldChangesetMeta)
|
||||
await this.UpdateTags(csId, rewrittenTags)
|
||||
|
||||
} catch (e) {
|
||||
console.warn("Could not upload, changeset is probably closed: ", e);
|
||||
|
@ -145,13 +184,13 @@ export class ChangesetHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the metatag of a changeset -
|
||||
* Given an existing changeset with metadata and extraMetaTags to add, will fuse them to a new set of metatags
|
||||
* Does not yet send data
|
||||
* @param extraMetaTags: new changeset tags to add/fuse with this changeset
|
||||
* @param rewriteIds: the mapping of ids
|
||||
* @param oldChangesetMeta: the metadata-object of the already existing changeset
|
||||
* @constructor
|
||||
* @private
|
||||
*/
|
||||
private async RewriteTagsOf(extraMetaTags: ChangesetTag[],
|
||||
public RewriteTagsOf(extraMetaTags: ChangesetTag[],
|
||||
rewriteIds: Map<string, string>,
|
||||
oldChangesetMeta: {
|
||||
open: boolean,
|
||||
|
@ -159,9 +198,8 @@ export class ChangesetHandler {
|
|||
uid: number, // User ID
|
||||
changes_count: number,
|
||||
tags: any
|
||||
}) {
|
||||
}) : ChangesetTag[] {
|
||||
|
||||
const csId = oldChangesetMeta.id
|
||||
|
||||
// Note: extraMetaTags is where all the tags are collected into
|
||||
|
||||
|
@ -206,64 +244,71 @@ export class ChangesetHandler {
|
|||
|
||||
|
||||
ChangesetHandler.rewriteMetaTags(extraMetaTags, rewriteIds)
|
||||
|
||||
await this.UpdateTags(csId, extraMetaTags)
|
||||
|
||||
|
||||
return extraMetaTags
|
||||
|
||||
}
|
||||
|
||||
private handleIdRewrite(node: any, type: string): [string, string] {
|
||||
/**
|
||||
* Updates the id in the AllElements store, returns the new ID
|
||||
* @param node: the XML-element, e.g. <node old_id="-1" new_id="9650458521" new_version="1"/>
|
||||
* @param type
|
||||
* @private
|
||||
*/
|
||||
private static parseIdRewrite(node: any, type: string): [string, string] {
|
||||
const oldId = parseInt(node.attributes.old_id.value);
|
||||
if (node.attributes.new_id === undefined) {
|
||||
// We just removed this point!
|
||||
const element = this.allElements.getEventSourceById("node/" + oldId);
|
||||
element.data._deleted = "yes"
|
||||
element.ping();
|
||||
return;
|
||||
return [type+"/"+oldId, undefined];
|
||||
}
|
||||
|
||||
const newId = parseInt(node.attributes.new_id.value);
|
||||
// The actual mapping
|
||||
const result: [string, string] = [type + "/" + oldId, type + "/" + newId]
|
||||
if (!(oldId !== undefined && newId !== undefined &&
|
||||
!isNaN(oldId) && !isNaN(newId))) {
|
||||
if(oldId === newId){
|
||||
return undefined;
|
||||
}
|
||||
if (oldId == newId) {
|
||||
return undefined;
|
||||
}
|
||||
const element = this.allElements.getEventSourceById("node/" + oldId);
|
||||
if (element === undefined) {
|
||||
// Element to rewrite not found, probably a node or relation that is not rendered
|
||||
return undefined
|
||||
}
|
||||
element.data.id = type + "/" + newId;
|
||||
this.allElements.addElementById(type + "/" + newId, element);
|
||||
this.allElements.ContainingFeatures.set(type + "/" + newId, this.allElements.ContainingFeatures.get(type + "/" + oldId))
|
||||
element.ping();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a diff-result XML of the form
|
||||
* <diffResult version="0.6">
|
||||
* <node old_id="-1" new_id="9650458521" new_version="1"/>
|
||||
* <way old_id="-2" new_id="1050127772" new_version="1"/>
|
||||
* </diffResult>,
|
||||
* will:
|
||||
*
|
||||
* - create a mapping `{'node/-1' --> "node/9650458521", 'way/-2' --> "way/9650458521"}
|
||||
* - Call this.changes.registerIdRewrites
|
||||
* - Call handleIdRewrites as needed
|
||||
* @param response
|
||||
* @private
|
||||
*/
|
||||
private parseUploadChangesetResponse(response: XMLDocument): Map<string, string> {
|
||||
const nodes = response.getElementsByTagName("node");
|
||||
const mappings = new Map<string, string>()
|
||||
// @ts-ignore
|
||||
for (const node of nodes) {
|
||||
const mapping = this.handleIdRewrite(node, "node")
|
||||
const mappings : [string, string][]= []
|
||||
|
||||
for (const node of Array.from(nodes)) {
|
||||
const mapping = ChangesetHandler.parseIdRewrite(node, "node")
|
||||
if (mapping !== undefined) {
|
||||
mappings.set(mapping[0], mapping[1])
|
||||
mappings.push(mapping)
|
||||
}
|
||||
}
|
||||
|
||||
const ways = response.getElementsByTagName("way");
|
||||
// @ts-ignore
|
||||
for (const way of ways) {
|
||||
const mapping = this.handleIdRewrite(way, "way")
|
||||
for (const way of Array.from(ways)) {
|
||||
const mapping = ChangesetHandler.parseIdRewrite(way, "way")
|
||||
if (mapping !== undefined) {
|
||||
mappings.set(mapping[0], mapping[1])
|
||||
mappings.push(mapping)
|
||||
}
|
||||
}
|
||||
this.changes.registerIdRewrites(mappings)
|
||||
return mappings
|
||||
for (const mapping of mappings) {
|
||||
const [oldId, newId] = mapping
|
||||
this.allElements.addAlias(oldId, newId);
|
||||
if(newId !== undefined) {
|
||||
this._remappings.set(mapping[0], mapping[1])
|
||||
}
|
||||
}
|
||||
return new Map<string, string>(mappings)
|
||||
|
||||
}
|
||||
|
||||
|
@ -287,7 +332,7 @@ export class ChangesetHandler {
|
|||
})
|
||||
}
|
||||
|
||||
private async GetChangesetMeta(csId: number): Promise<{
|
||||
async GetChangesetMeta(csId: number): Promise<{
|
||||
id: number,
|
||||
open: boolean,
|
||||
uid: number,
|
||||
|
@ -307,8 +352,8 @@ export class ChangesetHandler {
|
|||
private async UpdateTags(
|
||||
csId: number,
|
||||
tags: ChangesetTag[]) {
|
||||
tags = ChangesetHandler.removeDuplicateMetaTags(tags)
|
||||
|
||||
console.trace("Updating tags of " + csId)
|
||||
const self = this;
|
||||
return new Promise<string>(function (resolve, reject) {
|
||||
|
||||
|
@ -324,7 +369,7 @@ export class ChangesetHandler {
|
|||
`</changeset></osm>`].join("")
|
||||
}, function (err, response) {
|
||||
if (response === undefined) {
|
||||
console.log("err", err);
|
||||
console.error("Updating the tags of changeset "+csId+" failed:", err);
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(response);
|
||||
|
@ -332,24 +377,30 @@ export class ChangesetHandler {
|
|||
});
|
||||
})
|
||||
}
|
||||
|
||||
private defaultChangesetTags() : ChangesetTag[]{
|
||||
return [ ["created_by", `MapComplete ${Constants.vNumber}`],
|
||||
["locale", Locale.language.data],
|
||||
["host", `${window.location.origin}${window.location.pathname}`],
|
||||
["source", this.changes.state["currentUserLocation"]?.features?.data?.length > 0 ? "survey" : undefined],
|
||||
["imagery", this.changes.state["backgroundLayer"]?.data?.id]].map(([key, value]) => ({
|
||||
key, value, aggretage: false
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a changeset with the specified tags
|
||||
* @param changesetTags
|
||||
* @constructor
|
||||
* @private
|
||||
*/
|
||||
private OpenChangeset(
|
||||
changesetTags: ChangesetTag[]
|
||||
): Promise<number> {
|
||||
const self = this;
|
||||
return new Promise<number>(function (resolve, reject) {
|
||||
|
||||
let path = window.location.pathname;
|
||||
path = path.substr(1, path.lastIndexOf("/"));
|
||||
const metadata = [
|
||||
["created_by", `MapComplete ${Constants.vNumber}`],
|
||||
["locale", Locale.language.data],
|
||||
["host", window.location.host],
|
||||
["path", path],
|
||||
["source", self.changes.state["currentUserLocation"]?.features?.data?.length > 0 ? "survey" : undefined],
|
||||
["imagery", self.changes.state["backgroundLayer"]?.data?.id],
|
||||
...changesetTags.map(cstag => [cstag.key, cstag.value])
|
||||
]
|
||||
const metadata = changesetTags.map(cstag => [cstag.key, cstag.value])
|
||||
.filter(kv => (kv[1] ?? "") !== "")
|
||||
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
|
||||
.join("\n")
|
||||
|
@ -364,7 +415,7 @@ export class ChangesetHandler {
|
|||
`</changeset></osm>`].join("")
|
||||
}, function (err, response) {
|
||||
if (response === undefined) {
|
||||
console.log("err", err);
|
||||
console.error("Opening a changeset failed:", err);
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(Number(response));
|
||||
|
@ -377,7 +428,7 @@ export class ChangesetHandler {
|
|||
/**
|
||||
* Upload a changesetXML
|
||||
*/
|
||||
private AddChange(changesetId: number,
|
||||
private UploadChange(changesetId: number,
|
||||
changesetXML: string): Promise<Map<string, string>> {
|
||||
const self = this;
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
@ -388,7 +439,7 @@ export class ChangesetHandler {
|
|||
content: changesetXML
|
||||
}, function (err, response) {
|
||||
if (response == null) {
|
||||
console.log("err", err);
|
||||
console.error("Uploading an actual change failed", err);
|
||||
reject(err);
|
||||
}
|
||||
const changes = self.parseUploadChangesetResponse(response);
|
||||
|
@ -400,4 +451,4 @@ export class ChangesetHandler {
|
|||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import State from "../../State";
|
||||
import {Utils} from "../../Utils";
|
||||
import {BBox} from "../BBox";
|
||||
|
||||
export interface GeoCodeResult {
|
||||
display_name: string,
|
||||
lat: number, lon: number, boundingbox: number[],
|
||||
osm_type: "node" | "way" | "relation",
|
||||
osm_id: string
|
||||
}
|
||||
|
||||
export class Geocoding {
|
||||
|
||||
private static readonly host = "https://nominatim.openstreetmap.org/search?";
|
||||
|
||||
static Search(query: string,
|
||||
handleResult: ((places: {
|
||||
display_name: string, lat: number, lon: number, boundingbox: number[],
|
||||
osm_type: string, osm_id: string
|
||||
}[]) => void),
|
||||
onFail: (() => void)) {
|
||||
const b = State.state.currentBounds.data;
|
||||
static async Search(query: string): Promise<GeoCodeResult[]> {
|
||||
const b = State?.state?.currentBounds?.data ?? BBox.global;
|
||||
const url = Geocoding.host + "format=json&limit=1&viewbox=" +
|
||||
`${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}` +
|
||||
"&accept-language=nl&q=" + query;
|
||||
Utils.downloadJson(
|
||||
url)
|
||||
.then(handleResult)
|
||||
.catch(onFail);
|
||||
return Utils.downloadJson(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// @ts-ignore
|
||||
import osmAuth from "osm-auth";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, Stores, UIEventSource} from "../UIEventSource";
|
||||
import {OsmPreferences} from "./OsmPreferences";
|
||||
import {ChangesetHandler} from "./ChangesetHandler";
|
||||
import {ElementStorage} from "../ElementStorage";
|
||||
|
@ -49,10 +48,9 @@ export class OsmConnection {
|
|||
}
|
||||
public auth;
|
||||
public userDetails: UIEventSource<UserDetails>;
|
||||
public isLoggedIn: UIEventSource<boolean>
|
||||
public isLoggedIn: Store<boolean>
|
||||
public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">("not-attempted")
|
||||
public preferencesHandler: OsmPreferences;
|
||||
public changesetHandler: ChangesetHandler;
|
||||
public readonly _oauth_config: {
|
||||
oauth_consumer_key: string,
|
||||
oauth_secret: string,
|
||||
|
@ -68,8 +66,6 @@ export class OsmConnection {
|
|||
constructor(options: {
|
||||
dryRun?: UIEventSource<boolean>,
|
||||
fakeUser?: false | boolean,
|
||||
allElements: ElementStorage,
|
||||
changes: Changes,
|
||||
oauth_token?: UIEventSource<string>,
|
||||
// Used to keep multiple changesets open and to write to the correct changeset
|
||||
singlePage?: boolean,
|
||||
|
@ -94,20 +90,21 @@ export class OsmConnection {
|
|||
ud.totalMessages = 42;
|
||||
}
|
||||
const self = this;
|
||||
this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => {
|
||||
this.isLoggedIn = this.userDetails.map(user => user.loggedIn);
|
||||
this.isLoggedIn.addCallback(isLoggedIn => {
|
||||
if (self.userDetails.data.loggedIn == false && isLoggedIn == true) {
|
||||
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
|
||||
// This means someone attempted to toggle this; so we attempt to login!
|
||||
self.AttemptLogin()
|
||||
}
|
||||
});
|
||||
|
||||
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false);
|
||||
|
||||
this.updateAuthObject();
|
||||
|
||||
this.preferencesHandler = new OsmPreferences(this.auth, this);
|
||||
|
||||
this.changesetHandler = new ChangesetHandler(this._dryRun, this, options.allElements, options.changes, this.auth);
|
||||
if (options.oauth_token?.data !== undefined) {
|
||||
console.log(options.oauth_token.data)
|
||||
const self = this;
|
||||
|
@ -126,9 +123,13 @@ export class OsmConnection {
|
|||
console.log("Not authenticated");
|
||||
}
|
||||
}
|
||||
|
||||
public CreateChangesetHandler(allElements: ElementStorage, changes: Changes){
|
||||
return new ChangesetHandler(this._dryRun, this, allElements, changes, this.auth);
|
||||
}
|
||||
|
||||
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
return this.preferencesHandler.GetPreference(key, prefix);
|
||||
public GetPreference(key: string, defaultValue: string = undefined, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
return this.preferencesHandler.GetPreference(key, defaultValue, prefix);
|
||||
}
|
||||
|
||||
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
|
@ -148,6 +149,10 @@ export class OsmConnection {
|
|||
console.log("Logged out")
|
||||
this.loadingStatus.setData("not-attempted")
|
||||
}
|
||||
|
||||
public Backend(): string {
|
||||
return this._oauth_config.url;
|
||||
}
|
||||
|
||||
public AttemptLogin() {
|
||||
this.loadingStatus.setData("loading")
|
||||
|
@ -226,14 +231,14 @@ export class OsmConnection {
|
|||
});
|
||||
}
|
||||
|
||||
public closeNote(id: number | string, text?: string): Promise<any> {
|
||||
public closeNote(id: number | string, text?: string): Promise<void> {
|
||||
let textSuffix = ""
|
||||
if ((text ?? "") !== "") {
|
||||
textSuffix = "?text=" + encodeURIComponent(text)
|
||||
}
|
||||
if (this._dryRun.data) {
|
||||
console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text)
|
||||
return new Promise((ok, error) => {
|
||||
return new Promise((ok) => {
|
||||
ok()
|
||||
});
|
||||
}
|
||||
|
@ -241,7 +246,7 @@ export class OsmConnection {
|
|||
this.auth.xhr({
|
||||
method: 'POST',
|
||||
path: `/api/0.6/notes/${id}/close${textSuffix}`,
|
||||
}, function (err, response) {
|
||||
}, function (err, _) {
|
||||
if (err !== null) {
|
||||
error(err)
|
||||
} else {
|
||||
|
@ -253,10 +258,10 @@ export class OsmConnection {
|
|||
|
||||
}
|
||||
|
||||
public reopenNote(id: number | string, text?: string): Promise<any> {
|
||||
public reopenNote(id: number | string, text?: string): Promise<void> {
|
||||
if (this._dryRun.data) {
|
||||
console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text)
|
||||
return new Promise((ok, error) => {
|
||||
return new Promise((ok) => {
|
||||
ok()
|
||||
});
|
||||
}
|
||||
|
@ -268,7 +273,7 @@ export class OsmConnection {
|
|||
this.auth.xhr({
|
||||
method: 'POST',
|
||||
path: `/api/0.6/notes/${id}/reopen${textSuffix}`
|
||||
}, function (err, response) {
|
||||
}, function (err, _) {
|
||||
if (err !== null) {
|
||||
error(err)
|
||||
} else {
|
||||
|
@ -283,7 +288,7 @@ export class OsmConnection {
|
|||
public openNote(lat: number, lon: number, text: string): Promise<{ id: number }> {
|
||||
if (this._dryRun.data) {
|
||||
console.warn("Dryrun enabled - not actually opening note with text ", text)
|
||||
return new Promise<{ id: number }>((ok, error) => {
|
||||
return new Promise<{ id: number }>((ok) => {
|
||||
window.setTimeout(() => ok({id: Math.floor(Math.random() * 1000)}), Math.random() * 5000)
|
||||
});
|
||||
}
|
||||
|
@ -392,10 +397,10 @@ export class OsmConnection {
|
|||
|
||||
}
|
||||
|
||||
public addCommentToNode(id: number | string, text: string): Promise<any> {
|
||||
public addCommentToNote(id: number | string, text: string): Promise<void> {
|
||||
if (this._dryRun.data) {
|
||||
console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id)
|
||||
return new Promise((ok, error) => {
|
||||
return new Promise((ok) => {
|
||||
ok()
|
||||
});
|
||||
}
|
||||
|
@ -408,7 +413,7 @@ export class OsmConnection {
|
|||
method: 'POST',
|
||||
|
||||
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`
|
||||
}, function (err, response) {
|
||||
}, function (err, _) {
|
||||
if (err !== null) {
|
||||
error(err)
|
||||
} else {
|
||||
|
@ -454,7 +459,7 @@ export class OsmConnection {
|
|||
return;
|
||||
}
|
||||
this.isChecking = true;
|
||||
UIEventSource.Chronic(5 * 60 * 1000).addCallback(_ => {
|
||||
Stores.Chronic(5 * 60 * 1000).addCallback(_ => {
|
||||
if (self.isLoggedIn.data) {
|
||||
console.log("Checking for messages")
|
||||
self.AttemptLogin();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {Utils} from "../../Utils";
|
||||
import * as polygon_features from "../../assets/polygon-features.json";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import {BBox} from "../BBox";
|
||||
|
||||
import * as OsmToGeoJson from "osmtogeojson";
|
||||
|
||||
export abstract class OsmObject {
|
||||
|
||||
|
@ -38,9 +38,10 @@ export abstract class OsmObject {
|
|||
throw "Backend URL must begin with http"
|
||||
}
|
||||
this.backendURL = url;
|
||||
this.DownloadObject("id/5")
|
||||
}
|
||||
|
||||
public static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
|
||||
public static DownloadObject(id: string, forceRefresh: boolean = false): Store<OsmObject> {
|
||||
let src: UIEventSource<OsmObject>;
|
||||
if (OsmObject.objectCache.has(id)) {
|
||||
src = OsmObject.objectCache.get(id)
|
||||
|
@ -69,7 +70,7 @@ export abstract class OsmObject {
|
|||
return rawData.elements[0].tags
|
||||
}
|
||||
|
||||
static async DownloadObjectAsync(id: string): Promise<OsmObject> {
|
||||
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined> {
|
||||
const splitted = id.split("/");
|
||||
const type = splitted[0];
|
||||
const idN = Number(splitted[1]);
|
||||
|
@ -77,9 +78,12 @@ export abstract class OsmObject {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const full = (id.startsWith("way")) ? "/full" : "";
|
||||
const full = (!id.startsWith("node")) ? "/full" : "";
|
||||
const url = `${OsmObject.backendURL}api/0.6/${id}${full}`;
|
||||
const rawData = await Utils.downloadJsonCached(url, 1000)
|
||||
const rawData = await Utils.downloadJsonCached(url, 10000)
|
||||
if (rawData === undefined) {
|
||||
return undefined
|
||||
}
|
||||
// A full query might contain more then just the requested object (e.g. nodes that are part of a way, where we only want the way)
|
||||
const parsed = OsmObject.ParseObjects(rawData.elements);
|
||||
// Lets fetch the object we need
|
||||
|
@ -124,7 +128,7 @@ export abstract class OsmObject {
|
|||
return data.elements.map(wayInfo => {
|
||||
const rel = new OsmRelation(wayInfo.id)
|
||||
rel.LoadData(wayInfo)
|
||||
rel.SaveExtraData(wayInfo)
|
||||
rel.SaveExtraData(wayInfo, undefined)
|
||||
return rel
|
||||
})
|
||||
}
|
||||
|
@ -193,7 +197,13 @@ export abstract class OsmObject {
|
|||
break;
|
||||
case("relation"):
|
||||
osmObject = new OsmRelation(idN);
|
||||
osmObject.SaveExtraData(element, [])
|
||||
const allGeojsons = OsmToGeoJson.default({elements},
|
||||
// @ts-ignore
|
||||
{
|
||||
flatProperties: true
|
||||
});
|
||||
const feature = allGeojsons.features.find(f => f.id === osmObject.type + "/" + osmObject.id)
|
||||
osmObject.SaveExtraData(element, feature)
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -207,27 +217,39 @@ export abstract class OsmObject {
|
|||
return objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the list of polygon features to determine if the given tags are a polygon or not.
|
||||
*
|
||||
* OsmObject.isPolygon({"building":"yes"}) // => true
|
||||
* OsmObject.isPolygon({"highway":"residential"}) // => false
|
||||
* */
|
||||
protected static isPolygon(tags: any): boolean {
|
||||
for (const tagsKey in tags) {
|
||||
if (!tags.hasOwnProperty(tagsKey)) {
|
||||
continue
|
||||
}
|
||||
const polyGuide = OsmObject.polygonFeatures.get(tagsKey)
|
||||
const polyGuide: { values: Set<string>; blacklist: boolean } = OsmObject.polygonFeatures.get(tagsKey)
|
||||
if (polyGuide === undefined) {
|
||||
continue
|
||||
}
|
||||
if ((polyGuide.values === null)) {
|
||||
// We match all
|
||||
// .values is null, thus merely _having_ this key is enough to be a polygon (or if blacklist, being a line)
|
||||
return !polyGuide.blacklist
|
||||
}
|
||||
// is the key contained?
|
||||
return polyGuide.values.has(tags[tagsKey])
|
||||
// is the key contained? Then we have a match if the value is contained
|
||||
const doesMatch = polyGuide.values.has(tags[tagsKey])
|
||||
if (polyGuide.blacklist) {
|
||||
return !doesMatch
|
||||
}
|
||||
return doesMatch
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static constructPolygonFeatures(): Map<string, { values: Set<string>, blacklist: boolean }> {
|
||||
const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
|
||||
for (const polygonFeature of polygon_features) {
|
||||
for (const polygonFeature of (polygon_features["default"] ?? polygon_features)) {
|
||||
const key = polygonFeature.key;
|
||||
|
||||
if (polygonFeature.polygon === "all") {
|
||||
|
@ -248,7 +270,7 @@ export abstract class OsmObject {
|
|||
|
||||
public abstract asGeoJson(): any;
|
||||
|
||||
abstract SaveExtraData(element: any, allElements: OsmObject[]);
|
||||
abstract SaveExtraData(element: any, allElements: OsmObject[] | any);
|
||||
|
||||
/**
|
||||
* Generates the changeset-XML for tags
|
||||
|
@ -381,7 +403,7 @@ export class OsmWay extends OsmObject {
|
|||
}
|
||||
|
||||
if (element.nodes === undefined) {
|
||||
console.log("PANIC")
|
||||
console.error("PANIC: no nodes!")
|
||||
}
|
||||
|
||||
for (const nodeId of element.nodes) {
|
||||
|
@ -417,7 +439,9 @@ export class OsmWay extends OsmObject {
|
|||
}
|
||||
|
||||
private isPolygon(): boolean {
|
||||
if (this.coordinates[0] !== this.coordinates[this.coordinates.length - 1]) {
|
||||
// Compare lat and lon seperately, as the coordinate array might not be a reference to the same object
|
||||
if (this.coordinates[0][0] !== this.coordinates[this.coordinates.length - 1][0] ||
|
||||
this.coordinates[0][1] !== this.coordinates[this.coordinates.length - 1][1]) {
|
||||
return false; // Not closed
|
||||
}
|
||||
return OsmObject.isPolygon(this.tags)
|
||||
|
@ -433,6 +457,8 @@ export class OsmRelation extends OsmObject {
|
|||
role: string
|
||||
}[];
|
||||
|
||||
private geojson = undefined
|
||||
|
||||
constructor(id: number) {
|
||||
super("relation", id);
|
||||
}
|
||||
|
@ -458,11 +484,15 @@ ${members}${tags} </relation>
|
|||
|
||||
}
|
||||
|
||||
SaveExtraData(element) {
|
||||
SaveExtraData(element, geojson) {
|
||||
this.members = element.members;
|
||||
this.geojson = geojson
|
||||
}
|
||||
|
||||
asGeoJson(): any {
|
||||
if (this.geojson !== undefined) {
|
||||
return this.geojson;
|
||||
}
|
||||
throw "Not Implemented"
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import UserDetails, {OsmConnection} from "./OsmConnection";
|
||||
import {Utils} from "../../Utils";
|
||||
import {DomEvent} from "leaflet";
|
||||
import preventDefault = DomEvent.preventDefault;
|
||||
|
||||
export class OsmPreferences {
|
||||
|
||||
public preferences = new UIEventSource<any>({}, "all-osm-preferences");
|
||||
public preferences = new UIEventSource<Record<string, string>>({}, "all-osm-preferences");
|
||||
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
|
||||
private auth: any;
|
||||
private userDetails: UIEventSource<UserDetails>;
|
||||
|
@ -35,7 +37,7 @@ export class OsmPreferences {
|
|||
|
||||
const allStartWith = prefix + key + "-combined";
|
||||
// Gives the number of combined preferences
|
||||
const length = this.GetPreference(allStartWith + "-length", "");
|
||||
const length = this.GetPreference(allStartWith + "-length", "", "");
|
||||
|
||||
if( (allStartWith + "-length").length > 255){
|
||||
throw "This preference key is too long, it has "+key.length+" characters, but at most "+(255 - "-length".length - "-combined".length - prefix.length)+" characters are allowed"
|
||||
|
@ -51,10 +53,10 @@ export class OsmPreferences {
|
|||
let count = parseInt(length.data);
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Delete all the preferences
|
||||
self.GetPreference(allStartWith + "-" + i, "")
|
||||
self.GetPreference(allStartWith + "-" + i, "", "")
|
||||
.setData("");
|
||||
}
|
||||
self.GetPreference(allStartWith + "-length", "")
|
||||
self.GetPreference(allStartWith + "-length", "", "")
|
||||
.setData("");
|
||||
return
|
||||
}
|
||||
|
@ -67,7 +69,7 @@ export class OsmPreferences {
|
|||
if (i > 100) {
|
||||
throw "This long preference is getting very long... "
|
||||
}
|
||||
self.GetPreference(allStartWith + "-" + i, "").setData(str.substr(0, 255));
|
||||
self.GetPreference(allStartWith + "-" + i, "","").setData(str.substr(0, 255));
|
||||
str = str.substr(255);
|
||||
i++;
|
||||
}
|
||||
|
@ -76,9 +78,9 @@ export class OsmPreferences {
|
|||
|
||||
|
||||
function updateData(l: number) {
|
||||
if (l === undefined) {
|
||||
source.setData(undefined);
|
||||
return;
|
||||
if(Object.keys(self.preferences.data).length === 0){
|
||||
// The preferences are still empty - they are not yet updated, so we delay updating for now
|
||||
return
|
||||
}
|
||||
const prefsCount = Number(l);
|
||||
if (prefsCount > 100) {
|
||||
|
@ -86,7 +88,11 @@ export class OsmPreferences {
|
|||
}
|
||||
let str = "";
|
||||
for (let i = 0; i < prefsCount; i++) {
|
||||
str += self.GetPreference(allStartWith + "-" + i, "").data;
|
||||
const key = allStartWith + "-" + i
|
||||
if(self.preferences.data[key] === undefined){
|
||||
console.warn("Detected a broken combined preference:", key, "is undefined", self.preferences)
|
||||
}
|
||||
str += self.preferences.data[key] ?? "";
|
||||
}
|
||||
|
||||
source.setData(str);
|
||||
|
@ -95,12 +101,17 @@ export class OsmPreferences {
|
|||
length.addCallback(l => {
|
||||
updateData(Number(l));
|
||||
});
|
||||
updateData(Number(length.data));
|
||||
this.preferences.addCallbackAndRun(_ => {
|
||||
updateData(Number(length.data));
|
||||
})
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
public GetPreference(key: string, defaultValue : string = undefined, prefix: string = "mapcomplete-"): UIEventSource<string> {
|
||||
if(key.startsWith(prefix) && prefix !== ""){
|
||||
console.trace("A preference was requested which has a duplicate prefix in its key. This is probably a bug")
|
||||
}
|
||||
key = prefix + key;
|
||||
key = key.replace(/[:\\\/"' {}.%]/g, '')
|
||||
if (key.length >= 255) {
|
||||
|
@ -114,7 +125,7 @@ export class OsmPreferences {
|
|||
this.UpdatePreferences();
|
||||
}
|
||||
|
||||
const pref = new UIEventSource<string>(this.preferences.data[key], "osm-preference:" + key);
|
||||
const pref = new UIEventSource<string>(this.preferences.data[key] ?? defaultValue, "osm-preference:" + key);
|
||||
pref.addCallback((v) => {
|
||||
this.UploadPreference(key, v);
|
||||
});
|
||||
|
@ -127,7 +138,8 @@ export class OsmPreferences {
|
|||
public ClearPreferences() {
|
||||
let isRunning = false;
|
||||
const self = this;
|
||||
this.preferences.addCallbackAndRun(prefs => {
|
||||
this.preferences.addCallback(prefs => {
|
||||
console.log("Cleaning preferences...")
|
||||
if (Object.keys(prefs).length == 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -135,19 +147,17 @@ export class OsmPreferences {
|
|||
return
|
||||
}
|
||||
isRunning = true
|
||||
const prefixes = ["mapcomplete-installed-theme", "mapcomplete-installed-themes-", "mapcomplete-current-open-changeset", "mapcomplete-personal-theme-layer"]
|
||||
const prefixes = ["mapcomplete-"]
|
||||
for (const key in prefs) {
|
||||
for (const prefix of prefixes) {
|
||||
if (key.startsWith(prefix)) {
|
||||
console.log("Clearing ", key)
|
||||
self.GetPreference(key, "").setData("")
|
||||
const matches = prefixes.some(prefix => key.startsWith(prefix))
|
||||
if (matches) {
|
||||
console.log("Clearing ", key)
|
||||
self.GetPreference(key, "", "").setData("")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
isRunning = false;
|
||||
return true;
|
||||
|
||||
return;
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -173,7 +183,6 @@ export class OsmPreferences {
|
|||
// For differing values, the server overrides local changes
|
||||
self.preferenceSources.forEach((preference, key) => {
|
||||
const osmValue = self.preferences.data[key]
|
||||
console.log("Sending value to osm:", key," osm has: ", osmValue, " local has: ", preference.data)
|
||||
if(osmValue === undefined && preference.data !== undefined){
|
||||
// OSM doesn't know this value yet
|
||||
self.UploadPreference(key, preference.data)
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import {TagsFilter} from "../Tags/TagsFilter";
|
||||
import RelationsTracker from "./RelationsTracker";
|
||||
import {Utils} from "../../Utils";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {ImmutableStore, Store} from "../UIEventSource";
|
||||
import {BBox} from "../BBox";
|
||||
import * as osmtogeojson from "osmtogeojson";
|
||||
import {FeatureCollection} from "@turf/turf";
|
||||
|
||||
/**
|
||||
* Interfaces overpass to get all the latest data
|
||||
|
@ -11,32 +12,42 @@ import * as osmtogeojson from "osmtogeojson";
|
|||
export class Overpass {
|
||||
private _filter: TagsFilter
|
||||
private readonly _interpreterUrl: string;
|
||||
private readonly _timeout: UIEventSource<number>;
|
||||
private readonly _timeout: Store<number>;
|
||||
private readonly _extraScripts: string[];
|
||||
private _includeMeta: boolean;
|
||||
private _relationTracker: RelationsTracker;
|
||||
|
||||
|
||||
constructor(filter: TagsFilter,
|
||||
extraScripts: string[],
|
||||
interpreterUrl: string,
|
||||
timeout: UIEventSource<number>,
|
||||
relationTracker: RelationsTracker,
|
||||
timeout?: Store<number>,
|
||||
relationTracker?: RelationsTracker,
|
||||
includeMeta = true) {
|
||||
this._timeout = timeout;
|
||||
this._timeout = timeout ?? new ImmutableStore<number>(90);
|
||||
this._interpreterUrl = interpreterUrl;
|
||||
this._filter = filter
|
||||
const optimized = filter.optimize()
|
||||
if(optimized === true || optimized === false){
|
||||
throw "Invalid filter: optimizes to true of false"
|
||||
}
|
||||
this._filter = optimized
|
||||
this._extraScripts = extraScripts;
|
||||
this._includeMeta = includeMeta;
|
||||
this._relationTracker = relationTracker
|
||||
}
|
||||
|
||||
public async queryGeoJson(bounds: BBox): Promise<[any, Date]> {
|
||||
|
||||
let query = this.buildQuery("[bbox:" + bounds.getSouth() + "," + bounds.getWest() + "," + bounds.getNorth() + "," + bounds.getEast() + "]")
|
||||
|
||||
public async queryGeoJson(bounds: BBox): Promise<[FeatureCollection, Date]> {
|
||||
const bbox = "[bbox:" + bounds.getSouth() + "," + bounds.getWest() + "," + bounds.getNorth() + "," + bounds.getEast() + "]";
|
||||
const query = this.buildScript(bbox)
|
||||
return this.ExecuteQuery(query);
|
||||
}
|
||||
|
||||
public buildUrl(query: string){
|
||||
return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
|
||||
}
|
||||
|
||||
public async ExecuteQuery(query: string):Promise<[FeatureCollection, Date]> {
|
||||
const self = this;
|
||||
const json = await Utils.downloadJson(query)
|
||||
const json = await Utils.downloadJson(this.buildUrl(query))
|
||||
|
||||
if (json.elements.length === 0 && json.remark !== undefined) {
|
||||
console.warn("Timeout or other runtime error while querying overpass", json.remark);
|
||||
|
@ -46,23 +57,79 @@ export class Overpass {
|
|||
console.warn("No features for", json)
|
||||
}
|
||||
|
||||
self._relationTracker.RegisterRelations(json)
|
||||
self._relationTracker?.RegisterRelations(json)
|
||||
const geojson = osmtogeojson.default(json);
|
||||
const osmTime = new Date(json.osm3s.timestamp_osm_base);
|
||||
return [geojson, osmTime];
|
||||
return [<any> geojson, osmTime];
|
||||
}
|
||||
|
||||
buildQuery(bbox: string): string {
|
||||
/**
|
||||
* Constructs the actual script to execute on Overpass
|
||||
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink'
|
||||
*
|
||||
* import {Tag} from "../Tags/Tag";
|
||||
*
|
||||
* new Overpass(new Tag("key","value"), [], "").buildScript("{{bbox}}") // => `[out:json][timeout:90]{{bbox}};(nwr["key"="value"];);out body;out meta;>;out skel qt;`
|
||||
*/
|
||||
public buildScript(bbox: string, postCall: string = "", pretty = false): string {
|
||||
const filters = this._filter.asOverpass()
|
||||
let filter = ""
|
||||
for (const filterOr of filters) {
|
||||
filter += 'nwr' + filterOr + ';'
|
||||
if(pretty){
|
||||
filter += " "
|
||||
}
|
||||
filter += 'nwr' + filterOr + postCall + ';'
|
||||
if(pretty){
|
||||
filter+="\n"
|
||||
}
|
||||
}
|
||||
for (const extraScript of this._extraScripts) {
|
||||
filter += '(' + extraScript + ');';
|
||||
}
|
||||
const query =
|
||||
`[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
|
||||
return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
|
||||
return`[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
|
||||
}
|
||||
/**
|
||||
* Constructs the actual script to execute on Overpass with geocoding
|
||||
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink'
|
||||
*
|
||||
*/
|
||||
public buildScriptInArea(area: {osm_type: "way" | "relation", osm_id: number}, pretty = false): string {
|
||||
const filters = this._filter.asOverpass()
|
||||
let filter = ""
|
||||
for (const filterOr of filters) {
|
||||
if(pretty){
|
||||
filter += " "
|
||||
}
|
||||
filter += 'nwr' + filterOr + '(area.searchArea);'
|
||||
if(pretty){
|
||||
filter+="\n"
|
||||
}
|
||||
}
|
||||
for (const extraScript of this._extraScripts) {
|
||||
filter += '(' + extraScript + ');';
|
||||
}
|
||||
let id = area.osm_id;
|
||||
if(area.osm_type === "relation"){
|
||||
id += 3600000000
|
||||
}
|
||||
return`[out:json][timeout:${this._timeout.data}];
|
||||
area(id:${id})->.searchArea;
|
||||
(${filter});
|
||||
out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
|
||||
}
|
||||
|
||||
|
||||
public buildQuery(bbox: string) {
|
||||
return this.buildUrl(this.buildScript(bbox))
|
||||
}
|
||||
|
||||
/**
|
||||
* Little helper method to quickly open overpass-turbo in the browser
|
||||
*/
|
||||
public static AsOverpassTurboLink(tags: TagsFilter){
|
||||
const overpass = new Overpass(tags, [], "", undefined, undefined, false)
|
||||
const script = overpass.buildScript("","({{bbox}})", true)
|
||||
const url = "http://overpass-turbo.eu/?Q="
|
||||
return url + encodeURIComponent(script)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import Title from "../UI/Base/Title";
|
|||
import {FixedUiElement} from "../UI/Base/FixedUiElement";
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
|
||||
import {CountryCoder} from "latlon2country"
|
||||
import Constants from "../Models/Constants";
|
||||
import {TagUtils} from "./Tags/TagUtils";
|
||||
|
||||
|
||||
export class SimpleMetaTagger {
|
||||
|
@ -31,7 +33,7 @@ export class SimpleMetaTagger {
|
|||
if (!docs.cleanupRetagger) {
|
||||
for (const key of docs.keys) {
|
||||
if (!key.startsWith('_') && key.toLowerCase().indexOf("theme") < 0) {
|
||||
throw `Incorrect metakey ${key}: it should start with underscore (_)`
|
||||
throw `Incorrect key for a calculated meta value '${key}': it should start with underscore (_)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +42,7 @@ export class SimpleMetaTagger {
|
|||
}
|
||||
|
||||
export class CountryTagger extends SimpleMetaTagger {
|
||||
private static readonly coder = new CountryCoder("https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country", Utils.downloadJson);
|
||||
private static readonly coder = new CountryCoder(Constants.countryCoderEndpoint, Utils.downloadJson);
|
||||
public runningTasks: Set<any>;
|
||||
|
||||
constructor() {
|
||||
|
@ -210,6 +212,27 @@ export default class SimpleMetaTaggers {
|
|||
return true;
|
||||
})
|
||||
);
|
||||
private static levels = new SimpleMetaTagger(
|
||||
{
|
||||
doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
|
||||
keys: ["_level"]
|
||||
},
|
||||
((feature) => {
|
||||
if (feature.properties["level"] === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const l = feature.properties["level"]
|
||||
const newValue = TagUtils.LevelsParser(l).join(";")
|
||||
if(l === newValue) {
|
||||
return false;
|
||||
}
|
||||
feature.properties["level"] = newValue
|
||||
return true
|
||||
|
||||
})
|
||||
)
|
||||
|
||||
private static canonicalize = new SimpleMetaTagger(
|
||||
{
|
||||
doc: "If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical form (e.g. `1meter` will be rewritten to `1m`)",
|
||||
|
@ -217,7 +240,7 @@ export default class SimpleMetaTaggers {
|
|||
|
||||
},
|
||||
((feature, _, __, state) => {
|
||||
const units = Utils.NoNull([].concat(...state?.layoutToUse?.layers?.map(layer => layer.units ?? [])));
|
||||
const units = Utils.NoNull([].concat(...state?.layoutToUse?.layers?.map(layer => layer.units) ?? []));
|
||||
if (units.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -313,9 +336,10 @@ export default class SimpleMetaTaggers {
|
|||
lat: lat,
|
||||
lon: lon,
|
||||
address: {
|
||||
country_code: tags._country.toLowerCase()
|
||||
country_code: tags._country.toLowerCase(),
|
||||
state: undefined
|
||||
}
|
||||
}, {tag_key: "opening_hours"});
|
||||
}, <any>{tag_key: "opening_hours"});
|
||||
|
||||
// Recalculate!
|
||||
return oh.getState() ? "yes" : "no";
|
||||
|
@ -325,12 +349,12 @@ export default class SimpleMetaTaggers {
|
|||
delete tags._isOpen
|
||||
tags["_isOpen"] = "parse_error";
|
||||
}
|
||||
}});
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const tagsSource = state.allElements.getEventSourceById(feature.properties.id);
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
)
|
||||
|
@ -398,7 +422,8 @@ export default class SimpleMetaTaggers {
|
|||
SimpleMetaTaggers.currentTime,
|
||||
SimpleMetaTaggers.objectMetaInfo,
|
||||
SimpleMetaTaggers.noBothButLeftRight,
|
||||
SimpleMetaTaggers.geometryType
|
||||
SimpleMetaTaggers.geometryType,
|
||||
SimpleMetaTaggers.levels
|
||||
|
||||
];
|
||||
public static readonly lazyTags: string[] = [].concat(...SimpleMetaTaggers.metatags.filter(tagger => tagger.isLazy)
|
||||
|
@ -485,7 +510,7 @@ export default class SimpleMetaTaggers {
|
|||
const subElements: (string | BaseUIElement)[] = [
|
||||
new Combine([
|
||||
"Metatags are extra tags available, in order to display more data or to give better questions.",
|
||||
"The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.",
|
||||
"They are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.",
|
||||
"**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object"
|
||||
]).SetClass("flex-col")
|
||||
|
||||
|
|
|
@ -20,11 +20,7 @@ export default class ElementsState extends FeatureSwitchState {
|
|||
The mapping from id -> UIEventSource<properties>
|
||||
*/
|
||||
public allElements: ElementStorage = new ElementStorage();
|
||||
/**
|
||||
THe change handler
|
||||
*/
|
||||
public changes: Changes;
|
||||
|
||||
|
||||
/**
|
||||
The latest element that was selected
|
||||
*/
|
||||
|
@ -47,32 +43,34 @@ export default class ElementsState extends FeatureSwitchState {
|
|||
|
||||
constructor(layoutToUse: LayoutConfig) {
|
||||
super(layoutToUse);
|
||||
|
||||
|
||||
function localStorageSynced(key: string, deflt: number, docs: string ): UIEventSource<number>{
|
||||
const localStorage = LocalStorageSource.Get(key)
|
||||
const previousValue = localStorage.data
|
||||
const src = UIEventSource.asFloat(
|
||||
QueryParameters.GetQueryParameter(
|
||||
key,
|
||||
"" + deflt,
|
||||
docs
|
||||
).syncWith(localStorage)
|
||||
);
|
||||
|
||||
if(src.data === deflt){
|
||||
const prev = Number(previousValue)
|
||||
if(!isNaN(prev)){
|
||||
src.setData(prev)
|
||||
}
|
||||
}
|
||||
|
||||
return src;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false)
|
||||
{
|
||||
// -- Location control initialization
|
||||
const zoom = UIEventSource.asFloat(
|
||||
QueryParameters.GetQueryParameter(
|
||||
"z",
|
||||
"" + (layoutToUse?.startZoom ?? 1),
|
||||
"The initial/current zoom level"
|
||||
).syncWith(LocalStorageSource.Get("zoom"))
|
||||
);
|
||||
const lat = UIEventSource.asFloat(
|
||||
QueryParameters.GetQueryParameter(
|
||||
"lat",
|
||||
"" + (layoutToUse?.startLat ?? 0),
|
||||
"The initial/current latitude"
|
||||
).syncWith(LocalStorageSource.Get("lat"))
|
||||
);
|
||||
const lon = UIEventSource.asFloat(
|
||||
QueryParameters.GetQueryParameter(
|
||||
"lon",
|
||||
"" + (layoutToUse?.startLon ?? 0),
|
||||
"The initial/current longitude of the app"
|
||||
).syncWith(LocalStorageSource.Get("lon"))
|
||||
);
|
||||
const zoom = localStorageSynced("z",(layoutToUse?.startZoom ?? 1),"The initial/current zoom level")
|
||||
const lat = localStorageSynced("lat",(layoutToUse?.startLat ?? 0),"The initial/current latitude")
|
||||
const lon = localStorageSynced("lon",(layoutToUse?.startLon ?? 0),"The initial/current longitude of the app")
|
||||
|
||||
|
||||
this.locationControl.setData({
|
||||
zoom: Utils.asFloat(zoom.data),
|
||||
|
@ -80,15 +78,12 @@ export default class ElementsState extends FeatureSwitchState {
|
|||
lon: Utils.asFloat(lon.data),
|
||||
})
|
||||
this.locationControl.addCallback((latlonz) => {
|
||||
// Sync th location controls
|
||||
// Sync the location controls
|
||||
zoom.setData(latlonz.zoom);
|
||||
lat.setData(latlonz.lat);
|
||||
lon.setData(latlonz.lon);
|
||||
});
|
||||
}
|
||||
|
||||
new ChangeToElementsActor(this.changes, this.allElements)
|
||||
new PendingChangesUploader(this.changes, this.selectedElement);
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -12,6 +12,8 @@ import {BBox} from "../BBox";
|
|||
import FeatureInfoBox from "../../UI/Popup/FeatureInfoBox";
|
||||
import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource";
|
||||
import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator";
|
||||
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
|
||||
export default class FeaturePipelineState extends MapState {
|
||||
|
||||
|
@ -21,11 +23,12 @@ export default class FeaturePipelineState extends MapState {
|
|||
public readonly featurePipeline: FeaturePipeline;
|
||||
private readonly featureAggregator: TileHierarchyAggregator;
|
||||
private readonly metatagRecalculator: MetaTagRecalculator
|
||||
|
||||
private readonly popups : Map<string, ScrollableFullScreen> = new Map<string, ScrollableFullScreen>();
|
||||
|
||||
constructor(layoutToUse: LayoutConfig) {
|
||||
super(layoutToUse);
|
||||
|
||||
const clustering = layoutToUse.clustering
|
||||
const clustering = layoutToUse?.clustering
|
||||
this.featureAggregator = TileHierarchyAggregator.createHierarchy(this);
|
||||
const clusterCounter = this.featureAggregator
|
||||
const self = this;
|
||||
|
@ -48,7 +51,8 @@ export default class FeaturePipelineState extends MapState {
|
|||
self.metatagRecalculator.registerSource(source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function registerSource(source: FeatureSourceForLayer & Tiled) {
|
||||
|
||||
clusterCounter.addTile(source)
|
||||
|
@ -117,7 +121,7 @@ export default class FeaturePipelineState extends MapState {
|
|||
doShowLayer: doShowFeatures,
|
||||
selectedElement: self.selectedElement,
|
||||
state: self,
|
||||
popup: (tags, layer) => new FeatureInfoBox(tags, layer, self)
|
||||
popup: (tags, layer) => self.CreatePopup(tags, layer)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -134,6 +138,15 @@ export default class FeaturePipelineState extends MapState {
|
|||
this.AddClusteringToMap(this.leafletMap)
|
||||
|
||||
}
|
||||
|
||||
public CreatePopup(tags:UIEventSource<any> , layer: LayerConfig): ScrollableFullScreen{
|
||||
if(this.popups.has(tags.data.id)){
|
||||
return this.popups.get(tags.data.id)
|
||||
}
|
||||
const popup = new FeatureInfoBox(tags, layer, this)
|
||||
this.popups.set(tags.data.id, popup)
|
||||
return popup
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the cluster-tiles to the given map
|
||||
|
|
|
@ -56,8 +56,9 @@ export default class FeatureSwitchState {
|
|||
);
|
||||
|
||||
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
|
||||
return queryParam.map((str) =>
|
||||
str === undefined ? defaultValue : str !== "false"
|
||||
return queryParam.sync((str) =>
|
||||
str === undefined ? defaultValue : str !== "false", [],
|
||||
b => b == defaultValue ? undefined : (""+b)
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -163,7 +164,7 @@ export default class FeatureSwitchState {
|
|||
this.overpassUrl = QueryParameters.GetQueryParameter("overpassUrl",
|
||||
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
|
||||
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
|
||||
).map(param => param.split(","), [], urls => urls.join(","))
|
||||
).sync(param => param.split(","), [], urls => urls.join(","))
|
||||
|
||||
this.overpassTimeout = UIEventSource.asFloat(QueryParameters.GetQueryParameter("overpassTimeout",
|
||||
"" + layoutToUse?.overpassTimeout,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import UserRelatedState from "./UserRelatedState";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, Stores, UIEventSource} from "../UIEventSource";
|
||||
import BaseLayer from "../../Models/BaseLayer";
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import AvailableBaseLayers from "../Actors/AvailableBaseLayers";
|
||||
|
@ -18,6 +18,20 @@ import {GeoOperations} from "../GeoOperations";
|
|||
import TitleHandler from "../Actors/TitleHandler";
|
||||
import {BBox} from "../BBox";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
import {TiledStaticFeatureSource} from "../FeatureSource/Sources/StaticFeatureSource";
|
||||
import {Translation, TypedTranslation} from "../../UI/i18n/Translation";
|
||||
import {Tag} from "../Tags/Tag";
|
||||
|
||||
|
||||
export interface GlobalFilter {
|
||||
filter: FilterState,
|
||||
id: string,
|
||||
onNewPoint: {
|
||||
safetyCheck: Translation,
|
||||
confirmAddNew: TypedTranslation<{ preset: Translation }>
|
||||
tags: Tag[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains all the leaflet-map related state
|
||||
|
@ -31,7 +45,7 @@ export default class MapState extends UserRelatedState {
|
|||
/**
|
||||
* A list of currently available background layers
|
||||
*/
|
||||
public availableBackgroundLayers: UIEventSource<BaseLayer[]>;
|
||||
public availableBackgroundLayers: Store<BaseLayer[]>;
|
||||
|
||||
/**
|
||||
* The current background layer
|
||||
|
@ -52,12 +66,12 @@ export default class MapState extends UserRelatedState {
|
|||
/**
|
||||
* The location as delivered by the GPS
|
||||
*/
|
||||
public currentUserLocation: FeatureSourceForLayer & Tiled;
|
||||
public currentUserLocation: SimpleFeatureSource;
|
||||
|
||||
/**
|
||||
* All previously visited points
|
||||
*/
|
||||
public historicalUserLocations: FeatureSourceForLayer & Tiled;
|
||||
public historicalUserLocations: SimpleFeatureSource;
|
||||
/**
|
||||
* The number of seconds that the GPS-locations are stored in memory.
|
||||
* Time in seconds
|
||||
|
@ -77,6 +91,12 @@ export default class MapState extends UserRelatedState {
|
|||
* Which layers are enabled in the current theme and what filters are applied onto them
|
||||
*/
|
||||
public filteredLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>([], "filteredLayers");
|
||||
|
||||
/**
|
||||
* Filters which apply onto all layers
|
||||
*/
|
||||
public globalFilters: UIEventSource<GlobalFilter[]> = new UIEventSource([], "globalFilters")
|
||||
|
||||
/**
|
||||
* Which overlays are shown
|
||||
*/
|
||||
|
@ -117,10 +137,12 @@ export default class MapState extends UserRelatedState {
|
|||
})
|
||||
|
||||
|
||||
this.overlayToggles = this.layoutToUse.tileLayerSources.filter(c => c.name !== undefined).map(c => ({
|
||||
config: c,
|
||||
isDisplayed: QueryParameters.GetBooleanQueryParameter("overlay-" + c.id, c.defaultState, "Wether or not the overlay " + c.id + " is shown")
|
||||
}))
|
||||
this.overlayToggles = this.layoutToUse?.tileLayerSources
|
||||
?.filter(c => c.name !== undefined)
|
||||
?.map(c => ({
|
||||
config: c,
|
||||
isDisplayed: QueryParameters.GetBooleanQueryParameter("overlay-" + c.id, c.defaultState, "Wether or not the overlay " + c.id + " is shown")
|
||||
})) ?? []
|
||||
this.filteredLayers = this.InitializeFilteredLayers()
|
||||
|
||||
|
||||
|
@ -142,7 +164,7 @@ export default class MapState extends UserRelatedState {
|
|||
initialized.add(overlayToggle.config)
|
||||
}
|
||||
|
||||
for (const tileLayerSource of this.layoutToUse.tileLayerSources) {
|
||||
for (const tileLayerSource of this.layoutToUse?.tileLayerSources ?? []) {
|
||||
if (initialized.has(tileLayerSource)) {
|
||||
continue
|
||||
}
|
||||
|
@ -153,28 +175,14 @@ export default class MapState extends UserRelatedState {
|
|||
|
||||
private lockBounds() {
|
||||
const layout = this.layoutToUse;
|
||||
if (layout.lockLocation) {
|
||||
if (layout.lockLocation === true) {
|
||||
const tile = Tiles.embedded_tile(
|
||||
layout.startLat,
|
||||
layout.startLon,
|
||||
layout.startZoom - 1
|
||||
);
|
||||
const bounds = Tiles.tile_bounds(tile.z, tile.x, tile.y);
|
||||
// We use the bounds to get a sense of distance for this zoom level
|
||||
const latDiff = bounds[0][0] - bounds[1][0];
|
||||
const lonDiff = bounds[0][1] - bounds[1][1];
|
||||
layout.lockLocation = [
|
||||
[layout.startLat - latDiff, layout.startLon - lonDiff],
|
||||
[layout.startLat + latDiff, layout.startLon + lonDiff],
|
||||
];
|
||||
}
|
||||
console.warn("Locking the bounds to ", layout.lockLocation);
|
||||
this.mainMapObject.installBounds(
|
||||
new BBox(layout.lockLocation),
|
||||
this.featureSwitchIsTesting.data
|
||||
)
|
||||
if (!layout?.lockLocation) {
|
||||
return;
|
||||
}
|
||||
console.warn("Locking the bounds to ", layout.lockLocation);
|
||||
this.mainMapObject.installBounds(
|
||||
new BBox(layout.lockLocation),
|
||||
this.featureSwitchIsTesting.data
|
||||
)
|
||||
}
|
||||
|
||||
private initCurrentView() {
|
||||
|
@ -188,7 +196,7 @@ export default class MapState extends UserRelatedState {
|
|||
|
||||
let i = 0
|
||||
const self = this;
|
||||
const features: UIEventSource<{ feature: any, freshness: Date }[]> = this.currentBounds.map(bounds => {
|
||||
const features: Store<{ feature: any, freshness: Date }[]> = this.currentBounds.map(bounds => {
|
||||
if (bounds === undefined) {
|
||||
return []
|
||||
}
|
||||
|
@ -217,7 +225,7 @@ export default class MapState extends UserRelatedState {
|
|||
return [feature]
|
||||
})
|
||||
|
||||
this.currentView = new SimpleFeatureSource(currentViewLayer, 0, features)
|
||||
this.currentView = new TiledStaticFeatureSource(features, currentViewLayer);
|
||||
}
|
||||
|
||||
private initGpsLocation() {
|
||||
|
@ -271,6 +279,7 @@ export default class MapState extends UserRelatedState {
|
|||
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_location_history")[0]
|
||||
if (gpsLayerDef !== undefined) {
|
||||
this.historicalUserLocations = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0), features);
|
||||
this.changes.setHistoricalUserLocations(this.historicalUserLocations)
|
||||
}
|
||||
|
||||
|
||||
|
@ -300,13 +309,13 @@ export default class MapState extends UserRelatedState {
|
|||
})
|
||||
let gpsLineLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_track")[0]
|
||||
if (gpsLineLayerDef !== undefined) {
|
||||
this.historicalUserLocationsTrack = new SimpleFeatureSource(gpsLineLayerDef, Tiles.tile_index(0, 0, 0), asLine);
|
||||
this.historicalUserLocationsTrack = new TiledStaticFeatureSource(asLine, gpsLineLayerDef);
|
||||
}
|
||||
}
|
||||
|
||||
private initHomeLocation() {
|
||||
const empty = []
|
||||
const feature = UIEventSource.ListStabilized(this.osmConnection.userDetails.map(userDetails => {
|
||||
const feature = Stores.ListStabilized(this.osmConnection.userDetails.map(userDetails => {
|
||||
|
||||
if (userDetails === undefined) {
|
||||
return undefined;
|
||||
|
@ -339,48 +348,47 @@ export default class MapState extends UserRelatedState {
|
|||
|
||||
const flayer = this.filteredLayers.data.filter(l => l.layerDef.id === "home_location")[0]
|
||||
if (flayer !== undefined) {
|
||||
this.homeLocation = new SimpleFeatureSource(flayer, Tiles.tile_index(0, 0, 0), feature)
|
||||
this.homeLocation = new TiledStaticFeatureSource(feature, flayer)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private getPref(key: string, layer: LayerConfig): UIEventSource<boolean> {
|
||||
const pref = this.osmConnection
|
||||
.GetPreference(key)
|
||||
.map(v => {
|
||||
if(v === undefined){
|
||||
return this.osmConnection
|
||||
.GetPreference(key, layer.shownByDefault + "")
|
||||
.sync(v => {
|
||||
if (v === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return v === "true";
|
||||
}, [], b => {
|
||||
if(b === undefined){
|
||||
if (b === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return "" + b;
|
||||
})
|
||||
pref.setData(layer.shownByDefault)
|
||||
return pref
|
||||
}
|
||||
|
||||
private InitializeFilteredLayers() {
|
||||
|
||||
const layoutToUse = this.layoutToUse;
|
||||
if (layoutToUse === undefined) {
|
||||
return new UIEventSource<FilteredLayer[]>([])
|
||||
}
|
||||
const flayers: FilteredLayer[] = [];
|
||||
for (const layer of layoutToUse.layers) {
|
||||
let isDisplayed: UIEventSource<boolean>
|
||||
if (layer.syncSelection === "local") {
|
||||
isDisplayed = LocalStorageSource.GetParsed(layoutToUse.id + "-layer-" + layer.id + "-enabled", layer.shownByDefault)
|
||||
} else if (layer.syncSelection === "theme-only") {
|
||||
isDisplayed = this.getPref(layoutToUse.id+ "-layer-" + layer.id + "-enabled", layer)
|
||||
isDisplayed = this.getPref(layoutToUse.id + "-layer-" + layer.id + "-enabled", layer)
|
||||
} else if (layer.syncSelection === "global") {
|
||||
isDisplayed = this.getPref("layer-" + layer.id + "-enabled", layer)
|
||||
} else {
|
||||
isDisplayed = QueryParameters.GetBooleanQueryParameter("layer-" + layer.id + "-enabled",layer.shownByDefault, "Wether or not layer "+layer.id+" is shown")
|
||||
isDisplayed = QueryParameters.GetBooleanQueryParameter("layer-" + layer.id, layer.shownByDefault, "Wether or not layer " + layer.id + " is shown")
|
||||
}
|
||||
|
||||
|
||||
const flayer: FilteredLayer = {
|
||||
isDisplayed: isDisplayed,
|
||||
isDisplayed,
|
||||
layerDef: layer,
|
||||
appliedFilters: new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>())
|
||||
};
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import {OsmConnection} from "../Osm/OsmConnection";
|
||||
import {MangroveIdentity} from "../Web/MangroveReviews";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store, UIEventSource} from "../UIEventSource";
|
||||
import {QueryParameters} from "../Web/QueryParameters";
|
||||
import {LocalStorageSource} from "../Web/LocalStorageSource";
|
||||
import {Utils} from "../../Utils";
|
||||
import Locale from "../../UI/i18n/Locale";
|
||||
import ElementsState from "./ElementsState";
|
||||
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater";
|
||||
|
||||
import {Changes} from "../Osm/Changes";
|
||||
import ChangeToElementsActor from "../Actors/ChangeToElementsActor";
|
||||
import PendingChangesUploader from "../Actors/PendingChangesUploader";
|
||||
import * as translators from "../../assets/translators.json"
|
||||
import {post} from "jquery";
|
||||
import Maproulette from "../Maproulette";
|
||||
|
||||
/**
|
||||
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
|
||||
* which layers they enabled, ...
|
||||
|
@ -20,23 +26,30 @@ export default class UserRelatedState extends ElementsState {
|
|||
The user credentials
|
||||
*/
|
||||
public osmConnection: OsmConnection;
|
||||
/**
|
||||
THe change handler
|
||||
*/
|
||||
public changes: Changes;
|
||||
/**
|
||||
* The key for mangrove
|
||||
*/
|
||||
public mangroveIdentity: MangroveIdentity;
|
||||
/**
|
||||
* Which layers are enabled in the personal theme
|
||||
*/
|
||||
public favouriteLayers: UIEventSource<string[]>;
|
||||
|
||||
/**
|
||||
* Maproulette connection
|
||||
*/
|
||||
public maprouletteConnection: Maproulette;
|
||||
|
||||
public readonly isTranslator : Store<boolean>;
|
||||
|
||||
public readonly installedUserThemes: Store<string[]>
|
||||
|
||||
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
|
||||
super(layoutToUse);
|
||||
|
||||
this.osmConnection = new OsmConnection({
|
||||
changes: this.changes,
|
||||
dryRun: this.featureSwitchIsTesting,
|
||||
fakeUser: this.featureSwitchFakeUser.data,
|
||||
allElements: this.allElements,
|
||||
oauth_token: QueryParameters.GetQueryParameter(
|
||||
"oauth_token",
|
||||
undefined,
|
||||
|
@ -45,11 +58,36 @@ export default class UserRelatedState extends ElementsState {
|
|||
osmConfiguration: <'osm' | 'osm-test'>this.featureSwitchApiURL.data,
|
||||
attemptLogin: options?.attemptLogin
|
||||
})
|
||||
const translationMode = this.osmConnection.GetPreference("translation-mode").sync(str => str === undefined ? undefined : str === "true", [], b => b === undefined ? undefined : b+"")
|
||||
|
||||
translationMode.syncWith(Locale.showLinkToWeblate)
|
||||
|
||||
this.isTranslator = this.osmConnection.userDetails.map(ud => {
|
||||
if(!ud.loggedIn){
|
||||
return false;
|
||||
}
|
||||
const name= ud.name.toLowerCase().replace(/\s+/g, '')
|
||||
return translators.contributors.some(c => c.contributor.toLowerCase().replace(/\s+/g, '') === name)
|
||||
})
|
||||
|
||||
this.isTranslator.addCallbackAndRunD(ud => {
|
||||
if(ud){
|
||||
Locale.showLinkToWeblate.setData(true)
|
||||
}
|
||||
});
|
||||
|
||||
this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false)
|
||||
|
||||
|
||||
new ChangeToElementsActor(this.changes, this.allElements)
|
||||
new PendingChangesUploader(this.changes, this.selectedElement);
|
||||
|
||||
this.mangroveIdentity = new MangroveIdentity(
|
||||
this.osmConnection.GetLongPreference("identity", "mangrove")
|
||||
);
|
||||
|
||||
this.maprouletteConnection = new Maproulette();
|
||||
|
||||
if (layoutToUse?.hideFromOverview) {
|
||||
this.osmConnection.isLoggedIn.addCallbackAndRunD(loggedIn => {
|
||||
if (loggedIn) {
|
||||
|
@ -73,18 +111,9 @@ export default class UserRelatedState extends ElementsState {
|
|||
}))
|
||||
}
|
||||
|
||||
|
||||
// Important: the favourite layers are initialized _after_ the installed themes, as these might contain an installedTheme
|
||||
this.favouriteLayers = LocalStorageSource.Get("favouriteLayers")
|
||||
.syncWith(this.osmConnection.GetLongPreference("favouriteLayers"))
|
||||
.map(
|
||||
(str) => Utils.Dedup(str?.split(";")) ?? [],
|
||||
[],
|
||||
(layers) => Utils.Dedup(layers)?.join(";")
|
||||
);
|
||||
|
||||
this.InitializeLanguage();
|
||||
new SelectedElementTagsUpdater(this)
|
||||
this.installedUserThemes = this.InitInstalledUserThemes();
|
||||
|
||||
}
|
||||
|
||||
|
@ -96,6 +125,9 @@ export default class UserRelatedState extends ElementsState {
|
|||
if (layoutToUse === undefined) {
|
||||
return;
|
||||
}
|
||||
if(Locale.showLinkToWeblate.data){
|
||||
return true; // Disable auto switching as we are in translators mode
|
||||
}
|
||||
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
|
||||
console.log(
|
||||
"Resetting language to",
|
||||
|
@ -108,7 +140,53 @@ export default class UserRelatedState extends ElementsState {
|
|||
Locale.language.setData(layoutToUse.language[0]);
|
||||
}
|
||||
})
|
||||
.ping();
|
||||
Locale.language.ping();
|
||||
}
|
||||
|
||||
private InitInstalledUserThemes(): Store<string[]>{
|
||||
const prefix = "mapcomplete-unofficial-theme-";
|
||||
const postfix = "-combined-length"
|
||||
return this.osmConnection.preferencesHandler.preferences.map(prefs =>
|
||||
Object.keys(prefs)
|
||||
.filter(k => k.startsWith(prefix) && k.endsWith(postfix))
|
||||
.map(k => k.substring(prefix.length, k.length - postfix.length))
|
||||
)
|
||||
}
|
||||
|
||||
public GetUnofficialTheme(id: string): {
|
||||
id: string
|
||||
icon: string,
|
||||
title: any,
|
||||
shortDescription: any,
|
||||
definition?: any,
|
||||
isOfficial: boolean
|
||||
} | undefined {
|
||||
console.log("GETTING UNOFFICIAL THEME")
|
||||
const pref = this.osmConnection.GetLongPreference("unofficial-theme-"+id)
|
||||
const str = pref.data
|
||||
|
||||
if (str === undefined || str === "undefined" || str === "") {
|
||||
pref.setData(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const value: {
|
||||
id: string
|
||||
icon: string,
|
||||
title: any,
|
||||
shortDescription: any,
|
||||
definition?: any,
|
||||
isOfficial: boolean
|
||||
} = JSON.parse(str)
|
||||
value.isOfficial = false
|
||||
return value;
|
||||
} catch (e) {
|
||||
console.warn("Removing theme " + id + " as it could not be parsed from the preferences; the content is:", str)
|
||||
pref.setData(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,11 @@
|
|||
import {TagsFilter} from "./TagsFilter";
|
||||
import {Or} from "./Or";
|
||||
import {TagUtils} from "./TagUtils";
|
||||
import {Tag} from "./Tag";
|
||||
import {RegexTag} from "./RegexTag";
|
||||
|
||||
export class And extends TagsFilter {
|
||||
|
||||
public and: TagsFilter[]
|
||||
|
||||
constructor(and: TagsFilter[]) {
|
||||
|
@ -8,6 +13,13 @@ export class And extends TagsFilter {
|
|||
this.and = and
|
||||
}
|
||||
|
||||
public static construct(and: TagsFilter[]): TagsFilter {
|
||||
if (and.length === 1) {
|
||||
return and[0]
|
||||
}
|
||||
return new And(and)
|
||||
}
|
||||
|
||||
private static combine(filter: string, choices: string[]): string[] {
|
||||
const values = [];
|
||||
for (const or of choices) {
|
||||
|
@ -38,6 +50,11 @@ export class And extends TagsFilter {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)])
|
||||
* and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ]
|
||||
*/
|
||||
asOverpass(): string[] {
|
||||
let allChoices: string[] = null;
|
||||
for (const andElement of this.and) {
|
||||
|
@ -71,7 +88,24 @@ export class And extends TagsFilter {
|
|||
return true;
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
/**
|
||||
* const t0 = new And([
|
||||
* new Tag("valves:special", "A"),
|
||||
* new Tag("valves", "A")
|
||||
* ])
|
||||
* const t1 = new And([new Tag("valves", "A")])
|
||||
* const t2 = new And([new Tag("valves", "B")])
|
||||
* t0.shadows(t0) // => true
|
||||
* t1.shadows(t1) // => true
|
||||
* t2.shadows(t2) // => true
|
||||
* t0.shadows(t1) // => false
|
||||
* t0.shadows(t2) // => false
|
||||
* t1.shadows(t0) // => false
|
||||
* t1.shadows(t2) // => false
|
||||
* t2.shadows(t0) // => false
|
||||
* t2.shadows(t1) // => false
|
||||
*/
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if (!(other instanceof And)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -79,7 +113,7 @@ export class And extends TagsFilter {
|
|||
for (const selfTag of this.and) {
|
||||
let matchFound = false;
|
||||
for (const otherTag of other.and) {
|
||||
matchFound = selfTag.isEquivalent(otherTag);
|
||||
matchFound = selfTag.shadows(otherTag);
|
||||
if (matchFound) {
|
||||
break;
|
||||
}
|
||||
|
@ -92,7 +126,7 @@ export class And extends TagsFilter {
|
|||
for (const otherTag of other.and) {
|
||||
let matchFound = false;
|
||||
for (const selfTag of this.and) {
|
||||
matchFound = selfTag.isEquivalent(otherTag);
|
||||
matchFound = selfTag.shadows(otherTag);
|
||||
if (matchFound) {
|
||||
break;
|
||||
}
|
||||
|
@ -110,6 +144,10 @@ export class And extends TagsFilter {
|
|||
return [].concat(...this.and.map(subkeys => subkeys.usedKeys()));
|
||||
}
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
return [].concat(...this.and.map(subkeys => subkeys.usedTags()));
|
||||
}
|
||||
|
||||
asChange(properties: any): { k: string; v: string }[] {
|
||||
const result = []
|
||||
for (const tagsFilter of this.and) {
|
||||
|
@ -118,9 +156,227 @@ export class And extends TagsFilter {
|
|||
return result;
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return {
|
||||
and: this.and.map(a => a.AsJson())
|
||||
/**
|
||||
* IN some contexts, some expressions can be considered true, e.g.
|
||||
* (X=Y | (A=B & X=Y))
|
||||
* ^---------^
|
||||
* When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise.
|
||||
* This means that the entire 'AND' is considered FALSE
|
||||
*
|
||||
* new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value")
|
||||
* new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false
|
||||
* new And([ new RegexTag("key",/^..*$/) ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value")
|
||||
* new And([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true
|
||||
*
|
||||
* // should remove 'club~*' if we know that 'club=climbing'
|
||||
* const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} )
|
||||
* expr.removePhraseConsideredKnown(new Tag("club","climbing"), true) // => new Tag("sport","climbing")
|
||||
*
|
||||
* const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} )
|
||||
* expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr
|
||||
*/
|
||||
removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean {
|
||||
const newAnds: TagsFilter[] = []
|
||||
for (const tag of this.and) {
|
||||
if (tag instanceof And) {
|
||||
throw "Optimize expressions before using removePhraseConsideredKnown"
|
||||
}
|
||||
if (tag instanceof Or) {
|
||||
const r = tag.removePhraseConsideredKnown(knownExpression, value)
|
||||
if (r === true) {
|
||||
continue
|
||||
}
|
||||
if (r === false) {
|
||||
return false;
|
||||
}
|
||||
newAnds.push(r)
|
||||
continue
|
||||
}
|
||||
if (value && knownExpression.shadows(tag)) {
|
||||
/**
|
||||
* At this point, we do know that 'knownExpression' is true in every case
|
||||
* As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true,
|
||||
* we can be sure that 'tag' is true as well.
|
||||
*
|
||||
* "True" is the neutral element in an AND, so we can skip the tag
|
||||
*/
|
||||
continue
|
||||
}
|
||||
if (!value && tag.shadows(knownExpression)) {
|
||||
|
||||
/**
|
||||
* We know that knownExpression is unmet.
|
||||
* if the tag shadows 'knownExpression' (which is the case when control flows gets here),
|
||||
* then tag CANNOT be met too, as known expression is not met.
|
||||
*
|
||||
* This implies that 'tag' must be false too!
|
||||
*/
|
||||
|
||||
// false is the element which absorbs all
|
||||
return false
|
||||
}
|
||||
|
||||
newAnds.push(tag)
|
||||
}
|
||||
if (newAnds.length === 0) {
|
||||
return true
|
||||
}
|
||||
return And.construct(newAnds)
|
||||
}
|
||||
|
||||
optimize(): TagsFilter | boolean {
|
||||
if (this.and.length === 0) {
|
||||
return true
|
||||
}
|
||||
const optimizedRaw = this.and.map(t => t.optimize())
|
||||
.filter(t => t !== true /* true is the neutral element in an AND, we drop them*/)
|
||||
if (optimizedRaw.some(t => t === false)) {
|
||||
// We have an AND with a contained false: this is always 'false'
|
||||
return false;
|
||||
}
|
||||
const optimized = <TagsFilter[]>optimizedRaw;
|
||||
|
||||
{
|
||||
// Conflicting keys do return false
|
||||
const properties: object = {}
|
||||
for (const opt of optimized) {
|
||||
if (opt instanceof Tag) {
|
||||
properties[opt.key] = opt.value
|
||||
}
|
||||
}
|
||||
for (const opt of optimized) {
|
||||
if(opt instanceof Tag ){
|
||||
const k = opt.key
|
||||
const v = properties[k]
|
||||
if(v === undefined){
|
||||
continue
|
||||
}
|
||||
if(v !== opt.value){
|
||||
// detected an internal conflict
|
||||
return false
|
||||
}
|
||||
}
|
||||
if(opt instanceof RegexTag ){
|
||||
const k = opt.key
|
||||
if(typeof k !== "string"){
|
||||
continue
|
||||
}
|
||||
const v = properties[k]
|
||||
if(v === undefined){
|
||||
continue
|
||||
}
|
||||
if(v !== opt.value){
|
||||
// detected an internal conflict
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newAnds: TagsFilter[] = []
|
||||
|
||||
let containedOrs: Or[] = []
|
||||
for (const tf of optimized) {
|
||||
if (tf instanceof And) {
|
||||
newAnds.push(...tf.and)
|
||||
} else if (tf instanceof Or) {
|
||||
containedOrs.push(tf)
|
||||
} else {
|
||||
newAnds.push(tf)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let dirty = false;
|
||||
do {
|
||||
const cleanedContainedOrs: Or[] = []
|
||||
outer: for (let containedOr of containedOrs) {
|
||||
for (const known of newAnds) {
|
||||
// input for optimazation: (K=V & (X=Y | K=V))
|
||||
// containedOr: (X=Y | K=V)
|
||||
// newAnds (and thus known): (K=V) --> true
|
||||
const cleaned = containedOr.removePhraseConsideredKnown(known, true)
|
||||
if (cleaned === true) {
|
||||
// The neutral element within an AND
|
||||
continue outer // skip addition too
|
||||
}
|
||||
if (cleaned === false) {
|
||||
// zero element
|
||||
return false
|
||||
}
|
||||
if (cleaned instanceof Or) {
|
||||
containedOr = cleaned
|
||||
continue
|
||||
}
|
||||
// the 'or' dissolved into a normal tag -> it has to be added to the newAnds
|
||||
newAnds.push(cleaned)
|
||||
dirty = true; // rerun this algo later on
|
||||
continue outer;
|
||||
}
|
||||
cleanedContainedOrs.push(containedOr)
|
||||
}
|
||||
containedOrs = cleanedContainedOrs
|
||||
} while (dirty)
|
||||
}
|
||||
|
||||
|
||||
containedOrs = containedOrs.filter(ca => {
|
||||
const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or)
|
||||
// If 'isShadowed', then at least one part of the 'OR' is matched by the outer and, so this means that this OR isn't needed at all
|
||||
// XY & (XY | AB) === XY
|
||||
return !isShadowed;
|
||||
})
|
||||
|
||||
// Extract common keys from the OR
|
||||
if (containedOrs.length === 1) {
|
||||
newAnds.push(containedOrs[0])
|
||||
} else if (containedOrs.length > 1) {
|
||||
let commonValues: TagsFilter [] = containedOrs[0].or
|
||||
for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++) {
|
||||
const containedOr = containedOrs[i];
|
||||
commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv)))
|
||||
}
|
||||
if (commonValues.length === 0) {
|
||||
newAnds.push(...containedOrs)
|
||||
} else {
|
||||
const newOrs: TagsFilter[] = []
|
||||
for (const containedOr of containedOrs) {
|
||||
const elements = containedOr.or
|
||||
.filter(candidate => !commonValues.some(cv => cv.shadows(candidate)))
|
||||
newOrs.push(Or.construct(elements))
|
||||
}
|
||||
|
||||
commonValues.push(And.construct(newOrs))
|
||||
const result = new Or(commonValues).optimize()
|
||||
if (result === false) {
|
||||
return false
|
||||
} else if (result === true) {
|
||||
// neutral element: skip
|
||||
} else {
|
||||
newAnds.push(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newAnds.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (TagUtils.ContainsOppositeTags(newAnds)) {
|
||||
return false
|
||||
}
|
||||
|
||||
TagUtils.sortFilters(newAnds, true)
|
||||
|
||||
return And.construct(newAnds)
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return !this.and.some(t => !t.isNegative());
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter: any) => void) {
|
||||
f(this)
|
||||
this.and.forEach(sub => sub.visit(f))
|
||||
}
|
||||
|
||||
}
|
|
@ -23,7 +23,7 @@ export default class ComparingTag implements TagsFilter {
|
|||
throw "A comparable tag can not be used as overpass filter"
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
shadows(other: TagsFilter): boolean {
|
||||
return other === this;
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,15 @@ export default class ComparingTag implements TagsFilter {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the properties match
|
||||
*
|
||||
* const t = new ComparingTag("key", (x => Number(x) < 42))
|
||||
* t.matchesProperties({key: 42}) // => false
|
||||
* t.matchesProperties({key: 41}) // => true
|
||||
* t.matchesProperties({key: 0}) // => true
|
||||
* t.matchesProperties({differentKey: 42}) // => false
|
||||
*/
|
||||
matchesProperties(properties: any): boolean {
|
||||
return this._predicate(properties[this._key]);
|
||||
}
|
||||
|
@ -38,9 +47,20 @@ export default class ComparingTag implements TagsFilter {
|
|||
usedKeys(): string[] {
|
||||
return [this._key];
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return this.asHumanString(false, false, {})
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
optimize(): TagsFilter | boolean {
|
||||
return this;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter) => void) {
|
||||
f(this)
|
||||
}
|
||||
}
|
205
Logic/Tags/Or.ts
205
Logic/Tags/Or.ts
|
@ -1,4 +1,6 @@
|
|||
import {TagsFilter} from "./TagsFilter";
|
||||
import {TagUtils} from "./TagUtils";
|
||||
import {And} from "./And";
|
||||
|
||||
|
||||
export class Or extends TagsFilter {
|
||||
|
@ -9,6 +11,14 @@ export class Or extends TagsFilter {
|
|||
this.or = or;
|
||||
}
|
||||
|
||||
public static construct(or: TagsFilter[]): TagsFilter{
|
||||
if(or.length === 1){
|
||||
return or[0]
|
||||
}
|
||||
return new Or(or)
|
||||
}
|
||||
|
||||
|
||||
matchesProperties(properties: any): boolean {
|
||||
for (const tagsFilter of this.or) {
|
||||
if (tagsFilter.matchesProperties(properties)) {
|
||||
|
@ -19,6 +29,19 @@ export class Or extends TagsFilter {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* import {Tag} from "./Tag";
|
||||
* import {RegexTag} from "./RegexTag";
|
||||
*
|
||||
* const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)])
|
||||
* const or = new Or([and, new Tag("leisure", "nature_reserve"])
|
||||
* or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]", "[\"leisure\"=\"nature_reserve\"]" ]
|
||||
*
|
||||
* // should fuse nested ors into a single list
|
||||
* const or = new Or([new Tag("key","value"), new Or([new Tag("key1","value1"), new Tag("key2","value2")])])
|
||||
* or.asOverpass() // => [ `["key"="value"]`, `["key1"="value1"]`, `["key2"="value2"]` ]
|
||||
*/
|
||||
asOverpass(): string[] {
|
||||
const choices = [];
|
||||
for (const tagsFilter of this.or) {
|
||||
|
@ -36,14 +59,14 @@ export class Or extends TagsFilter {
|
|||
return false;
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if (other instanceof Or) {
|
||||
|
||||
for (const selfTag of this.or) {
|
||||
let matchFound = false;
|
||||
for (let i = 0; i < other.or.length && !matchFound; i++) {
|
||||
let otherTag = other.or[i];
|
||||
matchFound = selfTag.isEquivalent(otherTag);
|
||||
matchFound = selfTag.shadows(otherTag);
|
||||
}
|
||||
if (!matchFound) {
|
||||
return false;
|
||||
|
@ -58,6 +81,10 @@ export class Or extends TagsFilter {
|
|||
return [].concat(...this.or.map(subkeys => subkeys.usedKeys()));
|
||||
}
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
return [].concat(...this.or.map(subkeys => subkeys.usedTags()));
|
||||
}
|
||||
|
||||
asChange(properties: any): { k: string; v: string }[] {
|
||||
const result = []
|
||||
for (const tagsFilter of this.or) {
|
||||
|
@ -66,11 +93,179 @@ export class Or extends TagsFilter {
|
|||
return result;
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return {
|
||||
or: this.or.map(o => o.AsJson())
|
||||
/**
|
||||
* IN some contexts, some expressions can be considered true, e.g.
|
||||
* (X=Y & (A=B | X=Y))
|
||||
* ^---------^
|
||||
* When the evaluation hits (A=B | X=Y), we know _for sure_ that X=Y _does match, as it would have failed the first clause otherwise.
|
||||
* This means we can safely ignore this in the OR
|
||||
*
|
||||
* new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // =>true
|
||||
* new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => new Tag("other_key","value")
|
||||
* new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true
|
||||
* new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false
|
||||
* new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]).removePhraseConsideredKnown(new Tag("foo","bar"), false) // => new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")])
|
||||
*/
|
||||
removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean {
|
||||
const newOrs: TagsFilter[] = []
|
||||
for (const tag of this.or) {
|
||||
if(tag instanceof Or){
|
||||
throw "Optimize expressions before using removePhraseConsideredKnown"
|
||||
}
|
||||
if(tag instanceof And){
|
||||
const r = tag.removePhraseConsideredKnown(knownExpression, value)
|
||||
if(r === false){
|
||||
continue
|
||||
}
|
||||
if(r === true){
|
||||
return true;
|
||||
}
|
||||
newOrs.push(r)
|
||||
continue
|
||||
}
|
||||
if(value && knownExpression.shadows(tag)){
|
||||
/**
|
||||
* At this point, we do know that 'knownExpression' is true in every case
|
||||
* As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true,
|
||||
* we can be sure that 'tag' is true as well.
|
||||
*
|
||||
* "True" is the absorbing element in an OR, so we can return true
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
if(!value && tag.shadows(knownExpression)){
|
||||
|
||||
/**
|
||||
* We know that knownExpression is unmet.
|
||||
* if the tag shadows 'knownExpression' (which is the case when control flows gets here),
|
||||
* then tag CANNOT be met too, as known expression is not met.
|
||||
*
|
||||
* This implies that 'tag' must be false too!
|
||||
* false is the neutral element in an OR
|
||||
*/
|
||||
continue
|
||||
}
|
||||
newOrs.push(tag)
|
||||
}
|
||||
if(newOrs.length === 0){
|
||||
return false
|
||||
}
|
||||
return Or.construct(newOrs)
|
||||
}
|
||||
|
||||
optimize(): TagsFilter | boolean {
|
||||
|
||||
if(this.or.length === 0){
|
||||
return false;
|
||||
}
|
||||
|
||||
const optimizedRaw = this.or.map(t => t.optimize())
|
||||
.filter(t => t !== false /* false is the neutral element in an OR, we drop them*/ )
|
||||
if(optimizedRaw.some(t => t === true)){
|
||||
// We have an OR with a contained true: this is always 'true'
|
||||
return true;
|
||||
}
|
||||
const optimized = <TagsFilter[]> optimizedRaw;
|
||||
|
||||
|
||||
const newOrs : TagsFilter[] = []
|
||||
let containedAnds : And[] = []
|
||||
for (const tf of optimized) {
|
||||
if(tf instanceof Or){
|
||||
// expand all the nested ors...
|
||||
newOrs.push(...tf.or)
|
||||
}else if(tf instanceof And){
|
||||
// partition of all the ands
|
||||
containedAnds.push(tf)
|
||||
} else {
|
||||
newOrs.push(tf)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let dirty = false;
|
||||
do {
|
||||
const cleanedContainedANds : And[] = []
|
||||
outer: for (let containedAnd of containedAnds) {
|
||||
for (const known of newOrs) {
|
||||
// input for optimazation: (K=V | (X=Y & K=V))
|
||||
// containedAnd: (X=Y & K=V)
|
||||
// newOrs (and thus known): (K=V) --> false
|
||||
const cleaned = containedAnd.removePhraseConsideredKnown(known, false)
|
||||
if (cleaned === false) {
|
||||
// The neutral element within an OR
|
||||
continue outer // skip addition too
|
||||
}
|
||||
if (cleaned === true) {
|
||||
// zero element
|
||||
return true
|
||||
}
|
||||
if (cleaned instanceof And) {
|
||||
containedAnd = cleaned
|
||||
continue // clean up with the other known values
|
||||
}
|
||||
// the 'and' dissolved into a normal tag -> it has to be added to the newOrs
|
||||
newOrs.push(cleaned)
|
||||
dirty = true; // rerun this algo later on
|
||||
continue outer;
|
||||
}
|
||||
cleanedContainedANds.push(containedAnd)
|
||||
}
|
||||
containedAnds = cleanedContainedANds
|
||||
} while(dirty)
|
||||
}
|
||||
// Extract common keys from the ANDS
|
||||
if(containedAnds.length === 1){
|
||||
newOrs.push(containedAnds[0])
|
||||
} else if(containedAnds.length > 1){
|
||||
let commonValues : TagsFilter [] = containedAnds[0].and
|
||||
for (let i = 1; i < containedAnds.length && commonValues.length > 0; i++){
|
||||
const containedAnd = containedAnds[i];
|
||||
commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.shadows(cv)))
|
||||
}
|
||||
if(commonValues.length === 0){
|
||||
newOrs.push(...containedAnds)
|
||||
}else{
|
||||
const newAnds: TagsFilter[] = []
|
||||
for (const containedAnd of containedAnds) {
|
||||
const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.shadows(candidate)))
|
||||
newAnds.push(And.construct(elements))
|
||||
}
|
||||
|
||||
commonValues.push(Or.construct(newAnds))
|
||||
const result = new And(commonValues).optimize()
|
||||
if(result === true){
|
||||
return true
|
||||
}else if(result === false){
|
||||
// neutral element: skip
|
||||
}else{
|
||||
newOrs.push(And.construct(commonValues))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(newOrs.length === 0){
|
||||
return false
|
||||
}
|
||||
|
||||
if(TagUtils.ContainsOppositeTags(newOrs)){
|
||||
return true
|
||||
}
|
||||
|
||||
TagUtils.sortFilters(newOrs, false)
|
||||
|
||||
return Or.construct(newOrs)
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.or.some(t => t.isNegative());
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter: any) => void) {
|
||||
f(this)
|
||||
this.or.forEach(t => t.visit(f))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -10,13 +10,6 @@ export class RegexTag extends TagsFilter {
|
|||
constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) {
|
||||
super();
|
||||
this.key = key;
|
||||
if (typeof value === "string") {
|
||||
if (value.indexOf("^") < 0 && value.indexOf("$") < 0) {
|
||||
value = "^" + value + "$"
|
||||
}
|
||||
value = new RegExp(value)
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
this.invert = invert;
|
||||
this.matchesEmpty = RegexTag.doesMatch("", this.value);
|
||||
|
@ -42,17 +35,93 @@ export class RegexTag extends TagsFilter {
|
|||
return r.source;
|
||||
}
|
||||
|
||||
/**
|
||||
* new RegexTag("a", /^[xyz]$/).asOverpass() // => [ `["a"~"^[xyz]$"]` ]
|
||||
*
|
||||
* // A wildcard regextag should only give the key
|
||||
* new RegexTag("a", /^..*$/).asOverpass() // => [ `["a"]` ]
|
||||
*
|
||||
* // A regextag with a regex key should give correct output
|
||||
* new RegexTag(/a.*x/, /^..*$/).asOverpass() // => [ `[~"a.*x"~\"^..*$\"]` ]
|
||||
*
|
||||
* // A regextag with a case invariant flag should signal this to overpass
|
||||
* new RegexTag("key", /^.*value.*$/i).asOverpass() // => [ `["key"~\"^.*value.*$\",i]` ]
|
||||
*/
|
||||
asOverpass(): string[] {
|
||||
if (typeof this.key === "string") {
|
||||
return [`["${this.key}"${this.invert ? "!" : ""}~"${RegexTag.source(this.value)}"]`];
|
||||
const inv =this.invert ? "!" : ""
|
||||
if (typeof this.key !== "string") {
|
||||
// The key is a regex too
|
||||
return [`[~"${this.key.source}"${inv}~"${RegexTag.source(this.value)}"]`];
|
||||
}
|
||||
return [`[~"${this.key.source}"${this.invert ? "!" : ""}~"${RegexTag.source(this.value)}"]`];
|
||||
|
||||
if(this.value instanceof RegExp){
|
||||
const src =this.value.source
|
||||
if(src === "^..*$"){
|
||||
// anything goes
|
||||
return [`[${inv}"${this.key}"]`]
|
||||
}
|
||||
const modifier = this.value.ignoreCase ? ",i" : ""
|
||||
return [`["${this.key}"${inv}~"${src}"${modifier}]`]
|
||||
}else{
|
||||
// Normal key and normal value
|
||||
return [`["${this.key}"${inv}="${this.value}"]`];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
isUsableAsAnswer(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this tag matches the given properties
|
||||
*
|
||||
* const isNotEmpty = new RegexTag("key",/^$/, true);
|
||||
* isNotEmpty.matchesProperties({"key": "value"}) // => true
|
||||
* isNotEmpty.matchesProperties({"key": "other_value"}) // => true
|
||||
* isNotEmpty.matchesProperties({"key": ""}) // => false
|
||||
* isNotEmpty.matchesProperties({"other_key": ""}) // => false
|
||||
* isNotEmpty.matchesProperties({"other_key": "value"}) // => false
|
||||
*
|
||||
* const isNotEmpty = new RegexTag("key",/^..*$/, true);
|
||||
* isNotEmpty.matchesProperties({"key": "value"}) // => false
|
||||
* isNotEmpty.matchesProperties({"key": "other_value"}) // => false
|
||||
* isNotEmpty.matchesProperties({"key": ""}) // => true
|
||||
* isNotEmpty.matchesProperties({"other_key": ""}) // => true
|
||||
* isNotEmpty.matchesProperties({"other_key": "value"}) // => true
|
||||
*
|
||||
* const notRegex = new RegexTag("x", /^y$/, true)
|
||||
* notRegex.matchesProperties({"x": "y"}) // => false
|
||||
* notRegex.matchesProperties({"x": "z"}) // => true
|
||||
* notRegex.matchesProperties({"x": ""}) // => true
|
||||
* notRegex.matchesProperties({}) // => true
|
||||
*
|
||||
* const bicycleTubeRegex = new RegexTag("vending", /^.*bicycle_tube.*$/)
|
||||
* bicycleTubeRegex.matchesProperties({"vending": "bicycle_tube"}) // => true
|
||||
* bicycleTubeRegex.matchesProperties({"vending": "something;bicycle_tube"}) // => true
|
||||
* bicycleTubeRegex.matchesProperties({"vending": "bicycle_tube;something"}) // => true
|
||||
* bicycleTubeRegex.matchesProperties({"vending": "xyz;bicycle_tube;something"}) // => true
|
||||
*
|
||||
* const nameStartsWith = new RegexTag("name", /^[sS]peelbox.*$/)
|
||||
* nameStartsWith.matchesProperties({"name": "Speelbos Sint-Anna"} => true
|
||||
* nameStartsWith.matchesProperties({"name": "speelbos Sint-Anna"} => true
|
||||
* nameStartsWith.matchesProperties({"name": "Sint-Anna"} => false
|
||||
* nameStartsWith.matchesProperties({"name": ""} => false
|
||||
*
|
||||
* const notEmptyList = new RegexTag("xyz", /^\[\]$/, true)
|
||||
* notEmptyList.matchesProperties({"xyz": undefined}) // => true
|
||||
* notEmptyList.matchesProperties({"xyz": "[]"}) // => false
|
||||
* notEmptyList.matchesProperties({"xyz": "[\"abc\"]"}) // => true
|
||||
*
|
||||
* const importMatch = new RegexTag("tags", /(^|.*;)amenity=public_bookcase($|;.*)/)
|
||||
* importMatch.matchesProperties({"tags": "amenity=public_bookcase;name=test"}) // =>true
|
||||
* importMatch.matchesProperties({"tags": "amenity=public_bookcase"}) // =>true
|
||||
* importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}) // =>true
|
||||
* importMatch.matchesProperties({"tags": "amenity=bench"}) // =>false
|
||||
*
|
||||
* new RegexTag("key","value").matchesProperties({"otherkey":"value"}) // => false
|
||||
* new RegexTag("key","value",true).matchesProperties({"otherkey":"something"}) // => true
|
||||
*/
|
||||
matchesProperties(tags: any): boolean {
|
||||
if (typeof this.key === "string") {
|
||||
const value = tags[this.key] ?? ""
|
||||
|
@ -78,17 +147,87 @@ export class RegexTag extends TagsFilter {
|
|||
|
||||
asHumanString() {
|
||||
if (typeof this.key === "string") {
|
||||
return `${this.key}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`;
|
||||
const oper = typeof this.value === "string" ? "=" : "~"
|
||||
return `${this.key}${this.invert ? "!" : ""}${oper}${RegexTag.source(this.value)}`;
|
||||
}
|
||||
return `${this.key.source}${this.invert ? "!" : ""}~~${RegexTag.source(this.value)}`
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
/**
|
||||
*
|
||||
* new RegexTag("key","value").shadows(new Tag("key","value")) // => true
|
||||
* new RegexTag("key",/value/).shadows(new RegexTag("key","value")) // => true
|
||||
* new RegexTag("key",/^..*$/).shadows(new Tag("key","value")) // => false
|
||||
* new RegexTag("key",/^..*$/).shadows(new Tag("other_key","value")) // => false
|
||||
* new RegexTag("key", /^a+$/).shadows(new Tag("key", "a")) // => false
|
||||
*
|
||||
*
|
||||
* // should not shadow too eagerly: the first tag might match 'key=abc', the second won't
|
||||
* new RegexTag("key", /^..*$/).shadows(new Tag("key", "some_value")) // => false
|
||||
*
|
||||
* // should handle 'invert'
|
||||
* new RegexTag("key",/^..*$/, true).shadows(new Tag("key","value")) // => false
|
||||
* new RegexTag("key",/^..*$/, true).shadows(new Tag("key","")) // => true
|
||||
* new RegexTag("key","value", true).shadows(new Tag("key","value")) // => false
|
||||
* new RegexTag("key","value", true).shadows(new Tag("key","some_other_value")) // => false
|
||||
*/
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if (other instanceof RegexTag) {
|
||||
return other.asHumanString() == this.asHumanString();
|
||||
if((other.key["source"] ?? other.key) !== (this.key["source"] ?? this.key) ){
|
||||
// Keys don't match, never shadowing
|
||||
return false
|
||||
}
|
||||
if((other.value["source"] ?? other.key) === (this.value["source"] ?? this.key) && this.invert == other.invert ){
|
||||
// Values (and inverts) match
|
||||
return true
|
||||
}
|
||||
if(typeof other.value ==="string"){
|
||||
const valuesMatch = RegexTag.doesMatch(other.value, this.value)
|
||||
if(!this.invert && !other.invert){
|
||||
// this: key~value, other: key=value
|
||||
return valuesMatch
|
||||
}
|
||||
if(this.invert && !other.invert){
|
||||
// this: key!~value, other: key=value
|
||||
return !valuesMatch
|
||||
}
|
||||
if(!this.invert && other.invert){
|
||||
// this: key~value, other: key!=value
|
||||
return !valuesMatch
|
||||
}
|
||||
if(!this.invert && !other.invert){
|
||||
// this: key!~value, other: key!=value
|
||||
return valuesMatch
|
||||
}
|
||||
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (other instanceof Tag) {
|
||||
return RegexTag.doesMatch(other.key, this.key) && RegexTag.doesMatch(other.value, this.value);
|
||||
if(!RegexTag.doesMatch(other.key, this.key)){
|
||||
// Keys don't match
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if(this.value["source"] === "^..*$") {
|
||||
if(this.invert){
|
||||
return other.value === ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.invert) {
|
||||
/*
|
||||
* this: "a!=b"
|
||||
* other: "a=c"
|
||||
* actual property: a=x
|
||||
* In other words: shadowing will never occur here
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
// Unless the values are the same, it is pretty hard to figure out if they are shadowing. This is future work
|
||||
return (this.value["source"] ?? this.value) === other.value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -99,6 +238,10 @@ export class RegexTag extends TagsFilter {
|
|||
}
|
||||
throw "Key cannot be determined as it is a regex"
|
||||
}
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
asChange(properties: any): { k: string; v: string }[] {
|
||||
if (this.invert) {
|
||||
|
@ -117,7 +260,15 @@ export class RegexTag extends TagsFilter {
|
|||
return []
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return this.asHumanString()
|
||||
optimize(): TagsFilter | boolean {
|
||||
return this;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.invert;
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter) => void) {
|
||||
f(this)
|
||||
}
|
||||
}
|
|
@ -35,7 +35,7 @@ export default class SubstitutingTag implements TagsFilter {
|
|||
throw "A variable with substitution can not be used to query overpass"
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if (!(other instanceof SubstitutingTag)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -46,6 +46,14 @@ export default class SubstitutingTag implements TagsFilter {
|
|||
return !this._invert;
|
||||
}
|
||||
|
||||
/**
|
||||
* const assign = new SubstitutingTag("survey:date", "{_date:now}")
|
||||
* assign.matchesProperties({"survey:date": "2021-03-29", "_date:now": "2021-03-29"}) // => true
|
||||
* assign.matchesProperties({"survey:date": "2021-03-29", "_date:now": "2021-01-01"}) // => false
|
||||
* assign.matchesProperties({"survey:date": "2021-03-29"}) // => false
|
||||
* assign.matchesProperties({"_date:now": "2021-03-29"}) // => false
|
||||
* assign.matchesProperties({"some_key": "2021-03-29"}) // => false
|
||||
*/
|
||||
matchesProperties(properties: any): boolean {
|
||||
const value = properties[this._key];
|
||||
if (value === undefined || value === "") {
|
||||
|
@ -59,6 +67,10 @@ export default class SubstitutingTag implements TagsFilter {
|
|||
return [this._key];
|
||||
}
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
return []
|
||||
}
|
||||
|
||||
asChange(properties: any): { k: string; v: string }[] {
|
||||
if (this._invert) {
|
||||
throw "An inverted substituting tag can not be used to create a change"
|
||||
|
@ -70,7 +82,15 @@ export default class SubstitutingTag implements TagsFilter {
|
|||
return [{k: this._key, v: v}];
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return this._key + (this._invert ? '!' : '') + "=" + this._value
|
||||
optimize(): TagsFilter | boolean {
|
||||
return this;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter: any) => void) {
|
||||
f(this)
|
||||
}
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import {Utils} from "../../Utils";
|
||||
import {RegexTag} from "./RegexTag";
|
||||
import {TagsFilter} from "./TagsFilter";
|
||||
|
||||
|
||||
export class Tag extends TagsFilter {
|
||||
public key: string
|
||||
public value: string
|
||||
|
||||
constructor(key: string, value: string) {
|
||||
super()
|
||||
this.key = key
|
||||
|
@ -14,7 +13,7 @@ export class Tag extends TagsFilter {
|
|||
throw "Invalid key: undefined or empty";
|
||||
}
|
||||
if (value === undefined) {
|
||||
throw "Invalid value: value is undefined";
|
||||
throw `Invalid value while constructing a Tag with key '${key}': value is undefined`;
|
||||
}
|
||||
if (value === "*") {
|
||||
console.warn(`Got suspicious tag ${key}=* ; did you mean ${key}~* ?`)
|
||||
|
@ -22,6 +21,24 @@ export class Tag extends TagsFilter {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* imort
|
||||
*
|
||||
* const tag = new Tag("key","value")
|
||||
* tag.matchesProperties({"key": "value"}) // => true
|
||||
* tag.matchesProperties({"key": "z"}) // => false
|
||||
* tag.matchesProperties({"key": ""}) // => false
|
||||
* tag.matchesProperties({"other_key": ""}) // => false
|
||||
* tag.matchesProperties({"other_key": "value"}) // => false
|
||||
*
|
||||
* const isEmpty = new Tag("key","")
|
||||
* isEmpty.matchesProperties({"key": "value"}) // => false
|
||||
* isEmpty.matchesProperties({"key": ""}) // => true
|
||||
* isEmpty.matchesProperties({"other_key": ""}) // => true
|
||||
* isEmpty.matchesProperties({"other_key": "value"}) // => true
|
||||
* isEmpty.matchesProperties({"key": undefined}) // => true
|
||||
*
|
||||
*/
|
||||
matchesProperties(properties: any): boolean {
|
||||
const foundValue = properties[this.key]
|
||||
if (foundValue === undefined && (this.value === "" || this.value === undefined)) {
|
||||
|
@ -41,12 +58,18 @@ export class Tag extends TagsFilter {
|
|||
return [`["${this.key}"="${this.value}"]`];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
const t = new Tag("key", "value")
|
||||
t.asHumanString() // => "key=value"
|
||||
t.asHumanString(true) // => "<a href='https://wiki.openstreetmap.org/wiki/Key:key' target='_blank'>key</a>=<a href='https://wiki.openstreetmap.org/wiki/Tag:key%3Dvalue' target='_blank'>value</a>"
|
||||
*/
|
||||
asHumanString(linkToWiki?: boolean, shorten?: boolean, currentProperties?: any) {
|
||||
let v = this.value;
|
||||
if (shorten) {
|
||||
v = Utils.EllipsesAfter(v, 25);
|
||||
}
|
||||
if (v === "" || v === undefined) {
|
||||
if (v === "" || v === undefined && currentProperties !== undefined) {
|
||||
// This tag will be removed if in the properties, so we indicate this with special rendering
|
||||
if (currentProperties !== undefined && (currentProperties[this.key] ?? "") === "") {
|
||||
// This tag is not present in the current properties, so this tag doesn't change anything
|
||||
|
@ -66,25 +89,52 @@ export class Tag extends TagsFilter {
|
|||
return true;
|
||||
}
|
||||
|
||||
isEquivalent(other: TagsFilter): boolean {
|
||||
if (other instanceof Tag) {
|
||||
return this.key === other.key && this.value === other.value;
|
||||
/**
|
||||
*
|
||||
* import {RegexTag} from "./RegexTag";
|
||||
*
|
||||
* // should handle advanced regexes
|
||||
* new Tag("key", "aaa").shadows(new RegexTag("key", /a+/)) // => true
|
||||
* new Tag("key","value").shadows(new RegexTag("key", /^..*$/, true)) // => false
|
||||
* new Tag("key","value").shadows(new Tag("key","value")) // => true
|
||||
* new Tag("key","some_other_value").shadows(new RegexTag("key", "value", true)) // => true
|
||||
* new Tag("key","value").shadows(new RegexTag("key", "value", true)) // => false
|
||||
* new Tag("key","value").shadows(new RegexTag("otherkey", "value", true)) // => false
|
||||
* new Tag("key","value").shadows(new RegexTag("otherkey", "value", false)) // => false
|
||||
*/
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if(other["key"] !== undefined){
|
||||
if(other["key"] !== this.key){
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (other instanceof RegexTag) {
|
||||
other.isEquivalent(this);
|
||||
}
|
||||
return false;
|
||||
return other.matchesProperties({[this.key]: this.value});
|
||||
}
|
||||
|
||||
usedKeys(): string[] {
|
||||
return [this.key];
|
||||
}
|
||||
|
||||
usedTags(): { key: string; value: string }[] {
|
||||
if(this.value == ""){
|
||||
return []
|
||||
}
|
||||
return [this]
|
||||
}
|
||||
|
||||
asChange(properties: any): { k: string; v: string }[] {
|
||||
return [{k: this.key, v: this.value}];
|
||||
}
|
||||
|
||||
AsJson() {
|
||||
return this.asHumanString(false, false)
|
||||
optimize(): TagsFilter | boolean {
|
||||
return this;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
visit(f: (TagsFilter) => void) {
|
||||
f(this)
|
||||
}
|
||||
}
|
|
@ -6,10 +6,14 @@ import ComparingTag from "./ComparingTag";
|
|||
import {RegexTag} from "./RegexTag";
|
||||
import SubstitutingTag from "./SubstitutingTag";
|
||||
import {Or} from "./Or";
|
||||
import {AndOrTagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
|
||||
import {TagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
|
||||
import {isRegExp} from "util";
|
||||
import * as key_counts from "../../assets/key_totals.json"
|
||||
|
||||
type Tags = Record<string, string>
|
||||
|
||||
export class TagUtils {
|
||||
private static keyCounts: { keys: any, tags: any } = key_counts["default"] ?? key_counts
|
||||
private static comparators
|
||||
: [string, (a: number, b: number) => boolean][]
|
||||
= [
|
||||
|
@ -54,11 +58,17 @@ export class TagUtils {
|
|||
return true;
|
||||
}
|
||||
|
||||
static SplitKeys(tagsFilters: TagsFilter[]): Record<string, string[]> {
|
||||
return <any>this.SplitKeysRegex(tagsFilters, false);
|
||||
}
|
||||
|
||||
/***
|
||||
* Creates a hash {key --> [values : string | Regex ]}, with all the values present in the tagsfilter
|
||||
* Creates a hash {key --> [values : string | RegexTag ]}, with all the values present in the tagsfilter
|
||||
*
|
||||
* TagUtils.SplitKeysRegex([new Tag("isced:level", "bachelor; master")], true) // => {"isced:level": ["bachelor","master"]}
|
||||
*/
|
||||
static SplitKeys(tagsFilters: TagsFilter[], allowRegex = false) {
|
||||
const keyValues = {} // Map string -> string[]
|
||||
static SplitKeysRegex(tagsFilters: TagsFilter[], allowRegex: boolean): Record<string, (string | RegexTag)[]> {
|
||||
const keyValues: Record<string, (string | RegexTag)[]> = {}
|
||||
tagsFilters = [...tagsFilters] // copy all, use as queue
|
||||
while (tagsFilters.length > 0) {
|
||||
const tagsFilter = tagsFilters.shift();
|
||||
|
@ -76,7 +86,7 @@ export class TagUtils {
|
|||
if (keyValues[tagsFilter.key] === undefined) {
|
||||
keyValues[tagsFilter.key] = [];
|
||||
}
|
||||
keyValues[tagsFilter.key].push(...tagsFilter.value.split(";"));
|
||||
keyValues[tagsFilter.key].push(...tagsFilter.value.split(";").map(s => s.trim()));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -105,12 +115,21 @@ export class TagUtils {
|
|||
* Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set.
|
||||
* E.g:
|
||||
*
|
||||
* FlattenMultiAnswer([and: [ "x=a", "y=0;1"], and: ["x=b", "y=2"], and: ["x=", "y=3"]])
|
||||
* will result in
|
||||
* ["x=a;b", "y=0;1;2;3"]
|
||||
* const tag = TagUtils.Tag({"and": [
|
||||
* {
|
||||
* and: [ "x=a", "y=0;1"],
|
||||
* },
|
||||
* {
|
||||
* and: ["x=", "y=3"]
|
||||
* },
|
||||
* {
|
||||
* and: ["x=b", "y=2"]
|
||||
* }
|
||||
* ]})
|
||||
* TagUtils.FlattenMultiAnswer([tag]) // => TagUtils.Tag({and:["x=a;b", "y=0;1;2;3"] })
|
||||
*
|
||||
* @param tagsFilters
|
||||
* @constructor
|
||||
* TagUtils.FlattenMultiAnswer(([new Tag("x","y"), new Tag("a","b")])) // => new And([new Tag("x","y"), new Tag("a","b")])
|
||||
* TagUtils.FlattenMultiAnswer(([new Tag("x","")])) // => new And([new Tag("x","")])
|
||||
*/
|
||||
static FlattenMultiAnswer(tagsFilters: TagsFilter[]): And {
|
||||
if (tagsFilters === undefined) {
|
||||
|
@ -120,7 +139,9 @@ export class TagUtils {
|
|||
let keyValues = TagUtils.SplitKeys(tagsFilters);
|
||||
const and: TagsFilter[] = []
|
||||
for (const key in keyValues) {
|
||||
and.push(new Tag(key, Utils.Dedup(keyValues[key]).join(";")));
|
||||
const values = Utils.Dedup(keyValues[key]).filter(v => v !== "")
|
||||
values.sort()
|
||||
and.push(new Tag(key, values.join(";")));
|
||||
}
|
||||
return new And(and);
|
||||
}
|
||||
|
@ -128,19 +149,23 @@ export class TagUtils {
|
|||
/**
|
||||
* Returns true if the properties match the tagsFilter, interpreted as a multikey.
|
||||
* Note that this might match a regex tag
|
||||
* @param tag
|
||||
* @param properties
|
||||
* @constructor
|
||||
*
|
||||
* TagUtils.MatchesMultiAnswer(new Tag("isced:level","bachelor"), {"isced:level":"bachelor; master"}) // => true
|
||||
* TagUtils.MatchesMultiAnswer(new Tag("isced:level","master"), {"isced:level":"bachelor;master"}) // => true
|
||||
* TagUtils.MatchesMultiAnswer(new Tag("isced:level","doctorate"), {"isced:level":"bachelor; master"}) // => false
|
||||
*
|
||||
* // should match with a space too
|
||||
* TagUtils.MatchesMultiAnswer(new Tag("isced:level","master"), {"isced:level":"bachelor; master"}) // => true
|
||||
*/
|
||||
static MatchesMultiAnswer(tag: TagsFilter, properties: any): boolean {
|
||||
const splitted = TagUtils.SplitKeys([tag], true);
|
||||
static MatchesMultiAnswer(tag: TagsFilter, properties: Tags): boolean {
|
||||
const splitted = TagUtils.SplitKeysRegex([tag], true);
|
||||
for (const splitKey in splitted) {
|
||||
const neededValues = splitted[splitKey];
|
||||
if (properties[splitKey] === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualValue = properties[splitKey].split(";");
|
||||
const actualValue = properties[splitKey].split(";").map(s => s.trim());
|
||||
for (const neededValue of neededValues) {
|
||||
|
||||
if (neededValue instanceof RegexTag) {
|
||||
|
@ -165,7 +190,65 @@ export class TagUtils {
|
|||
return new Tag(tag[0], tag[1]);
|
||||
}
|
||||
|
||||
public static Tag(json: AndOrTagConfigJson | string, context: string = ""): TagsFilter {
|
||||
/**
|
||||
* Returns wether or not a keys is (probably) a valid key.
|
||||
* See 'Tags_format.md' for an overview of what every tag does
|
||||
*
|
||||
* // should accept common keys
|
||||
* TagUtils.isValidKey("name") // => true
|
||||
* TagUtils.isValidKey("image:0") // => true
|
||||
* TagUtils.isValidKey("alt_name") // => true
|
||||
*
|
||||
* // should refuse short keys
|
||||
* TagUtils.isValidKey("x") // => false
|
||||
* TagUtils.isValidKey("xy") // => false
|
||||
*
|
||||
* // should refuse a string with >255 characters
|
||||
* let a255 = ""
|
||||
* for(let i = 0; i < 255; i++) { a255 += "a"; }
|
||||
* a255.length // => 255
|
||||
* TagUtils.isValidKey(a255) // => true
|
||||
* TagUtils.isValidKey("a"+a255) // => false
|
||||
*
|
||||
* // Should refuse unexpected characters
|
||||
* TagUtils.isValidKey("with space") // => false
|
||||
* TagUtils.isValidKey("some$type") // => false
|
||||
* TagUtils.isValidKey("_name") // => false
|
||||
*/
|
||||
public static isValidKey(key: string): boolean {
|
||||
return key.match(/^[a-z][a-z0-9:_]{2,253}[a-z0-9]$/) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a tag configuration (a json) into a TagsFilter
|
||||
*
|
||||
* TagUtils.Tag("key=value") // => new Tag("key", "value")
|
||||
* TagUtils.Tag("key=") // => new Tag("key", "")
|
||||
* TagUtils.Tag("key!=") // => new RegexTag("key", /^..*$/s)
|
||||
* TagUtils.Tag("key~*") // => new RegexTag("key", /^..*$/s)
|
||||
* TagUtils.Tag("name~i~somename") // => new RegexTag("name", /^somename$/si)
|
||||
* TagUtils.Tag("key!=value") // => new RegexTag("key", "value", true)
|
||||
* TagUtils.Tag("vending~.*bicycle_tube.*") // => new RegexTag("vending", /^.*bicycle_tube.*$/s)
|
||||
* TagUtils.Tag("x!~y") // => new RegexTag("x", /^y$/s, true)
|
||||
* TagUtils.Tag({"and": ["key=value", "x=y"]}) // => new And([new Tag("key","value"), new Tag("x","y")])
|
||||
* TagUtils.Tag("name~[sS]peelbos.*") // => new RegexTag("name", /^[sS]peelbos.*$/s)
|
||||
* TagUtils.Tag("survey:date:={_date:now}") // => new SubstitutingTag("survey:date", "{_date:now}")
|
||||
* TagUtils.Tag("xyz!~\\[\\]") // => new RegexTag("xyz", /^\[\]$/s, true)
|
||||
* TagUtils.Tag("tags~(.*;)?amenity=public_bookcase(;.*)?") // => new RegexTag("tags", /^(.*;)?amenity=public_bookcase(;.*)?$/s)
|
||||
* TagUtils.Tag("service:bicycle:.*~~*") // => new RegexTag(/^service:bicycle:.*$/, /^..*$/s)
|
||||
* TagUtils.Tag("_first_comment~.*{search}.*") // => new RegexTag('_first_comment', /^.*{search}.*$/s)
|
||||
*
|
||||
* TagUtils.Tag("xyz<5").matchesProperties({xyz: 4}) // => true
|
||||
* TagUtils.Tag("xyz<5").matchesProperties({xyz: 5}) // => false
|
||||
*
|
||||
* // RegexTags must match values with newlines
|
||||
* TagUtils.Tag("note~.*aed.*").matchesProperties({note: "Hier bevindt zich wss een defibrillator. \\n\\n De aed bevindt zich op de 5de verdieping"}) // => true
|
||||
* TagUtils.Tag("note~i~.*aed.*").matchesProperties({note: "Hier bevindt zich wss een defibrillator. \\n\\n De AED bevindt zich op de 5de verdieping"}) // => true
|
||||
*
|
||||
* // Must match case insensitive
|
||||
* TagUtils.Tag("name~i~somename").matchesProperties({name: "SoMeName"}) // => true
|
||||
*/
|
||||
public static Tag(json: TagConfigJson, context: string = ""): TagsFilter {
|
||||
try {
|
||||
return this.TagUnsafe(json, context);
|
||||
} catch (e) {
|
||||
|
@ -174,126 +257,372 @@ export class TagUtils {
|
|||
}
|
||||
}
|
||||
|
||||
private static TagUnsafe(json: AndOrTagConfigJson | string, context: string = ""): TagsFilter {
|
||||
/**
|
||||
* Same as `.Tag`, except that this will return undefined if the json is undefined
|
||||
* @param json
|
||||
* @param context
|
||||
* @constructor
|
||||
*/
|
||||
public static TagD(json?: TagConfigJson, context: string = ""): TagsFilter | undefined {
|
||||
if (json === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return TagUtils.Tag(json, context)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* INLINE sort of the given list
|
||||
*/
|
||||
public static sortFilters(filters: TagsFilter [], usePopularity: boolean): void {
|
||||
filters.sort((a, b) => TagUtils.order(a, b, usePopularity))
|
||||
}
|
||||
|
||||
public static toString(f: TagsFilter, toplevel = true): string {
|
||||
let r: string
|
||||
if (f instanceof Or) {
|
||||
r = TagUtils.joinL(f.or, "|", toplevel)
|
||||
} else if (f instanceof And) {
|
||||
r = TagUtils.joinL(f.and, "&", toplevel)
|
||||
} else {
|
||||
r = f.asHumanString(false, false, {})
|
||||
}
|
||||
if (toplevel) {
|
||||
r = r.trim()
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the various parts of a regex tag
|
||||
*
|
||||
* TagUtils.parseRegexOperator("key~value") // => {invert: false, key: "key", value: "value", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("key!~value") // => {invert: true, key: "key", value: "value", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("key~i~value") // => {invert: false, key: "key", value: "value", modifier: "i"}
|
||||
* TagUtils.parseRegexOperator("key!~i~someweirdvalue~qsdf") // => {invert: true, key: "key", value: "someweirdvalue~qsdf", modifier: "i"}
|
||||
* TagUtils.parseRegexOperator("_image:0~value") // => {invert: false, key: "_image:0", value: "value", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("key~*") // => {invert: false, key: "key", value: "*", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("Brugs volgnummer~*") // => {invert: false, key: "Brugs volgnummer", value: "*", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("socket:USB-A~*") // => {invert: false, key: "socket:USB-A", value: "*", modifier: ""}
|
||||
* TagUtils.parseRegexOperator("tileId~*") // => {invert: false, key: "tileId", value: "*", modifier: ""}
|
||||
*/
|
||||
public static parseRegexOperator(tag: string): {
|
||||
invert: boolean;
|
||||
key: string;
|
||||
value: string;
|
||||
modifier: "i" | "";
|
||||
} | null {
|
||||
const match = tag.match(/^([_a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
const [_, key, invert, modifier, value] = match;
|
||||
return {key, value, invert: invert == "!", modifier: (modifier == "i~" ? "i" : "")};
|
||||
}
|
||||
|
||||
private static TagUnsafe(json: TagConfigJson, context: string = ""): TagsFilter {
|
||||
|
||||
if (json === undefined) {
|
||||
throw `Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression`
|
||||
throw new Error(`Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression`)
|
||||
}
|
||||
if (typeof (json) != "string") {
|
||||
if (json["and"] !== undefined && json["or"] !== undefined) {
|
||||
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined`
|
||||
}
|
||||
if (json["and"] !== undefined) {
|
||||
return new And(json["and"].map(t => TagUtils.Tag(t, context)));
|
||||
}
|
||||
if (json["or"] !== undefined) {
|
||||
return new Or(json["or"].map(t => TagUtils.Tag(t, context)));
|
||||
}
|
||||
throw `At ${context}: unrecognized tag: ${JSON.stringify(json)}`
|
||||
}
|
||||
if (typeof (json) == "string") {
|
||||
const tag = json as string;
|
||||
|
||||
for (const [operator, comparator] of TagUtils.comparators) {
|
||||
if (tag.indexOf(operator) >= 0) {
|
||||
const split = Utils.SplitFirst(tag, operator);
|
||||
|
||||
let val = Number(split[1].trim())
|
||||
if (isNaN(val)) {
|
||||
val = new Date(split[1].trim()).getTime()
|
||||
const tag = json as string;
|
||||
for (const [operator, comparator] of TagUtils.comparators) {
|
||||
if (tag.indexOf(operator) >= 0) {
|
||||
const split = Utils.SplitFirst(tag, operator);
|
||||
|
||||
let val = Number(split[1].trim())
|
||||
if (isNaN(val)) {
|
||||
val = new Date(split[1].trim()).getTime()
|
||||
}
|
||||
|
||||
const f = (value: string | number | undefined) => {
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const f = (value: string | undefined) => {
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
let b = Number(value?.trim())
|
||||
let b: number
|
||||
if (typeof value === "number") {
|
||||
b = value
|
||||
} else if (typeof b === "string") {
|
||||
b = Number(value?.trim())
|
||||
} else {
|
||||
b = Number(value)
|
||||
}
|
||||
if (isNaN(b) && typeof value === "string") {
|
||||
b = Utils.ParseDate(value).getTime()
|
||||
if (isNaN(b)) {
|
||||
b = Utils.ParseDate(value).getTime()
|
||||
if (isNaN(b)) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
return comparator(b, val)
|
||||
}
|
||||
return new ComparingTag(split[0], f, operator + val)
|
||||
return comparator(b, val)
|
||||
}
|
||||
return new ComparingTag(split[0], f, operator + val)
|
||||
}
|
||||
|
||||
if (tag.indexOf("!~") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!~");
|
||||
if (split[1] === "*") {
|
||||
throw `Don't use 'key!~*' - use 'key=' instead (empty string as value (in the tag ${tag} while parsing ${context})`
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
split[1],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (tag.indexOf("~~") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "~~");
|
||||
if (split[1] === "*") {
|
||||
split[1] = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
split[1]
|
||||
);
|
||||
}
|
||||
if (tag.indexOf("!:=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!:=");
|
||||
return new SubstitutingTag(split[0], split[1], true);
|
||||
}
|
||||
if (tag.indexOf(":=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, ":=");
|
||||
return new SubstitutingTag(split[0], split[1]);
|
||||
}
|
||||
|
||||
if (tag.indexOf("!=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!=");
|
||||
if (split[1] === "*") {
|
||||
split[1] = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
new RegExp("^" + split[1] + "$"),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (tag.indexOf("!~") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!~");
|
||||
if (split[1] === "*") {
|
||||
split[1] = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
split[1],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (tag.indexOf("~") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "~");
|
||||
if (split[1] === "") {
|
||||
throw "Detected a regextag with an empty regex; this is not allowed. Use '" + split[0] + "='instead (at " + context + ")"
|
||||
}
|
||||
if (split[1] === "*") {
|
||||
split[1] = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
split[1]
|
||||
);
|
||||
}
|
||||
if (tag.indexOf("=") >= 0) {
|
||||
|
||||
|
||||
const split = Utils.SplitFirst(tag, "=");
|
||||
if (split[1] == "*") {
|
||||
throw `Error while parsing tag '${tag}' in ${context}: detected a wildcard on a normal value. Use a regex pattern instead`
|
||||
}
|
||||
return new Tag(split[0], split[1])
|
||||
}
|
||||
throw `Error while parsing tag '${tag}' in ${context}: no key part and value part were found`
|
||||
|
||||
}
|
||||
|
||||
if(json.and !== undefined && json.or !== undefined){
|
||||
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined`}
|
||||
|
||||
if (json.and !== undefined) {
|
||||
return new And(json.and.map(t => TagUtils.Tag(t, context)));
|
||||
|
||||
if (tag.indexOf("~~") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "~~");
|
||||
if (split[1] === "*") {
|
||||
split[1] = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
new RegExp("^" + split[0] + "$"),
|
||||
new RegExp("^" + split[1] + "$", "s")
|
||||
);
|
||||
}
|
||||
if (json.or !== undefined) {
|
||||
return new Or(json.or.map(t => TagUtils.Tag(t, context)));
|
||||
const withRegex = TagUtils.parseRegexOperator(tag)
|
||||
if (withRegex != null) {
|
||||
if (withRegex.value === "*" && withRegex.invert) {
|
||||
throw `Don't use 'key!~*' - use 'key=' instead (empty string as value (in the tag ${tag} while parsing ${context})`
|
||||
}
|
||||
if (withRegex.value === "") {
|
||||
throw "Detected a regextag with an empty regex; this is not allowed. Use '" + withRegex.key + "='instead (at " + context + ")"
|
||||
}
|
||||
|
||||
let value: string | RegExp = withRegex.value;
|
||||
if (value === "*") {
|
||||
value = "..*"
|
||||
}
|
||||
return new RegexTag(
|
||||
withRegex.key,
|
||||
new RegExp("^" + value + "$", "s" + withRegex.modifier),
|
||||
withRegex.invert
|
||||
);
|
||||
}
|
||||
|
||||
if (tag.indexOf("!:=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!:=");
|
||||
return new SubstitutingTag(split[0], split[1], true);
|
||||
}
|
||||
if (tag.indexOf(":=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, ":=");
|
||||
return new SubstitutingTag(split[0], split[1]);
|
||||
}
|
||||
|
||||
if (tag.indexOf("!=") >= 0) {
|
||||
const split = Utils.SplitFirst(tag, "!=");
|
||||
if (split[1] === "*") {
|
||||
throw "At " + context + ": invalid tag " + tag + ". To indicate a missing tag, use '" + split[0] + "!=' instead"
|
||||
}
|
||||
if (split[1] === "") {
|
||||
split[1] = "..*"
|
||||
return new RegexTag(split[0], /^..*$/s)
|
||||
}
|
||||
return new RegexTag(
|
||||
split[0],
|
||||
split[1],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (tag.indexOf("=") >= 0) {
|
||||
|
||||
|
||||
const split = Utils.SplitFirst(tag, "=");
|
||||
if (split[1] == "*") {
|
||||
throw `Error while parsing tag '${tag}' in ${context}: detected a wildcard on a normal value. Use a regex pattern instead`
|
||||
}
|
||||
return new Tag(split[0], split[1])
|
||||
}
|
||||
throw `Error while parsing tag '${tag}' in ${context}: no key part and value part were found`
|
||||
}
|
||||
|
||||
private static GetCount(key: string, value?: string) {
|
||||
if (key === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const tag = TagUtils.keyCounts.tags[key]
|
||||
if (tag !== undefined && tag[value] !== undefined) {
|
||||
return tag[value]
|
||||
}
|
||||
return TagUtils.keyCounts.keys[key]
|
||||
}
|
||||
|
||||
private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number {
|
||||
const rta = a instanceof RegexTag
|
||||
const rtb = b instanceof RegexTag
|
||||
if (rta !== rtb) {
|
||||
// Regex tags should always go at the end: these use a lot of computation at the overpass side, avoiding it is better
|
||||
if (rta) {
|
||||
return 1 // b < a
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
if (a["key"] !== undefined && b["key"] !== undefined) {
|
||||
if (usePopularity) {
|
||||
const countA = TagUtils.GetCount(a["key"], a["value"])
|
||||
const countB = TagUtils.GetCount(b["key"], b["value"])
|
||||
if (countA !== undefined && countB !== undefined) {
|
||||
return countA - countB
|
||||
}
|
||||
}
|
||||
|
||||
if (a["key"] === b["key"]) {
|
||||
return 0
|
||||
}
|
||||
if (a["key"] < b["key"]) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
private static joinL(tfs: TagsFilter[], seperator: string, toplevel: boolean) {
|
||||
const joined = tfs.map(e => TagUtils.toString(e, false)).join(seperator)
|
||||
if (toplevel) {
|
||||
return joined
|
||||
}
|
||||
return " (" + joined + ") "
|
||||
}
|
||||
|
||||
public static ExtractSimpleTags(tf: TagsFilter) : Tag[] {
|
||||
const result: Tag[] = []
|
||||
tf.visit(t => {
|
||||
if(t instanceof Tag){
|
||||
result.push(t)
|
||||
}
|
||||
})
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'true' is opposite tags are detected.
|
||||
* Note that this method will never work perfectly
|
||||
*
|
||||
* // should be false for some simple cases
|
||||
* TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key0", "value")]) // => false
|
||||
* TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key", "value0")]) // => false
|
||||
*
|
||||
* // should detect simple cases
|
||||
* TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", "value", true)]) // => true
|
||||
* TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", /value/, true)]) // => true
|
||||
*/
|
||||
public static ContainsOppositeTags(tags: (TagsFilter)[]): boolean {
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
if (!(tag instanceof Tag || tag instanceof RegexTag)) {
|
||||
continue
|
||||
}
|
||||
for (let j = i + 1; j < tags.length; j++) {
|
||||
const guard = tags[j];
|
||||
if (!(guard instanceof Tag || guard instanceof RegexTag)) {
|
||||
continue
|
||||
}
|
||||
if (guard.key !== tag.key) {
|
||||
// Different keys: they can _never_ be opposites
|
||||
continue
|
||||
}
|
||||
if ((guard.value["source"] ?? guard.value) !== (tag.value["source"] ?? tag.value)) {
|
||||
// different values: the can _never_ be opposites
|
||||
continue
|
||||
}
|
||||
if ((guard["invert"] ?? false) !== (tag["invert"] ?? false)) {
|
||||
// The 'invert' flags are opposite, the key and value is the same for both
|
||||
// This means we have found opposite tags!
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a filtered version of 'listToFilter'.
|
||||
* For a list [t0, t1, t2], If `blackList` contains an equivalent (or broader) match of any `t`, this respective `t` is dropped from the returned list
|
||||
* Ignores nested ORS and ANDS
|
||||
*
|
||||
* TagUtils.removeShadowedElementsFrom([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => [new Tag("other_key","value")]
|
||||
*/
|
||||
public static removeShadowedElementsFrom(blacklist: TagsFilter[], listToFilter: TagsFilter[]): TagsFilter[] {
|
||||
return listToFilter.filter(tf => !blacklist.some(guard => guard.shadows(tf)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a filtered version of 'listToFilter', where no duplicates and no equivalents exists.
|
||||
*
|
||||
* TagUtils.removeEquivalents([new RegexTag("key", /^..*$/), new Tag("key","value")]) // => [new Tag("key", "value")]
|
||||
*/
|
||||
public static removeEquivalents(listToFilter: (Tag | RegexTag)[]): TagsFilter[] {
|
||||
const result: TagsFilter[] = []
|
||||
outer: for (let i = 0; i < listToFilter.length; i++) {
|
||||
const tag = listToFilter[i];
|
||||
for (let j = 0; j < listToFilter.length; j++) {
|
||||
if (i === j) {
|
||||
continue
|
||||
}
|
||||
const guard = listToFilter[j];
|
||||
if (guard.shadows(tag)) {
|
||||
// the guard 'kills' the tag: we continue the outer loop without adding the tag
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
result.push(tag)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if at least one element of the 'guards' shadows one element of the 'listToFilter'.
|
||||
*
|
||||
* TagUtils.containsEquivalents([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => true
|
||||
* TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("other_key","value")]) // => false
|
||||
* TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("key","other_value")]) // => false
|
||||
*/
|
||||
public static containsEquivalents(guards: TagsFilter[], listToFilter: TagsFilter[]): boolean {
|
||||
return listToFilter.some(tf => guards.some(guard => guard.shadows(tf)))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses a level specifier to the various available levels
|
||||
*
|
||||
* TagUtils.LevelsParser("0") // => ["0"]
|
||||
* TagUtils.LevelsParser("1") // => ["1"]
|
||||
* TagUtils.LevelsParser("0;2") // => ["0","2"]
|
||||
* TagUtils.LevelsParser("0-5") // => ["0","1","2","3","4","5"]
|
||||
* TagUtils.LevelsParser("0") // => ["0"]
|
||||
* TagUtils.LevelsParser("-1") // => ["-1"]
|
||||
* TagUtils.LevelsParser("0;-1") // => ["0", "-1"]
|
||||
*/
|
||||
public static LevelsParser(level: string): string[] {
|
||||
let spec = Utils.NoNull([level])
|
||||
spec = [].concat(...spec.map(s => s?.split(";")))
|
||||
spec = [].concat(...spec.map(s => {
|
||||
s = s.trim()
|
||||
if (s.indexOf("-") < 0 || s.startsWith("-")) {
|
||||
return s
|
||||
}
|
||||
const [start, end] = s.split("-").map(s => Number(s.trim()))
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
return undefined
|
||||
}
|
||||
const values = []
|
||||
for (let i = start; i <= end; i++) {
|
||||
values.push(i + "")
|
||||
}
|
||||
return values
|
||||
}))
|
||||
return Utils.NoNull(spec);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -4,7 +4,11 @@ export abstract class TagsFilter {
|
|||
|
||||
abstract isUsableAsAnswer(): boolean;
|
||||
|
||||
abstract isEquivalent(other: TagsFilter): boolean;
|
||||
/**
|
||||
* Indicates some form of equivalency:
|
||||
* if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties
|
||||
*/
|
||||
abstract shadows(other: TagsFilter): boolean;
|
||||
|
||||
abstract matchesProperties(properties: any): boolean;
|
||||
|
||||
|
@ -12,6 +16,12 @@ export abstract class TagsFilter {
|
|||
|
||||
abstract usedKeys(): string[];
|
||||
|
||||
/**
|
||||
* Returns all normal key/value pairs
|
||||
* Regex tags, substitutions, comparisons, ... are exempt
|
||||
*/
|
||||
abstract usedTags(): { key: string, value: string }[];
|
||||
|
||||
/**
|
||||
* Converts the tagsFilter into a list of key-values that should be uploaded to OSM.
|
||||
* Throws an error if not applicable.
|
||||
|
@ -20,5 +30,31 @@ export abstract class TagsFilter {
|
|||
*/
|
||||
abstract asChange(properties: any): { k: string, v: string }[]
|
||||
|
||||
abstract AsJson() ;
|
||||
/**
|
||||
* Returns an optimized version (or self) of this tagsFilter
|
||||
*/
|
||||
abstract optimize(): TagsFilter | boolean;
|
||||
|
||||
/**
|
||||
* Returns 'true' if the tagsfilter might select all features (i.e. the filter will return everything from OSM, except a few entries).
|
||||
*
|
||||
* A typical negative tagsfilter is 'key!=value'
|
||||
*
|
||||
* import {RegexTag} from "./RegexTag";
|
||||
* import {Tag} from "./Tag";
|
||||
* import {And} from "./And";
|
||||
* import {Or} from "./Or";
|
||||
*
|
||||
* new Tag("key","value").isNegative() // => false
|
||||
* new And([new RegexTag("key","value", true)]).isNegative() // => true
|
||||
* new Or([new RegexTag("key","value", true), new Tag("x","y")]).isNegative() // => true
|
||||
* new And([new RegexTag("key","value", true), new Tag("x","y")]).isNegative() // => false
|
||||
*/
|
||||
abstract isNegative(): boolean
|
||||
|
||||
/**
|
||||
* Walks the entire tree, every tagsFilter will be passed into the function once
|
||||
*/
|
||||
abstract visit(f: ((TagsFilter) => void));
|
||||
|
||||
}
|
|
@ -1,64 +1,10 @@
|
|||
import {Utils} from "../Utils";
|
||||
|
||||
export class UIEventSource<T> {
|
||||
|
||||
private static allSources: UIEventSource<any>[] = UIEventSource.PrepPerf();
|
||||
public data: T;
|
||||
public trace: boolean;
|
||||
private readonly tag: string;
|
||||
private _callbacks: ((t: T) => (boolean | void | any)) [] = [];
|
||||
|
||||
constructor(data: T, tag: string = "") {
|
||||
this.tag = tag;
|
||||
this.data = data;
|
||||
if (tag === undefined || tag === "") {
|
||||
const callstack = new Error().stack.split("\n")
|
||||
this.tag = callstack[1]
|
||||
}
|
||||
UIEventSource.allSources.push(this);
|
||||
}
|
||||
|
||||
static PrepPerf(): UIEventSource<any>[] {
|
||||
if (Utils.runningFromConsole) {
|
||||
return [];
|
||||
}
|
||||
// @ts-ignore
|
||||
window.mapcomplete_performance = () => {
|
||||
console.log(UIEventSource.allSources.length, "uieventsources created");
|
||||
const copy = [...UIEventSource.allSources];
|
||||
copy.sort((a, b) => b._callbacks.length - a._callbacks.length);
|
||||
console.log("Topten is:")
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(copy[i].tag, copy[i]);
|
||||
}
|
||||
return UIEventSource.allSources;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public static flatten<X>(source: UIEventSource<UIEventSource<X>>, possibleSources?: UIEventSource<any>[]): UIEventSource<X> {
|
||||
const sink = new UIEventSource<X>(source.data?.data);
|
||||
|
||||
source.addCallback((latestData) => {
|
||||
sink.setData(latestData?.data);
|
||||
latestData.addCallback(data => {
|
||||
if (source.data !== latestData) {
|
||||
return true;
|
||||
}
|
||||
sink.setData(data)
|
||||
})
|
||||
});
|
||||
|
||||
for (const possibleSource of possibleSources ?? []) {
|
||||
possibleSource?.addCallback(() => {
|
||||
sink.setData(source.data?.data);
|
||||
})
|
||||
}
|
||||
|
||||
return sink;
|
||||
}
|
||||
|
||||
public static Chronic(millis: number, asLong: () => boolean = undefined): UIEventSource<Date> {
|
||||
/**
|
||||
* Various static utils
|
||||
*/
|
||||
export class Stores {
|
||||
public static Chronic(millis: number, asLong: () => boolean = undefined): Store<Date> {
|
||||
const source = new UIEventSource<Date>(undefined);
|
||||
|
||||
function run() {
|
||||
|
@ -72,17 +18,8 @@ export class UIEventSource<T> {
|
|||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
|
||||
* If the promise fails, the value will stay undefined
|
||||
* @param promise
|
||||
* @constructor
|
||||
*/
|
||||
public static FromPromise<T>(promise: Promise<T>): UIEventSource<T> {
|
||||
const src = new UIEventSource<T>(undefined)
|
||||
promise?.then(d => src.setData(d))
|
||||
promise?.catch(err => console.warn("Promise failed:", err))
|
||||
return src
|
||||
public static FromPromiseWithErr<T>(promise: Promise<T>): Store<{ success: T } | { error: any }> {
|
||||
return UIEventSource.FromPromiseWithErr(promise);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,13 +28,17 @@ export class UIEventSource<T> {
|
|||
* @param promise
|
||||
* @constructor
|
||||
*/
|
||||
public static FromPromiseWithErr<T>(promise: Promise<T>): UIEventSource<{ success: T } | { error: any }> {
|
||||
const src = new UIEventSource<{ success: T } | { error: any }>(undefined)
|
||||
promise?.then(d => src.setData({success: d}))
|
||||
promise?.catch(err => src.setData({error: err}))
|
||||
public static FromPromise<T>(promise: Promise<T>): Store<T> {
|
||||
const src = new UIEventSource<T>(undefined)
|
||||
promise?.then(d => src.setData(d))
|
||||
promise?.catch(err => console.warn("Promise failed:", err))
|
||||
return src
|
||||
}
|
||||
|
||||
public static flatten<X>(source: Store<Store<X>>, possibleSources?: Store<any>[]): Store<X> {
|
||||
return UIEventSource.flatten(source, possibleSources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a UIEVentSource with a list, returns a new UIEventSource which is only updated if the _contents_ of the list are different.
|
||||
* E.g.
|
||||
|
@ -112,10 +53,9 @@ export class UIEventSource<T> {
|
|||
* @param src
|
||||
* @constructor
|
||||
*/
|
||||
public static ListStabilized<T>(src: UIEventSource<T[]>): UIEventSource<T[]> {
|
||||
|
||||
const stable = new UIEventSource<T[]>(src.data)
|
||||
src.addCallback(list => {
|
||||
public static ListStabilized<T>(src: Store<T[]>): Store<T[]> {
|
||||
const stable = new UIEventSource<T[]>(undefined)
|
||||
src.addCallbackAndRun(list => {
|
||||
if (list === undefined) {
|
||||
stable.setData(undefined)
|
||||
return;
|
||||
|
@ -124,6 +64,9 @@ export class UIEventSource<T> {
|
|||
if (oldList === list) {
|
||||
return;
|
||||
}
|
||||
if(oldList == list){
|
||||
return;
|
||||
}
|
||||
if (oldList === undefined || oldList.length !== list.length) {
|
||||
stable.setData(list);
|
||||
return;
|
||||
|
@ -141,45 +84,58 @@ export class UIEventSource<T> {
|
|||
})
|
||||
return stable
|
||||
}
|
||||
}
|
||||
|
||||
public static asFloat(source: UIEventSource<string>): UIEventSource<number> {
|
||||
return source.map(
|
||||
(str) => {
|
||||
let parsed = parseFloat(str);
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
},
|
||||
[],
|
||||
(fl) => {
|
||||
if (fl === undefined || isNaN(fl)) {
|
||||
return undefined;
|
||||
}
|
||||
return ("" + fl).substr(0, 8);
|
||||
export abstract class Store<T> {
|
||||
abstract readonly data: T;
|
||||
|
||||
/**
|
||||
* OPtional value giving a title to the UIEventSource, mainly used for debugging
|
||||
*/
|
||||
public readonly tag: string | undefined;
|
||||
|
||||
|
||||
constructor(tag: string = undefined) {
|
||||
this.tag = tag;
|
||||
if ((tag === undefined || tag === "")) {
|
||||
let createStack = Utils.runningFromConsole;
|
||||
if (!Utils.runningFromConsole) {
|
||||
createStack = window.location.hostname === "127.0.0.1"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public AsPromise(): Promise<T> {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (self.data !== undefined) {
|
||||
resolve(self.data)
|
||||
} else {
|
||||
self.addCallbackD(data => {
|
||||
resolve(data)
|
||||
return true; // return true to unregister as we only need to be called once
|
||||
})
|
||||
if (createStack) {
|
||||
const callstack = new Error().stack.split("\n")
|
||||
this.tag = callstack[1]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public WaitForPromise(promise: Promise<T>, onFail: ((any) => void)): UIEventSource<T> {
|
||||
const self = this;
|
||||
promise?.then(d => self.setData(d))
|
||||
promise?.catch(err => onFail(err))
|
||||
return this
|
||||
}
|
||||
abstract map<J>(f: ((t: T) => J)): Store<J>
|
||||
abstract map<J>(f: ((t: T) => J), extraStoresToWatch: Store<any>[]): Store<J>
|
||||
|
||||
public withEqualityStabilized(comparator: (t: T | undefined, t1: T | undefined) => boolean): UIEventSource<T> {
|
||||
/**
|
||||
* Add a callback function which will run on future data changes
|
||||
*/
|
||||
abstract addCallback(callback: (data: T) => void): (() => void);
|
||||
|
||||
/**
|
||||
* Adds a callback function, which will be run immediately.
|
||||
* Only triggers if the current data is defined
|
||||
*/
|
||||
abstract addCallbackAndRunD(callback: (data: T) => void): (() => void);
|
||||
|
||||
/**
|
||||
* Add a callback function which will run on future data changes
|
||||
* Only triggers if the data is defined
|
||||
*/
|
||||
abstract addCallbackD(callback: (data: T) => void): (() => void);
|
||||
|
||||
/**
|
||||
* Adds a callback function, which will be run immediately.
|
||||
* Only triggers if the current data is defined
|
||||
*/
|
||||
abstract addCallbackAndRun(callback: (data: T) => void): (() => void);
|
||||
|
||||
public withEqualityStabilized(comparator: (t: T | undefined, t1: T | undefined) => boolean): Store<T> {
|
||||
let oldValue = undefined;
|
||||
return this.map(v => {
|
||||
if (v == oldValue) {
|
||||
|
@ -193,73 +149,56 @@ export class UIEventSource<T> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a callback
|
||||
*
|
||||
* If the result of the callback is 'true', the callback is considered finished and will be removed again
|
||||
* @param callback
|
||||
*/
|
||||
public addCallback(callback: ((latestData: T) => (boolean | void | any))): UIEventSource<T> {
|
||||
if (callback === console.log) {
|
||||
// This ^^^ actually works!
|
||||
throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead."
|
||||
}
|
||||
if (this.trace) {
|
||||
console.trace("Added a callback")
|
||||
}
|
||||
this._callbacks.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addCallbackAndRun(callback: ((latestData: T) => (boolean | void | any))): UIEventSource<T> {
|
||||
const doDeleteCallback = callback(this.data);
|
||||
if (doDeleteCallback !== true) {
|
||||
this.addCallback(callback);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public setData(t: T): UIEventSource<T> {
|
||||
if (this.data == t) { // MUST COMPARE BY REFERENCE!
|
||||
return;
|
||||
}
|
||||
this.data = t;
|
||||
this.ping();
|
||||
return this;
|
||||
}
|
||||
|
||||
public ping(): void {
|
||||
let toDelete = undefined
|
||||
let startTime = new Date().getTime() / 1000;
|
||||
for (const callback of this._callbacks) {
|
||||
if (callback(this.data) === true) {
|
||||
// This callback wants to be deleted
|
||||
// Note: it has to return precisely true in order to avoid accidental deletions
|
||||
if (toDelete === undefined) {
|
||||
toDelete = [callback]
|
||||
} else {
|
||||
toDelete.push(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
let endTime = new Date().getTime() / 1000
|
||||
if ((endTime - startTime) > 500) {
|
||||
console.trace("Warning: a ping of ", this.tag, " took more then 500ms; this is probably a performance issue")
|
||||
}
|
||||
if (toDelete !== undefined) {
|
||||
for (const toDeleteElement of toDelete) {
|
||||
this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monadic bind function
|
||||
*
|
||||
* // simple test with bound and immutablestores
|
||||
* const src = new UIEventSource<number>(3)
|
||||
* const bound = src.bind(i => new ImmutableStore(i * 2))
|
||||
* let lastValue = undefined;
|
||||
* bound.addCallbackAndRun(v => lastValue = v);
|
||||
* lastValue // => 6
|
||||
* src.setData(21)
|
||||
* lastValue // => 42
|
||||
*
|
||||
* // simple test with bind over a mapped value
|
||||
* const src = new UIEventSource<number>(0)
|
||||
* const srcs : UIEventSource<string>[] = [new UIEventSource<string>("a"), new UIEventSource<string>("b")]
|
||||
* const bound = src.map(i => -i).bind(i => srcs[i])
|
||||
* let lastValue : string = undefined;
|
||||
* bound.addCallbackAndRun(v => lastValue = v);
|
||||
* lastValue // => "a"
|
||||
* src.setData(-1)
|
||||
* lastValue // => "b"
|
||||
* srcs[1].setData("xyz")
|
||||
* lastValue // => "xyz"
|
||||
* srcs[0].setData("def")
|
||||
* lastValue // => "xyz"
|
||||
* src.setData(0)
|
||||
* lastValue // => "def"
|
||||
*
|
||||
*
|
||||
*
|
||||
* // advanced test with bound
|
||||
* const src = new UIEventSource<number>(0)
|
||||
* const srcs : UIEventSource<string>[] = [new UIEventSource<string>("a"), new UIEventSource<string>("b")]
|
||||
* const bound = src.bind(i => srcs[i])
|
||||
* let lastValue : string = undefined;
|
||||
* bound.addCallbackAndRun(v => lastValue = v);
|
||||
* lastValue // => "a"
|
||||
* src.setData(1)
|
||||
* lastValue // => "b"
|
||||
* srcs[1].setData("xyz")
|
||||
* lastValue // => "xyz"
|
||||
* srcs[0].setData("def")
|
||||
* lastValue // => "xyz"
|
||||
* src.setData(0)
|
||||
* lastValue // => "def"
|
||||
*/
|
||||
public bind<X>(f: ((t: T) => UIEventSource<X>)): UIEventSource<X> {
|
||||
public bind<X>(f: ((t: T) => Store<X>)): Store<X> {
|
||||
const mapped = this.map(f)
|
||||
const sink = new UIEventSource<X>(undefined)
|
||||
const seenEventSources = new Set<UIEventSource<X>>();
|
||||
const seenEventSources = new Set<Store<X>>();
|
||||
mapped.addCallbackAndRun(newEventSource => {
|
||||
if (newEventSource === null) {
|
||||
sink.setData(null)
|
||||
|
@ -281,18 +220,472 @@ export class UIEventSource<T> {
|
|||
return sink;
|
||||
}
|
||||
|
||||
public stabilized(millisToStabilize): Store<T> {
|
||||
if (Utils.runningFromConsole) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const newSource = new UIEventSource<T>(this.data);
|
||||
|
||||
this.addCallback(latestData => {
|
||||
window.setTimeout(() => {
|
||||
if (this.data == latestData) { // compare by reference
|
||||
newSource.setData(latestData);
|
||||
}
|
||||
}, millisToStabilize)
|
||||
});
|
||||
|
||||
return newSource;
|
||||
}
|
||||
|
||||
public AsPromise(condition?: ((t: T) => boolean)): Promise<T> {
|
||||
const self = this;
|
||||
condition = condition ?? (t => t !== undefined)
|
||||
return new Promise((resolve) => {
|
||||
if (condition(self.data)) {
|
||||
resolve(self.data)
|
||||
} else {
|
||||
self.addCallbackD(data => {
|
||||
resolve(data)
|
||||
return true; // return true to unregister as we only need to be called once
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class ImmutableStore<T> extends Store<T> {
|
||||
public readonly data: T;
|
||||
|
||||
private static readonly pass: (() => void) = () => {
|
||||
}
|
||||
|
||||
constructor(data: T) {
|
||||
super();
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
addCallback(callback: (data: T) => void): (() => void) {
|
||||
// pass: data will never change
|
||||
return ImmutableStore.pass
|
||||
}
|
||||
|
||||
addCallbackAndRun(callback: (data: T) => void): (() => void) {
|
||||
callback(this.data)
|
||||
// no callback registry: data will never change
|
||||
return ImmutableStore.pass
|
||||
}
|
||||
|
||||
addCallbackAndRunD(callback: (data: T) => void): (() => void) {
|
||||
if (this.data !== undefined) {
|
||||
callback(this.data)
|
||||
}
|
||||
// no callback registry: data will never change
|
||||
return ImmutableStore.pass
|
||||
}
|
||||
|
||||
addCallbackD(callback: (data: T) => void): (() => void) {
|
||||
// pass: data will never change
|
||||
return ImmutableStore.pass
|
||||
}
|
||||
|
||||
|
||||
map<J>(f: (t: T) => J, extraStores: Store<any>[] = undefined): ImmutableStore<J> {
|
||||
if(extraStores?.length > 0){
|
||||
return new MappedStore(this, f, extraStores, undefined, f(this.data))
|
||||
}
|
||||
return new ImmutableStore<J>(f(this.data));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of the callback functions
|
||||
*/
|
||||
class ListenerTracker<T> {
|
||||
private readonly _callbacks: ((t: T) => (boolean | void | any)) [] = [];
|
||||
|
||||
public pingCount = 0;
|
||||
/**
|
||||
* Monoidal map:
|
||||
* Adds a callback which can be called; a function to unregister is returned
|
||||
*/
|
||||
public addCallback(callback: (t: T) => (boolean | void | any)): (() => void) {
|
||||
if (callback === console.log) {
|
||||
// This ^^^ actually works!
|
||||
throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead."
|
||||
}
|
||||
this._callbacks.push(callback);
|
||||
|
||||
// Give back an unregister-function!
|
||||
return () => {
|
||||
const index = this._callbacks.indexOf(callback)
|
||||
if (index >= 0) {
|
||||
this._callbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all the callbacks.
|
||||
* Returns the number of registered callbacks
|
||||
*/
|
||||
public ping(data: T): number {
|
||||
this.pingCount ++;
|
||||
let toDelete = undefined
|
||||
let startTime = new Date().getTime() / 1000;
|
||||
for (const callback of this._callbacks) {
|
||||
if (callback(data) === true) {
|
||||
// This callback wants to be deleted
|
||||
// Note: it has to return precisely true in order to avoid accidental deletions
|
||||
if (toDelete === undefined) {
|
||||
toDelete = [callback]
|
||||
} else {
|
||||
toDelete.push(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
let endTime = new Date().getTime() / 1000
|
||||
if ((endTime - startTime) > 500) {
|
||||
console.trace("Warning: a ping took more then 500ms; this is probably a performance issue")
|
||||
}
|
||||
if (toDelete !== undefined) {
|
||||
for (const toDeleteElement of toDelete) {
|
||||
this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1)
|
||||
}
|
||||
}
|
||||
return this._callbacks.length
|
||||
}
|
||||
|
||||
length() {
|
||||
return this._callbacks.length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The mapped store is a helper type which does the mapping of a function.
|
||||
* It'll fuse
|
||||
*/
|
||||
class MappedStore<TIn, T> extends Store<T> {
|
||||
|
||||
private _upstream: Store<TIn>;
|
||||
private _upstreamCallbackHandler: ListenerTracker<TIn> | undefined;
|
||||
private _upstreamPingCount: number = -1;
|
||||
private _unregisterFromUpstream: (() => void)
|
||||
|
||||
private _f: (t: TIn) => T;
|
||||
private readonly _extraStores: Store<any>[] | undefined;
|
||||
private _unregisterFromExtraStores: (() => void)[] | undefined
|
||||
|
||||
private _callbacks: ListenerTracker<T> = new ListenerTracker<T>()
|
||||
|
||||
private static readonly pass: () => {}
|
||||
|
||||
|
||||
constructor(upstream: Store<TIn>, f: (t: TIn) => T, extraStores: Store<any>[],
|
||||
upstreamListenerHandler: ListenerTracker<TIn> | undefined, initialState: T) {
|
||||
super();
|
||||
this._upstream = upstream;
|
||||
this._upstreamCallbackHandler = upstreamListenerHandler
|
||||
this._f = f;
|
||||
this._data = initialState
|
||||
this._upstreamPingCount = upstreamListenerHandler?.pingCount
|
||||
this._extraStores = extraStores;
|
||||
this.registerCallbacksToUpstream()
|
||||
}
|
||||
|
||||
private _data: T;
|
||||
private _callbacksAreRegistered = false
|
||||
|
||||
/**
|
||||
* Gets the current data from the store
|
||||
*
|
||||
* const src = new UIEventSource(21)
|
||||
* const mapped = src.map(i => i * 2)
|
||||
* src.setData(3)
|
||||
* mapped.data // => 6
|
||||
*
|
||||
*/
|
||||
get data(): T {
|
||||
if (!this._callbacksAreRegistered) {
|
||||
// Callbacks are not registered, so we haven't been listening for updates from the upstream which might have changed
|
||||
if(this._upstreamCallbackHandler?.pingCount != this._upstreamPingCount){
|
||||
// Upstream has pinged - let's update our data first
|
||||
this._data = this._f(this._upstream.data)
|
||||
}
|
||||
return this._data
|
||||
}
|
||||
return this._data
|
||||
}
|
||||
|
||||
|
||||
map<J>(f: (t: T) => J, extraStores: (Store<any>)[] = undefined): Store<J> {
|
||||
let stores: Store<any>[] = undefined
|
||||
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
|
||||
stores = []
|
||||
}
|
||||
if (extraStores?.length > 0) {
|
||||
stores.push(...extraStores)
|
||||
}
|
||||
if (this._extraStores?.length > 0) {
|
||||
this._extraStores?.forEach(store => {
|
||||
if (stores.indexOf(store) < 0) {
|
||||
stores.push(store)
|
||||
}
|
||||
})
|
||||
}
|
||||
return new MappedStore(
|
||||
this,
|
||||
f, // we could fuse the functions here (e.g. data => f(this._f(data), but this might result in _f being calculated multiple times, breaking things
|
||||
stores,
|
||||
this._callbacks,
|
||||
f(this.data)
|
||||
);
|
||||
}
|
||||
|
||||
private unregisterFromUpstream() {
|
||||
console.log("Unregistering callbacks for", this.tag)
|
||||
this._callbacksAreRegistered = false;
|
||||
this._unregisterFromUpstream()
|
||||
this._unregisterFromExtraStores?.forEach(unr => unr())
|
||||
}
|
||||
|
||||
private registerCallbacksToUpstream() {
|
||||
const self = this
|
||||
|
||||
this._unregisterFromUpstream = this._upstream.addCallback(
|
||||
_ => self.update()
|
||||
)
|
||||
this._unregisterFromExtraStores = this._extraStores?.map(store =>
|
||||
store?.addCallback(_ => self.update())
|
||||
)
|
||||
this._callbacksAreRegistered = true;
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
const newData = this._f(this._upstream.data)
|
||||
this._upstreamPingCount = this._upstreamCallbackHandler?.pingCount
|
||||
if (this._data == newData) {
|
||||
return;
|
||||
}
|
||||
this._data = newData
|
||||
this._callbacks.ping(this._data)
|
||||
}
|
||||
|
||||
addCallback(callback: (data: T) => (any | boolean | void)): (() => void) {
|
||||
if (!this._callbacksAreRegistered) {
|
||||
// This is the first callback that is added
|
||||
// We register this 'map' to the upstream object and all the streams
|
||||
this.registerCallbacksToUpstream()
|
||||
}
|
||||
const unregister = this._callbacks.addCallback(callback)
|
||||
return () => {
|
||||
unregister()
|
||||
if (this._callbacks.length() == 0) {
|
||||
this.unregisterFromUpstream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addCallbackAndRun(callback: (data: T) => (any | boolean | void)): (() => void) {
|
||||
const unregister = this.addCallback(callback)
|
||||
const doRemove = callback(this.data)
|
||||
if (doRemove === true) {
|
||||
unregister()
|
||||
return MappedStore.pass
|
||||
}
|
||||
return unregister
|
||||
}
|
||||
|
||||
addCallbackAndRunD(callback: (data: T) => (any | boolean | void)): (() => void) {
|
||||
return this.addCallbackAndRun(data => {
|
||||
if (data !== undefined) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addCallbackD(callback: (data: T) => (any | boolean | void)): (() => void) {
|
||||
return this.addCallback(data => {
|
||||
if (data !== undefined) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export class UIEventSource<T> extends Store<T> {
|
||||
|
||||
public data: T;
|
||||
_callbacks: ListenerTracker<T> = new ListenerTracker<T>()
|
||||
|
||||
private static readonly pass: () => {}
|
||||
|
||||
constructor(data: T, tag: string = "") {
|
||||
super(tag);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static flatten<X>(source: Store<Store<X>>, possibleSources?: Store<any>[]): UIEventSource<X> {
|
||||
const sink = new UIEventSource<X>(source.data?.data);
|
||||
|
||||
source.addCallback((latestData) => {
|
||||
sink.setData(latestData?.data);
|
||||
latestData.addCallback(data => {
|
||||
if (source.data !== latestData) {
|
||||
return true;
|
||||
}
|
||||
sink.setData(data)
|
||||
})
|
||||
});
|
||||
|
||||
for (const possibleSource of possibleSources ?? []) {
|
||||
possibleSource?.addCallback(() => {
|
||||
sink.setData(source.data?.data);
|
||||
})
|
||||
}
|
||||
|
||||
return sink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
|
||||
* If the promise fails, the value will stay undefined, but 'onError' will be called
|
||||
*/
|
||||
public static FromPromise<T>(promise: Promise<T>, onError: ((e: any) => void) = undefined): UIEventSource<T> {
|
||||
const src = new UIEventSource<T>(undefined)
|
||||
promise?.then(d => src.setData(d))
|
||||
promise?.catch(err => {
|
||||
if (onError !== undefined) {
|
||||
onError(err)
|
||||
} else {
|
||||
console.warn("Promise failed:", err);
|
||||
}
|
||||
})
|
||||
return src
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
|
||||
* If the promise fails, the value will stay undefined
|
||||
* @param promise
|
||||
* @constructor
|
||||
*/
|
||||
public static FromPromiseWithErr<T>(promise: Promise<T>): UIEventSource<{ success: T } | { error: any }> {
|
||||
const src = new UIEventSource<{ success: T } | { error: any }>(undefined)
|
||||
promise?.then(d => src.setData({success: d}))
|
||||
promise?.catch(err => src.setData({error: err}))
|
||||
return src
|
||||
}
|
||||
|
||||
public static asFloat(source: UIEventSource<string>): UIEventSource<number> {
|
||||
return source.sync(
|
||||
(str) => {
|
||||
let parsed = parseFloat(str);
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
},
|
||||
[],
|
||||
(fl) => {
|
||||
if (fl === undefined || isNaN(fl)) {
|
||||
return undefined;
|
||||
}
|
||||
return ("" + fl).substr(0, 8);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a callback
|
||||
*
|
||||
* If the result of the callback is 'true', the callback is considered finished and will be removed again
|
||||
* @param callback
|
||||
*/
|
||||
public addCallback(callback: ((latestData: T) => (boolean | void | any))): (() => void) {
|
||||
return this._callbacks.addCallback(callback);
|
||||
}
|
||||
|
||||
public addCallbackAndRun(callback: ((latestData: T) => (boolean | void | any))): (() => void) {
|
||||
const doDeleteCallback = callback(this.data);
|
||||
if (doDeleteCallback !== true) {
|
||||
return this.addCallback(callback);
|
||||
} else {
|
||||
return UIEventSource.pass
|
||||
}
|
||||
}
|
||||
|
||||
public addCallbackAndRunD(callback: (data: T) => void): (() => void) {
|
||||
return this.addCallbackAndRun(data => {
|
||||
if (data !== undefined && data !== null) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public addCallbackD(callback: (data: T) => void): (() => void) {
|
||||
return this.addCallback(data => {
|
||||
if (data !== undefined && data !== null) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public setData(t: T): UIEventSource<T> {
|
||||
if (this.data == t) { // MUST COMPARE BY REFERENCE!
|
||||
return;
|
||||
}
|
||||
this.data = t;
|
||||
this._callbacks.ping(t)
|
||||
return this;
|
||||
}
|
||||
|
||||
public ping(): void {
|
||||
this._callbacks.ping(this.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monoidal map which results in a read-only store
|
||||
* Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)'
|
||||
* @param f: The transforming function
|
||||
* @param extraSources: also trigger the update if one of these sources change
|
||||
*
|
||||
* const src = new UIEventSource<number>(10)
|
||||
* const store = src.map(i => i * 2)
|
||||
* store.data // => 20
|
||||
* let srcSeen = undefined;
|
||||
* src.addCallback(v => {
|
||||
* console.log("Triggered")
|
||||
* srcSeen = v
|
||||
* })
|
||||
* let lastSeen = undefined
|
||||
* store.addCallback(v => {
|
||||
* console.log("Triggered!")
|
||||
* lastSeen = v
|
||||
* })
|
||||
* src.setData(21)
|
||||
* srcSeen // => 21
|
||||
* lastSeen // => 42
|
||||
*/
|
||||
public map<J>(f: ((t: T) => J),
|
||||
extraSources: Store<any>[] = []): Store<J> {
|
||||
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two way sync with functions in both directions
|
||||
* Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)'
|
||||
* @param f: The transforming function
|
||||
* @param extraSources: also trigger the update if one of these sources change
|
||||
* @param g: a 'backfunction to let the sync run in two directions. (data of the new UIEVEntSource, currentData) => newData
|
||||
* @param allowUnregister: if set, the update will be halted if no listeners are registered
|
||||
*/
|
||||
public map<J>(f: ((t: T) => J),
|
||||
extraSources: UIEventSource<any>[] = [],
|
||||
g: ((j: J, t: T) => T) = undefined,
|
||||
allowUnregister = false): UIEventSource<J> {
|
||||
public sync<J>(f: ((t: T) => J),
|
||||
extraSources: Store<any>[],
|
||||
g: ((j: J, t: T) => T),
|
||||
allowUnregister = false): UIEventSource<J> {
|
||||
const self = this;
|
||||
|
||||
const stack = new Error().stack.split("\n");
|
||||
|
@ -305,7 +698,7 @@ export class UIEventSource<T> {
|
|||
|
||||
const update = function () {
|
||||
newSource.setData(f(self.data));
|
||||
return allowUnregister && newSource._callbacks.length === 0
|
||||
return allowUnregister && newSource._callbacks.length() === 0
|
||||
}
|
||||
|
||||
this.addCallback(update);
|
||||
|
@ -327,7 +720,7 @@ export class UIEventSource<T> {
|
|||
const self = this;
|
||||
otherSource.addCallback((latest) => self.setData(latest));
|
||||
if (reverseOverride) {
|
||||
if(otherSource.data !== undefined){
|
||||
if (otherSource.data !== undefined) {
|
||||
this.setData(otherSource.data);
|
||||
}
|
||||
} else if (this.data === undefined) {
|
||||
|
@ -338,40 +731,4 @@ export class UIEventSource<T> {
|
|||
return this;
|
||||
}
|
||||
|
||||
public stabilized(millisToStabilize): UIEventSource<T> {
|
||||
if (Utils.runningFromConsole) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const newSource = new UIEventSource<T>(this.data);
|
||||
|
||||
let currentCallback = 0;
|
||||
this.addCallback(latestData => {
|
||||
currentCallback++;
|
||||
const thisCallback = currentCallback;
|
||||
window.setTimeout(() => {
|
||||
if (thisCallback === currentCallback) {
|
||||
newSource.setData(latestData);
|
||||
}
|
||||
}, millisToStabilize)
|
||||
});
|
||||
|
||||
return newSource;
|
||||
}
|
||||
|
||||
addCallbackAndRunD(callback: (data: T) => void) {
|
||||
this.addCallbackAndRun(data => {
|
||||
if (data !== undefined && data !== null) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addCallbackD(callback: (data: T) => void) {
|
||||
this.addCallback(data => {
|
||||
if (data !== undefined && data !== null) {
|
||||
return callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,19 +7,30 @@ import {Utils} from "../../Utils";
|
|||
*/
|
||||
export class IdbLocalStorage {
|
||||
|
||||
|
||||
public static Get<T>(key: string, options?: { defaultValue?: T, whenLoaded?: (t: T) => void }): UIEventSource<T> {
|
||||
private static readonly _sourceCache: Record<string, UIEventSource<any>> = {}
|
||||
|
||||
public static Get<T>(key: string, options?: { defaultValue?: T, whenLoaded?: (t: T | null) => void }): UIEventSource<T> {
|
||||
if(IdbLocalStorage._sourceCache[key] !== undefined){
|
||||
return IdbLocalStorage._sourceCache[key]
|
||||
}
|
||||
const src = new UIEventSource<T>(options?.defaultValue, "idb-local-storage:" + key)
|
||||
if (Utils.runningFromConsole) {
|
||||
return src;
|
||||
}
|
||||
src.addCallback(v => idb.set(key, v))
|
||||
|
||||
idb.get(key).then(v => {
|
||||
src.setData(v ?? options?.defaultValue);
|
||||
if (options?.whenLoaded !== undefined) {
|
||||
options?.whenLoaded(v)
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn("Loading from local storage failed due to", err)
|
||||
if (options?.whenLoaded !== undefined) {
|
||||
options?.whenLoaded(null)
|
||||
}
|
||||
})
|
||||
src.addCallback(v => idb.set(key, v))
|
||||
IdbLocalStorage._sourceCache[key] = src;
|
||||
return src;
|
||||
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import {UIEventSource} from "../UIEventSource";
|
|||
export class LocalStorageSource {
|
||||
|
||||
static GetParsed<T>(key: string, defaultValue: T): UIEventSource<T> {
|
||||
return LocalStorageSource.Get(key).map(
|
||||
return LocalStorageSource.Get(key).sync(
|
||||
str => {
|
||||
if (str === undefined) {
|
||||
return defaultValue
|
||||
|
|
|
@ -8,7 +8,7 @@ import {Utils} from "../../Utils";
|
|||
export class QueryParameters {
|
||||
|
||||
static defaults = {}
|
||||
static documentation = {}
|
||||
static documentation: Map<string, string> = new Map<string, string>()
|
||||
private static order: string [] = ["layout", "test", "z", "lat", "lon"];
|
||||
private static _wasInitialized: Set<string> = new Set()
|
||||
private static knownSources = {};
|
||||
|
@ -18,7 +18,7 @@ export class QueryParameters {
|
|||
if (!this.initialized) {
|
||||
this.init();
|
||||
}
|
||||
QueryParameters.documentation[key] = documentation;
|
||||
QueryParameters.documentation.set(key, documentation);
|
||||
if (deflt !== undefined) {
|
||||
QueryParameters.defaults[key] = deflt;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export class QueryParameters {
|
|||
}
|
||||
|
||||
public static GetBooleanQueryParameter(key: string, deflt: boolean, documentation?: string): UIEventSource<boolean> {
|
||||
return QueryParameters.GetQueryParameter(key, ""+ deflt, documentation).map(str => str === "true", [], b => "" + b)
|
||||
return QueryParameters.GetQueryParameter(key, ""+ deflt, documentation).sync(str => str === "true", [], b => "" + b)
|
||||
}
|
||||
|
||||
|
||||
|
@ -91,8 +91,10 @@ 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());
|
||||
if(!Utils.runningFromConsole){
|
||||
// Don't pollute the history every time a parameter changes
|
||||
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {UIEventSource} from "../UIEventSource";
|
||||
import {Store} from "../UIEventSource";
|
||||
|
||||
export interface Review {
|
||||
comment?: string,
|
||||
|
@ -9,5 +9,5 @@ export interface Review {
|
|||
/**
|
||||
* True if the current logged in user is the creator of this comment
|
||||
*/
|
||||
made_by_user: UIEventSource<boolean>
|
||||
made_by_user: Store<boolean>
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import {Utils} from "../../Utils";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import * as wds from "wikibase-sdk"
|
||||
import * as wds from "wikidata-sdk"
|
||||
|
||||
export class WikidataResponse {
|
||||
public readonly id: string
|
||||
|
@ -126,13 +126,22 @@ export interface WikidataSearchoptions {
|
|||
maxCount?: 20 | number
|
||||
}
|
||||
|
||||
export interface WikidataAdvancedSearchoptions extends WikidataSearchoptions {
|
||||
instanceOf?: number[];
|
||||
notInstanceOf?: number[]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Utility functions around wikidata
|
||||
*/
|
||||
export default class Wikidata {
|
||||
|
||||
private static readonly _identifierPrefixes = ["Q", "L"].map(str => str.toLowerCase())
|
||||
private static readonly _prefixesToRemove = ["https://www.wikidata.org/wiki/Lexeme:", "https://www.wikidata.org/wiki/", "Lexeme:"].map(str => str.toLowerCase())
|
||||
private static readonly _prefixesToRemove = ["https://www.wikidata.org/wiki/Lexeme:",
|
||||
"https://www.wikidata.org/wiki/",
|
||||
"http://www.wikidata.org/entity/",
|
||||
"Lexeme:"].map(str => str.toLowerCase())
|
||||
|
||||
|
||||
private static readonly _cache = new Map<string, UIEventSource<{ success: WikidataResponse } | { error: any }>>()
|
||||
|
@ -148,6 +157,52 @@ export default class Wikidata {
|
|||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a search text, searches for the relevant wikidata entries, excluding pages "outside of the main tree", e.g. disambiguation pages.
|
||||
* Optionally, an 'instance of' can be given to limit the scope, e.g. instanceOf:5 (humans) will only search for humans
|
||||
*/
|
||||
public static async searchAdvanced(text: string, options: WikidataAdvancedSearchoptions): Promise<{
|
||||
id: string,
|
||||
relevance?: number,
|
||||
label: string,
|
||||
description?: string
|
||||
}[]> {
|
||||
let instanceOf = ""
|
||||
if (options?.instanceOf !== undefined && options.instanceOf.length > 0) {
|
||||
const phrases = options.instanceOf.map(q => `{ ?item wdt:P31/wdt:P279* wd:Q${q}. }`)
|
||||
instanceOf = "{"+ phrases.join(" UNION ") + "}"
|
||||
}
|
||||
const forbidden = (options?.notInstanceOf ?? [])
|
||||
.concat([17379835]) // blacklist 'wikimedia pages outside of the main knowledge tree', e.g. disambiguation pages
|
||||
const minusPhrases = forbidden.map(q => `MINUS {?item wdt:P31/wdt:P279* wd:Q${q} .}`)
|
||||
const sparql = `SELECT * WHERE {
|
||||
SERVICE wikibase:mwapi {
|
||||
bd:serviceParam wikibase:api "EntitySearch" .
|
||||
bd:serviceParam wikibase:endpoint "www.wikidata.org" .
|
||||
bd:serviceParam mwapi:search "${text}" .
|
||||
bd:serviceParam mwapi:language "${options.lang}" .
|
||||
?item wikibase:apiOutputItem mwapi:item .
|
||||
?num wikibase:apiOrdinal true .
|
||||
bd:serviceParam wikibase:limit ${Math.round((options.maxCount ?? 20) * 1.5) /*Some padding for disambiguation pages */} .
|
||||
?label wikibase:apiOutput mwapi:label .
|
||||
?description wikibase:apiOutput "@description" .
|
||||
}
|
||||
${instanceOf}
|
||||
${minusPhrases.join("\n ")}
|
||||
} ORDER BY ASC(?num) LIMIT ${options.maxCount ?? 20}`
|
||||
const url = wds.sparqlQuery(sparql)
|
||||
|
||||
const result = await Utils.downloadJson(url)
|
||||
/*The full uri of the wikidata-item*/
|
||||
|
||||
return result.results.bindings.map(({item, label, description, num}) => ({
|
||||
relevance: num?.value,
|
||||
id: item?.value,
|
||||
label: label?.value,
|
||||
description: description?.value
|
||||
}))
|
||||
}
|
||||
|
||||
public static async search(
|
||||
search: string,
|
||||
options?: WikidataSearchoptions,
|
||||
|
@ -195,35 +250,29 @@ export default class Wikidata {
|
|||
|
||||
public static async searchAndFetch(
|
||||
search: string,
|
||||
options?: WikidataSearchoptions
|
||||
options?: WikidataAdvancedSearchoptions
|
||||
): Promise<WikidataResponse[]> {
|
||||
const maxCount = options.maxCount
|
||||
// We provide some padding to filter away invalid values
|
||||
options.maxCount = Math.ceil((options.maxCount ?? 20) * 1.5)
|
||||
const searchResults = await Wikidata.search(search, options)
|
||||
const maybeResponses = await Promise.all(searchResults.map(async r => {
|
||||
try {
|
||||
return await Wikidata.LoadWikidataEntry(r.id).AsPromise()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return undefined;
|
||||
}
|
||||
}))
|
||||
const responses = maybeResponses
|
||||
.map(r => <WikidataResponse>r["success"])
|
||||
.filter(wd => {
|
||||
if (wd === undefined) {
|
||||
return false;
|
||||
const searchResults = await Wikidata.searchAdvanced(search, options)
|
||||
const maybeResponses = await Promise.all(
|
||||
searchResults.map(async r => {
|
||||
try {
|
||||
console.log("Loading ", r.id)
|
||||
return await Wikidata.LoadWikidataEntry(r.id).AsPromise()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return undefined;
|
||||
}
|
||||
if (wd.claims.get("P31" /*Instance of*/)?.has("Q4167410"/* Wikimedia Disambiguation page*/)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
responses.splice(maxCount, responses.length - maxCount)
|
||||
return responses
|
||||
}))
|
||||
return Utils.NoNull(maybeResponses.map(r => <WikidataResponse>r["success"]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the 'key' segment from a URL
|
||||
*
|
||||
* Wikidata.ExtractKey("https://www.wikidata.org/wiki/Lexeme:L614072") // => "L614072"
|
||||
* Wikidata.ExtractKey("http://www.wikidata.org/entity/Q55008046") // => "Q55008046"
|
||||
*/
|
||||
public static ExtractKey(value: string | number): string {
|
||||
if (typeof value === "number") {
|
||||
return "Q" + value
|
||||
|
@ -266,6 +315,35 @@ export default class Wikidata {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 'Q123' into 123, returns undefined if invalid
|
||||
*
|
||||
* Wikidata.QIdToNumber("Q123") // => 123
|
||||
* Wikidata.QIdToNumber(" Q123 ") // => 123
|
||||
* Wikidata.QIdToNumber(" X123 ") // => undefined
|
||||
* Wikidata.QIdToNumber(" Q123X ") // => undefined
|
||||
* Wikidata.QIdToNumber(undefined) // => undefined
|
||||
* Wikidata.QIdToNumber(123) // => 123
|
||||
*/
|
||||
public static QIdToNumber(q: string | number): number | undefined {
|
||||
if(q === undefined || q === null){
|
||||
return
|
||||
}
|
||||
if(typeof q === "number"){
|
||||
return q
|
||||
}
|
||||
q = q.trim()
|
||||
if (!q.startsWith("Q")) {
|
||||
return
|
||||
}
|
||||
q = q.substr(1)
|
||||
const n = Number(q)
|
||||
if (isNaN(n)) {
|
||||
return
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
public static IdToArticle(id: string) {
|
||||
if (id.startsWith("Q")) {
|
||||
return "https://wikidata.org/wiki/" + id
|
||||
|
@ -284,7 +362,7 @@ export default class Wikidata {
|
|||
const id = Wikidata.ExtractKey(value)
|
||||
if (id === undefined) {
|
||||
console.warn("Could not extract a wikidata entry from", value)
|
||||
throw "Could not extract a wikidata entry from " + value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = "https://www.wikidata.org/wiki/Special:EntityData/" + id + ".json";
|
||||
|
@ -300,4 +378,4 @@ export default class Wikidata {
|
|||
return WikidataResponse.fromJson(response)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import {Utils} from "../../Utils";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {WikipediaBoxOptions} from "../../UI/Wikipedia/WikipediaBox";
|
||||
|
||||
export default class Wikipedia {
|
||||
|
||||
|
@ -29,30 +30,139 @@ export default class Wikipedia {
|
|||
|
||||
private static readonly _cache = new Map<string, UIEventSource<{ success: string } | { error: any }>>()
|
||||
|
||||
public static GetArticle(options: {
|
||||
pageName: string,
|
||||
language?: "en" | string
|
||||
}): UIEventSource<{ success: string } | { error: any }> {
|
||||
const key = (options.language ?? "en") + ":" + options.pageName
|
||||
|
||||
public readonly backend: string;
|
||||
|
||||
constructor(options?: ({ language?: "en" | string } | { backend?: string })) {
|
||||
this.backend = Wikipedia.getBackendUrl(options ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract the language and article name from the given string
|
||||
*
|
||||
* Wikipedia.extractLanguageAndName("qsdf") // => undefined
|
||||
* Wikipedia.extractLanguageAndName("nl:Warandeputten") // => {language: "nl", pageName: "Warandeputten"}
|
||||
*/
|
||||
public static extractLanguageAndName(input: string): { language: string, pageName: string } {
|
||||
const matched = input.match("([^:]+):(.*)")
|
||||
if (matched === undefined || matched === null) {
|
||||
return undefined
|
||||
}
|
||||
const [_, language, pageName] = matched
|
||||
return {
|
||||
language, pageName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the actual pagename; returns undefined if this came from a different wikimedia entry
|
||||
*
|
||||
* new Wikipedia({backend: "https://wiki.openstreetmap.org"}).extractPageName("https://wiki.openstreetmap.org/wiki/NL:Speelbos") // => "NL:Speelbos"
|
||||
* new Wikipedia().extractPageName("https://wiki.openstreetmap.org/wiki/NL:Speelbos") // => undefined
|
||||
*/
|
||||
public extractPageName(input: string):string | undefined{
|
||||
if(!input.startsWith(this.backend)){
|
||||
return undefined
|
||||
}
|
||||
input = input.substring(this.backend.length);
|
||||
|
||||
const matched = input.match("/?wiki/\(.+\)")
|
||||
if (matched === undefined || matched === null) {
|
||||
return undefined
|
||||
}
|
||||
const [_, pageName] = matched
|
||||
return pageName
|
||||
}
|
||||
|
||||
private static getBackendUrl(options: { language?: "en" | string } | { backend?: "en.wikipedia.org" | string }): string {
|
||||
let backend = "en.wikipedia.org"
|
||||
if (options["backend"]) {
|
||||
backend = options["backend"]
|
||||
} else if (options["language"]) {
|
||||
backend = `${options["language"] ?? "en"}.wikipedia.org`
|
||||
}
|
||||
if (!backend.startsWith("http")) {
|
||||
backend = "https://" + backend
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
public GetArticle(pageName: string, options: WikipediaBoxOptions): UIEventSource<{ success: string } | { error: any }> {
|
||||
const key = this.backend + ":" + pageName + ":" + (options.firstParagraphOnly ?? false)
|
||||
const cached = Wikipedia._cache.get(key)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const v = UIEventSource.FromPromiseWithErr(Wikipedia.GetArticleAsync(options))
|
||||
const v = UIEventSource.FromPromiseWithErr(this.GetArticleAsync(pageName, options))
|
||||
Wikipedia._cache.set(key, v)
|
||||
return v;
|
||||
}
|
||||
|
||||
public static async GetArticleAsync(options: {
|
||||
pageName: string,
|
||||
language?: "en" | string
|
||||
}): Promise<string> {
|
||||
public getDataUrl(pageName: string): string {
|
||||
return `${this.backend}/w/api.php?action=parse&format=json&origin=*&prop=text&page=` + pageName
|
||||
}
|
||||
|
||||
const language = options.language ?? "en"
|
||||
const url = `https://${language}.wikipedia.org/w/api.php?action=parse&format=json&origin=*&prop=text&page=` + options.pageName
|
||||
const response = await Utils.downloadJson(url)
|
||||
public getPageUrl(pageName: string): string {
|
||||
return `${this.backend}/wiki/${pageName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Textual search of the specified wiki-instance. If searching Wikipedia, we recommend using wikidata.search instead
|
||||
* @param searchTerm
|
||||
*/
|
||||
public async search(searchTerm: string): Promise<{ title: string, snippet: string }[]> {
|
||||
const url = this.backend + "/w/api.php?action=query&format=json&list=search&srsearch=" + encodeURIComponent(searchTerm);
|
||||
return (await Utils.downloadJson(url))["query"]["search"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches via 'index.php' and scrapes the result.
|
||||
* This gives better results then via the API
|
||||
* @param searchTerm
|
||||
*/
|
||||
public async searchViaIndex(searchTerm: string): Promise<{ title: string, snippet: string, url: string } []> {
|
||||
const url = `${this.backend}/w/index.php?search=${encodeURIComponent(searchTerm)}&ns0=1`
|
||||
const result = await Utils.downloadAdvanced(url);
|
||||
if(result["redirect"] ){
|
||||
const targetUrl = result["redirect"]
|
||||
// This is an exact match
|
||||
return [{
|
||||
title: this.extractPageName(targetUrl)?.trim(),
|
||||
url: targetUrl,
|
||||
snippet: ""
|
||||
}]
|
||||
}
|
||||
const el = document.createElement('html');
|
||||
el.innerHTML = result["content"].replace(/href="\//g, "href=\""+this.backend+"/");
|
||||
const searchResults = el.getElementsByClassName("mw-search-results")
|
||||
const individualResults = Array.from(searchResults[0]?.getElementsByClassName("mw-search-result") ?? [])
|
||||
return individualResults.map(result => {
|
||||
const toRemove = Array.from(result.getElementsByClassName("searchalttitle"))
|
||||
for (const toRm of toRemove) {
|
||||
toRm.parentElement.removeChild(toRm)
|
||||
}
|
||||
|
||||
return {
|
||||
title: result.getElementsByClassName("mw-search-result-heading")[0].textContent.trim(),
|
||||
url: result.getElementsByTagName("a")[0].href,
|
||||
snippet: result.getElementsByClassName("searchresult")[0].textContent
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async GetArticleAsync(pageName: string, options:
|
||||
{
|
||||
firstParagraphOnly?: false | boolean
|
||||
}): Promise<string | undefined> {
|
||||
|
||||
const response = await Utils.downloadJson(this.getDataUrl(pageName))
|
||||
if (response?.parse?.text === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const html = response["parse"]["text"]["*"];
|
||||
|
||||
if (html === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const div = document.createElement("div")
|
||||
div.innerHTML = html
|
||||
const content = Array.from(div.children)[0]
|
||||
|
@ -76,9 +186,13 @@ export default class Wikipedia {
|
|||
links.filter(link => link.getAttribute("href")?.startsWith("/") ?? false).forEach(link => {
|
||||
link.target = '_blank'
|
||||
// note: link.getAttribute("href") gets the textual value, link.href is the rewritten version which'll contain the host for relative paths
|
||||
link.href = `https://${language}.wikipedia.org${link.getAttribute("href")}`;
|
||||
link.href = `${this.backend}${link.getAttribute("href")}`;
|
||||
})
|
||||
|
||||
if (options?.firstParagraphOnly) {
|
||||
return content.getElementsByTagName("p").item(0).innerHTML
|
||||
}
|
||||
|
||||
return content.innerHTML
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue