MapComplete/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts

87 lines
3.4 KiB
TypeScript

import { FeatureSource } from "../FeatureSource"
import { Feature, Point } from "geojson"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import { GeoOperations } from "../../GeoOperations"
import StaticFeatureSource from "../Sources/StaticFeatureSource"
import { Tiles } from "../../../Models/TileRange"
export interface ClusteringOptions {
/**
* If the zoomlevel is (strictly) above the specified value, don't cluster no matter how many features the tile contains.
*/
dontClusterAboveZoom?: number
/**
* If the number of features in a _tile_ is equal or more then this number,
* drop those features and emit a summary tile instead
*/
cutoff?: 20 | number
}
export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>> implements FeatureSource<T> {
public readonly summaryPoints: FeatureSource
private readonly id: string
features: Store<T[]>
/**
*The clustering feature source works _only_ on points and is a preprocessing step for the ShowDataLayer.
* If a tile contains many points, a 'summary' point is emitted instead in 'summaryPoints'.
* The points from the summary will _not_ be emitted in 'this.features' in that case.
*
* We ignore the polygons, as polygons get smaller when zoomed out and thus don't clutter the map too much
*/
constructor(upstream: FeatureSource<T>,
currentZoomlevel: Store<number>,
id: string,
options?: ClusteringOptions) {
this.id = id
const clusterCutoff = options?.dontClusterAboveZoom ?? 17
const doCluster = options?.dontClusterAboveZoom === undefined ? new ImmutableStore(true) : currentZoomlevel.map(zoom => zoom <= clusterCutoff)
const cutoff = options?.cutoff ?? 20
const summaryPoints = new UIEventSource<Feature<Point>[]>([])
currentZoomlevel = currentZoomlevel.stabilized(500)
this.summaryPoints = new StaticFeatureSource(summaryPoints)
this.features = (upstream.features.map(features => {
if (!doCluster.data) {
summaryPoints.set([])
return features
}
const z = currentZoomlevel.data
const perTile = GeoOperations.spreadIntoBboxes(features, z)
const resultingFeatures = []
const summary: Feature<Point>[] = []
for (const tileIndex of perTile.keys()) {
const tileFeatures: Feature<Point>[] = perTile.get(tileIndex)
if (tileFeatures.length > cutoff) {
summary.push(this.createSummaryFeature(tileFeatures, tileIndex))
} else {
resultingFeatures.push(...tileFeatures)
}
}
summaryPoints.set(summary)
return resultingFeatures
}, [doCluster, currentZoomlevel]))
}
private createSummaryFeature(features: Feature<Point>[], tileId: number): Feature<Point> {
const [z, x, y] = Tiles.tile_from_index(tileId)
const [lon, lat] = Tiles.centerPointOf(z, x, y)
return <Feature<Point>>{
type: "Feature",
geometry: {
type: "Point",
coordinates: [lon, lat]
},
properties: {
id: "summary_" + this.id + "_" + tileId,
z,
total_metric: "" + features.length
}
}
}
}