Merge master

This commit is contained in:
Pieter Vander Vennet 2022-08-02 19:46:16 +02:00
commit be2816bd0e
1396 changed files with 1287846 additions and 69687 deletions

View file

@ -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);
}

View file

@ -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)

View file

@ -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) {

View file

@ -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>

View file

@ -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

View file

@ -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";
}
}

View file

@ -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 => {

View file

@ -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(

View file

@ -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);

View file

@ -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(),

View file

@ -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) {

View file

@ -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)

View file

@ -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
*/

View file

@ -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>>
}

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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})
})
}
}

View file

@ -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")

View file

@ -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])
})
}

View file

@ -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)
}

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -34,7 +34,7 @@ export default class GenericImageProvider extends ImageProvider {
return undefined;
}
protected DownloadAttribution(url: string) {
public DownloadAttribution(url: string) {
return undefined
}

View file

@ -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>;
}

View file

@ -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, "");
}

View file

@ -1,7 +1,7 @@
export class LicenseInfo {
title: string = ""
artist: string = "";
license: string = "";
license: string = undefined;
licenseShortName: string = "";
usageTerms: string = "";
attributionRequired: boolean = false;

View file

@ -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,

View file

@ -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!");
}

View file

@ -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
View 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}`);
}
}
}

View file

@ -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,

View file

@ -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)

View file

@ -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
}) {

View file

@ -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",

View file

@ -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])

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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 {
}
}
}

View file

@ -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)
}
}

View file

@ -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();

View file

@ -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"
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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")

View file

@ -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);
}
}

View file

@ -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

View file

@ -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,

View file

@ -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>())
};

View file

@ -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
}
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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);
}
}

View file

@ -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));
}

View file

@ -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)
}
})
}
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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());
}
}
}

View file

@ -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>
}

View file

@ -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)
}
}
}

View file

@ -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
}