import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import FilteringFeatureSource from "./Sources/FilteringFeatureSource" import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter" import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "./FeatureSource" import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource" import { Store, UIEventSource } from "../UIEventSource" import { TileHierarchyTools } from "./TiledFeatureSource/TileHierarchy" import RememberingSource from "./Sources/RememberingSource" import OverpassFeatureSource from "../Actors/OverpassFeatureSource" import GeoJsonSource from "./Sources/GeoJsonSource" import Loc from "../../Models/Loc" import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor" import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor" import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource" import { TileHierarchyMerger } from "./TiledFeatureSource/TileHierarchyMerger" import RelationsTracker from "../Osm/RelationsTracker" import { NewGeometryFromChangesFeatureSource } from "./Sources/NewGeometryFromChangesFeatureSource" import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator" import { BBox } from "../BBox" import OsmFeatureSource from "./TiledFeatureSource/OsmFeatureSource" import { Tiles } from "../../Models/TileRange" 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" /** * The features pipeline ties together a myriad of various datasources: * * - The Overpass-API * - The OSM-API * - Third-party geojson files, either sliced or directly. * * In order to truly understand this class, please have a look at the following diagram: https://cdn-images-1.medium.com/fit/c/800/618/1*qTK1iCtyJUr4zOyw4IFD7A.jpeg * * */ export default class FeaturePipeline { public readonly sufficientlyZoomed: Store public readonly runningQuery: Store public readonly timeout: UIEventSource public readonly somethingLoaded: UIEventSource = new UIEventSource(false) public readonly newDataLoadedSignal: UIEventSource = new UIEventSource(undefined) public readonly relationTracker: RelationsTracker /** * Keeps track of all raw OSM-nodes. * Only initialized if 'type_node' is defined as layer */ public readonly fullNodeDatabase?: FullNodeDatabaseSource private readonly overpassUpdater: OverpassFeatureSource private state: MapState private readonly perLayerHierarchy: Map /** * Keeps track of the age of the loaded data. * Has one freshness-Calculator for every layer * @private */ private readonly freshnesses = new Map() private readonly oldestAllowedDate: Date private readonly osmSourceZoomLevel private readonly localStorageSavers = new Map() private readonly newGeometryHandler: NewGeometryFromChangesFeatureSource constructor( handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void, state: MapState, options?: { /*Used for metatagging - will receive all the sources with changeGeometry applied but without filtering*/ handleRawFeatureSource: (source: FeatureSourceForLayer) => void } ) { this.state = state const self = this 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) ) this.relationTracker = new RelationsTracker() state.changes.allChanges.addCallbackAndRun((allChanges) => { allChanges .filter((ch) => ch.id < 0 && ch.changes !== undefined) .map((ch) => ch.changes) .filter((coor) => coor["lat"] !== undefined && coor["lon"] !== undefined) .forEach((coor) => { state.layoutToUse.layers.forEach((l) => self.localStorageSavers.get(l.id)?.poison(coor["lon"], coor["lat"]) ) }) }) this.sufficientlyZoomed = state.locationControl.map((location) => { if (location?.zoom === undefined) { return false } let minzoom = Math.min( ...state.filteredLayers.data.map((layer) => layer.layerDef.minzoom ?? 18) ) return location.zoom >= minzoom }) const neededTilesFromOsm = this.getNeededTilesFromOsm(this.sufficientlyZoomed) const perLayerHierarchy = new Map() this.perLayerHierarchy = perLayerHierarchy // Given a tile, wraps it and passes it on to render (handled by 'handleFeatureSource' function patchedHandleFeatureSource( src: FeatureSourceForLayer & IndexedFeatureSource & Tiled ) { // This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile const withChanges = new ChangeGeometryApplicator(src, state.changes) const srcFiltered = new FilteringFeatureSource(state, src.tileIndex, withChanges) handleFeatureSource(srcFiltered) if (options?.handleRawFeatureSource) { options.handleRawFeatureSource(withChanges) } self.somethingLoaded.setData(true) // We do not mark as visited here, this is the responsability of the code near the actual loader (e.g. overpassLoader and OSMApiFeatureLoader) } function handlePriviligedFeatureSource(src: FeatureSourceForLayer & Tiled) { // Passthrough to passed function, except that it registers as well handleFeatureSource(src) src.features.addCallbackAndRunD((fs) => { fs.forEach((ff) => state.allElements.addOrGetElement(ff.feature)) }) } for (const filteredLayer of state.filteredLayers.data) { const id = filteredLayer.layerDef.id const source = filteredLayer.layerDef.source const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile) ) perLayerHierarchy.set(id, hierarchy) this.freshnesses.set(id, new TileFreshnessCalculator()) if (id === "type_node") { this.fullNodeDatabase = new FullNodeDatabaseSource(filteredLayer, (tile) => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile)) }) continue } if (id === "selected_element") { handlePriviligedFeatureSource(state.selectedElementsLayer) continue } if (id === "gps_location") { handlePriviligedFeatureSource(state.currentUserLocation) continue } if (id === "gps_location_history") { handlePriviligedFeatureSource(state.historicalUserLocations) continue } if (id === "gps_track") { handlePriviligedFeatureSource(state.historicalUserLocationsTrack) continue } if (id === "home_location") { handlePriviligedFeatureSource(state.homeLocation) continue } if (id === "current_view") { handlePriviligedFeatureSource(state.currentView) continue } const localTileSaver = new SaveTileToLocalStorageActor(filteredLayer) this.localStorageSavers.set(filteredLayer.layerDef.id, localTileSaver) if (source.geojsonSource === undefined) { // This is an OSM layer // We load the cached values and register them // Getting data from upstream happens a bit lower localTileSaver.LoadTilesFromDisk( state.currentBounds, state.locationControl, (tileIndex, freshness) => self.freshnesses.get(id).addTileLoad(tileIndex, freshness), (tile) => { console.debug("Loaded tile ", id, tile.tileIndex, "from local cache") new RegisteringAllFromFeatureSourceActor(tile, state.allElements) hierarchy.registerTile(tile) tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile)) } ) continue } if (source.geojsonZoomLevel === undefined) { // This is a 'load everything at once' geojson layer const src = new GeoJsonSource(filteredLayer) if (source.isOsmCacheLayer) { // We split them up into tiles anyway as it is an OSM source TiledFeatureSource.createHierarchy(src, { layer: src.layer, minZoomLevel: this.osmSourceZoomLevel, noDuplicates: true, registerTile: (tile) => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(id).registerTile(tile) tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile)) }, }) } else { new RegisteringAllFromFeatureSourceActor(src, state.allElements) perLayerHierarchy.get(id).registerTile(src) src.features.addCallbackAndRunD((_) => self.onNewDataLoaded(src)) } } else { new DynamicGeoJsonTileSource( filteredLayer, (tile) => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) perLayerHierarchy.get(id).registerTile(tile) tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile)) }, state ) } } const osmFeatureSource = new OsmFeatureSource({ isActive: useOsmApi, neededTiles: neededTilesFromOsm, handleTile: (tile) => { new RegisteringAllFromFeatureSourceActor(tile, state.allElements) if (tile.layer.layerDef.maxAgeOfCache > 0) { const saver = self.localStorageSavers.get(tile.layer.layerDef.id) if (saver === undefined) { console.error( "No localStorageSaver found for layer ", tile.layer.layerDef.id ) } saver?.addTile(tile) } perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile)) }, state: state, markTileVisited: (tileId) => state.filteredLayers.data.forEach((flayer) => { const layer = flayer.layerDef if (layer.maxAgeOfCache > 0) { const saver = self.localStorageSavers.get(layer.id) if (saver === undefined) { console.error("No local storage saver found for ", layer.id) } else { saver.MarkVisited(tileId, new Date()) } } self.freshnesses.get(layer.id).addTileLoad(tileId, new Date()) }), }) if (this.fullNodeDatabase !== undefined) { osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => this.fullNodeDatabase.handleOsmJson(osmJson, tileId) ) } const updater = this.initOverpassUpdater(state, useOsmApi) this.overpassUpdater = updater this.timeout = updater.timeout // Actually load data from the overpass source new PerLayerFeatureSourceSplitter( state.filteredLayers, (source) => TiledFeatureSource.createHierarchy(source, { layer: source.layer, minZoomLevel: source.layer.layerDef.minzoom, noDuplicates: true, maxFeatureCount: state.layoutToUse.clustering.minNeededElements, maxZoomLevel: state.layoutToUse.clustering.maxZoom, registerTile: (tile) => { // We save the tile data for the given layer to local storage - data sourced from overpass self.localStorageSavers.get(tile.layer.layerDef.id)?.addTile(tile) perLayerHierarchy .get(source.layer.layerDef.id) .registerTile(new RememberingSource(tile)) tile.features.addCallbackAndRunD((f) => { if (f.length === 0) { return } self.onNewDataLoaded(tile) }) }, }), updater, { handleLeftovers: (leftOvers) => { console.warn("Overpass returned a few non-matched features:", leftOvers) }, } ) // Also load points/lines that are newly added. const newGeometry = new NewGeometryFromChangesFeatureSource( state.changes, state.allElements, state.osmConnection._oauth_config.url ) this.newGeometryHandler = newGeometry newGeometry.features.addCallbackAndRun((geometries) => { console.debug("New geometries are:", geometries) }) new RegisteringAllFromFeatureSourceActor(newGeometry, state.allElements) // A NewGeometryFromChangesFeatureSource does not split per layer, so we do this next new PerLayerFeatureSourceSplitter( state.filteredLayers, (perLayer) => { // We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer) // AT last, we always apply the metatags whenever possible perLayer.features.addCallbackAndRunD((_) => { self.onNewDataLoaded(perLayer) }) }, newGeometry, { handleLeftovers: (leftOvers) => { console.warn("Got some leftovers from the filteredLayers: ", leftOvers) }, } ) this.runningQuery = updater.runningQuery.map( (overpass) => { console.log( "FeaturePipeline: runningQuery state changed: Overpass", overpass ? "is querying," : "is idle,", "osmFeatureSource is", osmFeatureSource.isRunning ? "is running and needs " + neededTilesFromOsm.data?.length + " tiles (already got " + osmFeatureSource.downloadedTiles.size + " tiles )" : "is idle" ) return overpass || osmFeatureSource.isRunning.data }, [osmFeatureSource.isRunning] ) } public GetAllFeaturesWithin(bbox: BBox): OsmFeature[][] { const self = this const tiles: OsmFeature[][] = [] Array.from(this.perLayerHierarchy.keys()).forEach((key) => { const fetched: OsmFeature[][] = self.GetFeaturesWithin(key, bbox) tiles.push(...fetched) }) return tiles } public GetAllFeaturesAndMetaWithin( bbox: BBox, layerIdWhitelist?: Set ): { 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) } const requestedHierarchy = this.perLayerHierarchy.get(layerId) if (requestedHierarchy === undefined) { console.warn( "Layer ", layerId, "is not defined. Try one of ", Array.from(this.perLayerHierarchy.keys()) ) return undefined } return TileHierarchyTools.getTiles(requestedHierarchy, bbox) .filter((featureSource) => featureSource.features?.data !== undefined) .map((featureSource) => featureSource.features.data.map((fs) => fs.feature)) } public GetTilesPerLayerWithin( bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void ) { Array.from(this.perLayerHierarchy.values()).forEach((hierarchy) => { TileHierarchyTools.getTiles(hierarchy, bbox).forEach(handleTile) }) } private onNewDataLoaded(src: FeatureSource) { this.newDataLoadedSignal.setData(src) } private freshnessForVisibleLayers(z: number, x: number, y: number): Date { let oldestDate = undefined for (const flayer of this.state.filteredLayers.data) { if (!flayer.isDisplayed.data && !flayer.layerDef.forceLoad) { continue } if (this.state.locationControl.data.zoom < flayer.layerDef.minzoom) { continue } if (flayer.layerDef.maxAgeOfCache === 0) { return undefined } const freshnessCalc = this.freshnesses.get(flayer.layerDef.id) if (freshnessCalc === undefined) { console.warn("No freshness tracker found for ", flayer.layerDef.id) return undefined } const freshness = freshnessCalc.freshnessFor(z, x, y) if (freshness === undefined) { // SOmething is undefined --> we return undefined as we have to download return undefined } if (oldestDate === undefined || oldestDate > freshness) { oldestDate = freshness } } return oldestDate } /* * Gives an UIEventSource containing the tileIndexes of the tiles that should be loaded from OSM * */ private getNeededTilesFromOsm(isSufficientlyZoomed: Store): Store { const self = this return this.state.currentBounds.map( (bbox) => { if (bbox === undefined) { return [] } if (!isSufficientlyZoomed.data) { return [] } const osmSourceZoomLevel = self.osmSourceZoomLevel const range = bbox.containingTileRange(osmSourceZoomLevel) const tileIndexes = [] if (range.total >= 100) { // Too much tiles! return undefined } Tiles.MapRange(range, (x, y) => { const i = Tiles.tile_index(osmSourceZoomLevel, x, y) const oldestDate = self.freshnessForVisibleLayers(osmSourceZoomLevel, x, y) if (oldestDate !== undefined && oldestDate > this.oldestAllowedDate) { console.debug( "Skipping tile", osmSourceZoomLevel, x, y, "as a decently fresh one is available" ) // The cached tiles contain decently fresh data return undefined } tileIndexes.push(i) }) return tileIndexes }, [isSufficientlyZoomed] ) } private initOverpassUpdater( state: { allElements: ElementStorage layoutToUse: LayoutConfig currentBounds: Store locationControl: Store readonly overpassUrl: Store readonly overpassTimeout: Store readonly overpassMaxZoom: Store }, useOsmApi: Store ): OverpassFeatureSource { const minzoom = Math.min(...state.layoutToUse.layers.map((layer) => layer.minzoom)) const overpassIsActive = state.currentBounds.map( (bbox) => { if (bbox === undefined) { console.debug("Disabling overpass source: no bbox") return false } let zoom = state.locationControl.data.zoom if (zoom < minzoom) { // We are zoomed out over the zoomlevel of any layer console.debug("Disabling overpass source: zoom < minzoom") return false } const range = bbox.containingTileRange(zoom) if (range.total >= 5000) { // Let's assume we don't have so much data cached return true } const self = this const allFreshnesses = Tiles.MapRange(range, (x, y) => self.freshnessForVisibleLayers(zoom, x, y) ) return allFreshnesses.some( (freshness) => freshness === undefined || freshness < this.oldestAllowedDate ) }, [state.locationControl] ) const self = this const updater = new OverpassFeatureSource(state, { padToTiles: state.locationControl.map((l) => Math.min(15, l.zoom + 1)), relationTracker: this.relationTracker, isActive: useOsmApi.map((b) => !b && overpassIsActive.data, [overpassIsActive]), freshnesses: this.freshnesses, onBboxLoaded: (bbox, date, downloadedLayers, paddedToZoomLevel) => { Tiles.MapRange(bbox.containingTileRange(paddedToZoomLevel), (x, y) => { const tileIndex = Tiles.tile_index(paddedToZoomLevel, x, y) downloadedLayers.forEach((layer) => { self.freshnesses.get(layer.id).addTileLoad(tileIndex, date) self.localStorageSavers.get(layer.id)?.MarkVisited(tileIndex, date) }) }) }, }) // Register everything in the state' 'AllElements' new RegisteringAllFromFeatureSourceActor(updater, state.allElements) 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() 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 */ InjectNewPoint(geojson) { this.newGeometryHandler.features.data.push({ feature: geojson, freshness: new Date(), }) this.newGeometryHandler.features.ping() } }