mapcomplete/Logic/FeatureSource/TiledFeatureSource/TiledFeatureSource.ts

248 lines
9 KiB
TypeScript

import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import TileHierarchy from "./TileHierarchy"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
/**
* Contains all features in a tiled fashion.
* The data will be automatically broken down into subtiles when there are too much features in a single tile or if the zoomlevel is too high
*/
export default class TiledFeatureSource
implements
Tiled,
IndexedFeatureSource,
FeatureSourceForLayer,
TileHierarchy<IndexedFeatureSource & FeatureSourceForLayer & Tiled>
{
public readonly z: number
public readonly x: number
public readonly y: number
public readonly parent: TiledFeatureSource
public readonly root: TiledFeatureSource
public readonly layer: FilteredLayer
/* An index of all known tiles. allTiles[z][x][y].get('layerid') will yield the corresponding tile.
* Only defined on the root element!
*/
public readonly loadedTiles: Map<number, TiledFeatureSource & FeatureSourceForLayer> = undefined
public readonly maxFeatureCount: number
public readonly name
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly containedIds: Store<Set<string>>
public readonly bbox: BBox
public readonly tileIndex: number
private upper_left: TiledFeatureSource
private upper_right: TiledFeatureSource
private lower_left: TiledFeatureSource
private lower_right: TiledFeatureSource
private readonly maxzoom: number
private readonly options: TiledFeatureSourceOptions
private constructor(
z: number,
x: number,
y: number,
parent: TiledFeatureSource,
options?: TiledFeatureSourceOptions
) {
this.z = z
this.x = x
this.y = y
this.bbox = BBox.fromTile(z, x, y)
this.tileIndex = Tiles.tile_index(z, x, y)
this.name = `TiledFeatureSource(${z},${x},${y})`
this.parent = parent
this.layer = options.layer
options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 250
this.maxzoom = options.maxZoomLevel ?? 18
this.options = options
if (parent === undefined) {
throw "Parent is not allowed to be undefined. Use null instead"
}
if (parent === null && z !== 0 && x !== 0 && y !== 0) {
throw "Invalid root tile: z, x and y should all be null"
}
if (parent === null) {
this.root = this
this.loadedTiles = new Map()
} else {
this.root = this.parent.root
this.loadedTiles = this.root.loadedTiles
const i = Tiles.tile_index(z, x, y)
this.root.loadedTiles.set(i, this)
}
this.features = new UIEventSource<any[]>([])
this.containedIds = this.features.map((features) => {
if (features === undefined) {
return undefined
}
return new Set(features.map((f) => f.feature.properties.id))
})
// We register this tile, but only when there is some data in it
if (this.options.registerTile !== undefined) {
this.features.addCallbackAndRunD((features) => {
if (features.length === 0) {
return
}
this.options.registerTile(this)
return true
})
}
}
public static createHierarchy(
features: FeatureSource,
options?: TiledFeatureSourceOptions
): TiledFeatureSource {
options = {
...options,
layer: features["layer"] ?? options.layer,
}
const root = new TiledFeatureSource(0, 0, 0, null, options)
features.features?.addCallbackAndRunD((feats) => root.addFeatures(feats))
return root
}
private isSplitNeeded(featureCount: number) {
if (this.upper_left !== undefined) {
// This tile has been split previously, so we keep on splitting
return true
}
if (this.z >= this.maxzoom) {
// We are not allowed to split any further
return false
}
if (this.options.minZoomLevel !== undefined && this.z < this.options.minZoomLevel) {
// We must have at least this zoom level before we are allowed to start splitting
return true
}
// To much features - we split
return featureCount > this.maxFeatureCount
}
/***
* Adds the list of features to this hierarchy.
* If there are too much features, the list will be broken down and distributed over the subtiles (only retaining features that don't fit a subtile on this level)
* @param features
* @private
*/
private addFeatures(features: { feature: any; freshness: Date }[]) {
if (features === undefined || features.length === 0) {
return
}
if (!this.isSplitNeeded(features.length)) {
this.features.setData(features)
return
}
if (this.upper_left === undefined) {
this.upper_left = new TiledFeatureSource(
this.z + 1,
this.x * 2,
this.y * 2,
this,
this.options
)
this.upper_right = new TiledFeatureSource(
this.z + 1,
this.x * 2 + 1,
this.y * 2,
this,
this.options
)
this.lower_left = new TiledFeatureSource(
this.z + 1,
this.x * 2,
this.y * 2 + 1,
this,
this.options
)
this.lower_right = new TiledFeatureSource(
this.z + 1,
this.x * 2 + 1,
this.y * 2 + 1,
this,
this.options
)
}
const ulf = []
const urf = []
const llf = []
const lrf = []
const overlapsboundary = []
for (const feature of features) {
const bbox = BBox.get(feature.feature)
// There are a few strategies to deal with features that cross tile boundaries
if (this.options.noDuplicates) {
// Strategy 1: We put the feature into a somewhat matching tile
if (bbox.overlapsWith(this.upper_left.bbox)) {
ulf.push(feature)
} else if (bbox.overlapsWith(this.upper_right.bbox)) {
urf.push(feature)
} else if (bbox.overlapsWith(this.lower_left.bbox)) {
llf.push(feature)
} else if (bbox.overlapsWith(this.lower_right.bbox)) {
lrf.push(feature)
} else {
overlapsboundary.push(feature)
}
} else if (this.options.minZoomLevel === undefined) {
// Strategy 2: put it into a strictly matching tile (or in this tile, which is slightly too big)
if (bbox.isContainedIn(this.upper_left.bbox)) {
ulf.push(feature)
} else if (bbox.isContainedIn(this.upper_right.bbox)) {
urf.push(feature)
} else if (bbox.isContainedIn(this.lower_left.bbox)) {
llf.push(feature)
} else if (bbox.isContainedIn(this.lower_right.bbox)) {
lrf.push(feature)
} else {
overlapsboundary.push(feature)
}
} else {
// Strategy 3: We duplicate a feature on a boundary into every tile as we need to get to the minZoomLevel
if (bbox.overlapsWith(this.upper_left.bbox)) {
ulf.push(feature)
}
if (bbox.overlapsWith(this.upper_right.bbox)) {
urf.push(feature)
}
if (bbox.overlapsWith(this.lower_left.bbox)) {
llf.push(feature)
}
if (bbox.overlapsWith(this.lower_right.bbox)) {
lrf.push(feature)
}
}
}
this.upper_left.addFeatures(ulf)
this.upper_right.addFeatures(urf)
this.lower_left.addFeatures(llf)
this.lower_right.addFeatures(lrf)
this.features.setData(overlapsboundary)
}
}
export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number
readonly maxZoomLevel?: number
readonly minZoomLevel?: number
/**
* IF minZoomLevel is set, and if a feature runs through a tile boundary, it would normally be duplicated.
* Setting 'dontEnforceMinZoomLevel' will assign to feature to some matching subtile.
*/
readonly noDuplicates?: boolean
readonly registerTile?: (tile: TiledFeatureSource & FeatureSourceForLayer & Tiled) => void
readonly layer?: FilteredLayer
}