Feature: more or less working version of clustering, clusters of multiple layers are joined

This commit is contained in:
Pieter Vander Vennet 2025-07-24 15:57:30 +02:00
parent 5bc8f11d24
commit 0048c091d0
9 changed files with 143 additions and 65 deletions

View file

@ -1,25 +1,27 @@
import { Store, UIEventSource } from "../UIEventSource" import { Store, UIEventSource } from "../UIEventSource"
import FilteredLayer from "../../Models/FilteredLayer" import FilteredLayer from "../../Models/FilteredLayer"
import { Feature } from "geojson" import { Feature, Geometry } from "geojson"
import { OsmTags } from "../../Models/OsmFeature"
export interface FeatureSource<T extends Feature = Feature> { export interface FeatureSource<T extends Feature = Feature<Geometry, OsmTags>> {
features: Store<T[]> features: Store<T[]>
} }
export interface UpdatableFeatureSource<T extends Feature = Feature> extends FeatureSource<T> { export interface UpdatableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
/** /**
* Forces an update and downloads the data, even if the feature source is supposed to be active * Forces an update and downloads the data, even if the feature source is supposed to be active
*/ */
updateAsync() updateAsync(): void
} }
export interface WritableFeatureSource<T extends Feature = Feature> extends FeatureSource<T> {
export interface WritableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
features: UIEventSource<T[]> features: UIEventSource<T[]>
} }
/** /**
* A feature source which only contains features for the defined layer * A feature source which only contains features for the defined layer
*/ */
export interface FeatureSourceForLayer<T extends Feature = Feature> extends FeatureSource<T> { export interface FeatureSourceForLayer<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
readonly layer: FilteredLayer readonly layer: FilteredLayer
} }

View file

@ -11,17 +11,17 @@ import { UIEventSource } from "../UIEventSource"
* If this is the case, multiple objects with a different _matching_layer_id are generated. * If this is the case, multiple objects with a different _matching_layer_id are generated.
* In any case, this featureSource marks the objects with _matching_layer_id * In any case, this featureSource marks the objects with _matching_layer_id
*/ */
export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = FeatureSource> { export default class PerLayerFeatureSourceSplitter<T extends Feature, SRC extends FeatureSource<T>> {
public readonly perLayer: ReadonlyMap<string, T> public readonly perLayer: ReadonlyMap<string, SRC>
constructor( constructor(
layers: FilteredLayer[], layers: FilteredLayer[],
upstream: FeatureSource, upstream: FeatureSource<T>,
options?: { options?: {
constructStore?: (features: UIEventSource<Feature[]>, layer: FilteredLayer) => T constructStore?: (features: UIEventSource<T[]>, layer: FilteredLayer) => SRC
handleLeftovers?: (featuresWithoutLayer: Feature[]) => void handleLeftovers?: (featuresWithoutLayer: T[]) => void
} }
) { ) {
const knownLayers = new Map<string, T>() const knownLayers = new Map<string, SRC>()
/** /**
* Keeps track of the ids that are included per layer. * Keeps track of the ids that are included per layer.
* Used to know if the downstream feature source needs to be pinged * Used to know if the downstream feature source needs to be pinged
@ -32,12 +32,12 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
const constructStore = const constructStore =
options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store)) options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store))
for (const layer of layers) { for (const layer of layers) {
const src = new UIEventSource<Feature[]>([]) const src = new UIEventSource<T[]>([])
layerSources.set(layer.layerDef.id, src) layerSources.set(layer.layerDef.id, src)
knownLayers.set(layer.layerDef.id, <T>constructStore(src, layer)) knownLayers.set(layer.layerDef.id, <SRC>constructStore(src, layer))
} }
upstream.features.addCallbackAndRunD((features) => { upstream.features.addCallbackAndRunD((features: T[]) => {
if (layers === undefined) { if (layers === undefined) {
return return
} }
@ -51,7 +51,7 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
*/ */
const hasChanged: boolean[] = layers.map(() => false) const hasChanged: boolean[] = layers.map(() => false)
const newIndices: Set<string>[] = layers.map(() => new Set<string>()) const newIndices: Set<string>[] = layers.map(() => new Set<string>())
const noLayerFound: Feature[] = [] const noLayerFound: T[] = []
for (const layer of layers) { for (const layer of layers) {
featuresPerLayer.set(layer.layerDef.id, []) featuresPerLayer.set(layer.layerDef.id, [])
@ -115,7 +115,7 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
}) })
} }
public forEach(f: (featureSource: T) => void) { public forEach(f: ((src: SRC) => void)) {
for (const fs of this.perLayer.values()) { for (const fs of this.perLayer.values()) {
f(fs) f(fs)
} }

View file

@ -1,24 +1,19 @@
import { Feature as GeojsonFeature, Geometry } from "geojson" import { Feature, Feature as GeojsonFeature, Geometry } from "geojson"
import { Store, UIEventSource } from "../../UIEventSource" import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource" import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource"
import { MvtToGeojson } from "mvt-to-geojson" import { MvtToGeojson } from "mvt-to-geojson"
import { OsmTags } from "../../../Models/OsmFeature"
export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource { export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource {
public readonly features: Store<GeojsonFeature<Geometry, { [name: string]: any }>[]> public readonly features: Store<GeojsonFeature<Geometry, OsmTags>[]>
public readonly x: number public readonly x: number
public readonly y: number public readonly y: number
public readonly z: number public readonly z: number
private readonly _url: string private readonly _url: string
private readonly _layerName: string
private readonly _features: UIEventSource< private readonly _features: UIEventSource<
GeojsonFeature< GeojsonFeature<Geometry, OsmTags>[]
Geometry, > = new UIEventSource<GeojsonFeature<Geometry, OsmTags>[]>([])
{
[name: string]: any
}
>[]
> = new UIEventSource<GeojsonFeature<Geometry, { [p: string]: any }>[]>([])
private currentlyRunning: Promise<any> private currentlyRunning: Promise<any>
constructor( constructor(
@ -26,11 +21,9 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
x: number, x: number,
y: number, y: number,
z: number, z: number,
layerName?: string,
isActive?: Store<boolean> isActive?: Store<boolean>
) { ) {
this._url = url this._url = url
this._layerName = layerName
this.x = x this.x = x
this.y = y this.y = y
this.z = z this.z = z
@ -61,7 +54,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
return return
} }
const buffer = await result.arrayBuffer() const buffer = await result.arrayBuffer()
const features = MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z) const features = <Feature<Geometry, OsmTags>[]>MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z)
for (const feature of features) { for (const feature of features) {
const properties = feature.properties const properties = feature.properties
if (!properties["osm_type"]) { if (!properties["osm_type"]) {

View file

@ -1,14 +1,15 @@
import { UIEventSource } from "../../UIEventSource" import { UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer" import FilteredLayer from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer } from "../FeatureSource" import { FeatureSourceForLayer } from "../FeatureSource"
import { Feature } from "geojson" import { Feature, Geometry } from "geojson"
import { OsmTags } from "../../../Models/OsmFeature"
export default class SimpleFeatureSource implements FeatureSourceForLayer { export default class SimpleFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> implements FeatureSourceForLayer<T> {
public readonly features: UIEventSource<Feature[]> public readonly features: UIEventSource<T[]>
public readonly layer: FilteredLayer public readonly layer: FilteredLayer
constructor(layer: FilteredLayer, featureSource?: UIEventSource<Feature[]>) { constructor(layer: FilteredLayer, featureSource?: UIEventSource<T[]>) {
this.layer = layer this.layer = layer
this.features = featureSource ?? new UIEventSource<Feature[]>([]) this.features = featureSource ?? new UIEventSource<T[]>([])
} }
} }

View file

@ -2,7 +2,6 @@ import { FeatureSource } from "../FeatureSource"
import { Feature, Point } from "geojson" import { Feature, Point } from "geojson"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource" import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import { GeoOperations } from "../../GeoOperations" import { GeoOperations } from "../../GeoOperations"
import StaticFeatureSource from "../Sources/StaticFeatureSource"
import { Tiles } from "../../../Models/TileRange" import { Tiles } from "../../../Models/TileRange"
export interface ClusteringOptions { export interface ClusteringOptions {
@ -19,9 +18,14 @@ export interface ClusteringOptions {
showSummaryAt?: "tilecenter" | "average" showSummaryAt?: "tilecenter" | "average"
} }
interface SummaryProperties {
id: string,
total: number,
tile_id: number
}
export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>> implements FeatureSource<T> { export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>> implements FeatureSource<T> {
public readonly summaryPoints: FeatureSource
private readonly id: string private readonly id: string
private readonly showSummaryAt: "tilecenter" | "average" private readonly showSummaryAt: "tilecenter" | "average"
features: Store<T[]> features: Store<T[]>
@ -42,11 +46,9 @@ export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>>
const clusterCutoff = options?.dontClusterAboveZoom ?? 17 const clusterCutoff = options?.dontClusterAboveZoom ?? 17
const doCluster = options?.dontClusterAboveZoom === undefined ? new ImmutableStore(true) : currentZoomlevel.map(zoom => zoom <= clusterCutoff) const doCluster = options?.dontClusterAboveZoom === undefined ? new ImmutableStore(true) : currentZoomlevel.map(zoom => zoom <= clusterCutoff)
const cutoff = options?.cutoff ?? 20 const cutoff = options?.cutoff ?? 20
const summaryPoints = new UIEventSource<Feature<Point>[]>([]) const summaryPoints = new UIEventSource<Feature<Point, SummaryProperties>[]>([])
currentZoomlevel = currentZoomlevel.stabilized(500) currentZoomlevel = currentZoomlevel.stabilized(500).map(z => Math.floor(z))
this.summaryPoints = new StaticFeatureSource(summaryPoints)
this.features = (upstream.features.map(features => { this.features = (upstream.features.map(features => {
console.log(">>> Updating features in clusters ", this.id, ":", features)
if (!doCluster.data) { if (!doCluster.data) {
summaryPoints.set([]) summaryPoints.set([])
return features return features
@ -55,7 +57,7 @@ export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>>
const z = currentZoomlevel.data const z = currentZoomlevel.data
const perTile = GeoOperations.spreadIntoBboxes(features, z) const perTile = GeoOperations.spreadIntoBboxes(features, z)
const resultingFeatures = [] const resultingFeatures = []
const summary: Feature<Point>[] = [] const summary: Feature<Point, SummaryProperties>[] = []
for (const tileIndex of perTile.keys()) { for (const tileIndex of perTile.keys()) {
const tileFeatures: Feature<Point>[] = perTile.get(tileIndex) const tileFeatures: Feature<Point>[] = perTile.get(tileIndex)
if (tileFeatures.length > cutoff) { if (tileFeatures.length > cutoff) {
@ -69,11 +71,12 @@ export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>>
}, [doCluster, currentZoomlevel])) }, [doCluster, currentZoomlevel]))
ClusterGrouping.singleton.registerSource(summaryPoints)
} }
private createSummaryFeature(features: Feature<Point>[], tileId: number): Feature<Point> { private createSummaryFeature(features: Feature<Point>[], tileId: number): Feature<Point, SummaryProperties> {
let lon: number let lon: number
let lat: number let lat: number
@ -91,7 +94,7 @@ export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>>
lon = lonSum / features.length lon = lonSum / features.length
lat = latSum / features.length lat = latSum / features.length
} }
return <Feature<Point>>{ return {
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "Point", type: "Point",
@ -99,9 +102,68 @@ export class ClusteringFeatureSource<T extends Feature<Point> = Feature<Point>>
}, },
properties: { properties: {
id: "summary_" + this.id + "_" + tileId, id: "summary_" + this.id + "_" + tileId,
z, tile_id: tileId,
total_metric: "" + features.length total: features.length
} }
} }
} }
} }
/**
* Groups multiple summaries together
*/
export class ClusterGrouping implements FeatureSource<Feature<Point, { total_metric: string }>> {
private readonly _features: UIEventSource<Feature<Point, { total_metric: string }>[]> = new UIEventSource([])
public readonly features: Store<Feature<Point, { total_metric: string }>[]> = this._features
public static readonly singleton = new ClusterGrouping()
public readonly isDirty = new UIEventSource(false)
private constructor() {
this.isDirty.stabilized(200).addCallback(dirty => {
if (dirty) {
this.update()
}
})
}
private allSource: Store<Feature<Point, { total: number, tile_id: number }>[]>[] = []
private update() {
const countPerTile = new Map<number, number>()
for (const source of this.allSource) {
for (const f of source.data) {
const id = f.properties.tile_id
const count = f.properties.total + (countPerTile.get(id) ?? 0)
countPerTile.set(id, count)
}
}
const features: Feature<Point, { total_metric: string, id: string }>[] = []
for (const tileId of countPerTile.keys()) {
const coordinates = Tiles.centerPointOf(tileId)
features.push({
type: "Feature",
properties: {
total_metric: "" + countPerTile.get(tileId),
id: "clustered_all_" + tileId
},
geometry: {
type: "Point",
coordinates
}
})
}
this._features.set(features)
this.isDirty.set(false)
}
public registerSource(features: Store<Feature<Point, SummaryProperties>[]>) {
this.allSource.push(features)
features.addCallbackAndRun(() => {
//this.isDirty.set(true)
this.update()
})
}
}

View file

@ -18,9 +18,10 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource"
import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource" import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource"
import { import {
SummaryTileSource, SummaryTileSource,
SummaryTileSourceRewriter, SummaryTileSourceRewriter
} from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource" } from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource"
import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions" import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions"
import { ClusterGrouping } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource"
export class WithSpecialLayers extends WithChangesState { export class WithSpecialLayers extends WithChangesState {
readonly favourites: FavouritesFeatureSource readonly favourites: FavouritesFeatureSource
@ -61,6 +62,7 @@ export class WithSpecialLayers extends WithChangesState {
this.closestFeatures.registerSource(this.favourites, "favourite") this.closestFeatures.registerSource(this.favourites, "favourite")
this.featureSummary = this.setupSummaryLayer() this.featureSummary = this.setupSummaryLayer()
this.setupClusterLayer()
this.initActorsSpecialLayers() this.initActorsSpecialLayers()
this.drawSelectedElement() this.drawSelectedElement()
this.drawSpecialLayers() this.drawSpecialLayers()
@ -128,6 +130,18 @@ export class WithSpecialLayers extends WithChangesState {
return source return source
} }
/**
* On high zoom levels, the clusters will be gathered in the GroupClustering.
* This method is responsible for drawing it
* @private
*/
private setupClusterLayer(): void {
new ShowDataLayer(this.map, {
features: ClusterGrouping.singleton,
layer: new LayerConfig(<LayerConfigJson>(<unknown>summaryLayer), "summaryLayer")
})
}
protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) { protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) {
if (!source?.features) { if (!source?.features) {
return return

View file

@ -53,11 +53,20 @@ export class Tiles {
/** /**
* Returns the centerpoint [lon, lat] of the specified tile * Returns the centerpoint [lon, lat] of the specified tile
* @param z * @param z OR tileId
* @param x * @param x
* @param y * @param y
*/ */
static centerPointOf(z: number, x: number, y: number): [number, number] { static centerPointOf(z: number, x: number, y: number): [number, number] ;
static centerPointOf(tileId: number): [number, number] ;
static centerPointOf(zOrId: number, x?: number, y?: number): [number, number] {
let z: number
if (x === undefined) {
[z, x, y] = Tiles.tile_from_index(zOrId)
} else {
z = zOrId
}
return [ return [
(Tiles.tile2long(x, z) + Tiles.tile2long(x + 1, z)) / 2, (Tiles.tile2long(x, z) + Tiles.tile2long(x + 1, z)) / 2,
(Tiles.tile2lat(y, z) + Tiles.tile2lat(y + 1, z)) / 2, (Tiles.tile2lat(y, z) + Tiles.tile2lat(y + 1, z)) / 2,

View file

@ -3,9 +3,9 @@ import type { AddLayerObject, Map as MlMap } from "maplibre-gl"
import { GeoJSONSource } from "maplibre-gl" import { GeoJSONSource } from "maplibre-gl"
import { ShowDataLayerOptions } from "./ShowDataLayerOptions" import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource" import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { BBox } from "../../Logic/BBox" import { BBox } from "../../Logic/BBox"
import { Feature } from "geojson" import { Feature, Geometry } from "geojson"
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import * as range_layer from "../../../assets/layers/range/range.json" import * as range_layer from "../../../assets/layers/range/range.json"
@ -16,7 +16,7 @@ import { TagsFilter } from "../../Logic/Tags/TagsFilter"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import { PointRenderingLayer } from "./PointRenderingLayer" import { PointRenderingLayer } from "./PointRenderingLayer"
import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource"
import summaryLayer from "../../../public/assets/generated/layers/summary.json" import { OsmTags } from "../../Models/OsmFeature"
class LineRenderingLayer { class LineRenderingLayer {
/** /**
@ -361,7 +361,7 @@ export default class ShowDataLayer {
layers: LayerConfig[], layers: LayerConfig[],
options?: Partial<Omit<ShowDataLayerOptions, "features" | "layer">> options?: Partial<Omit<ShowDataLayerOptions, "features" | "layer">>
) { ) {
const perLayer: PerLayerFeatureSourceSplitter<FeatureSourceForLayer> = const perLayer =
new PerLayerFeatureSourceSplitter( new PerLayerFeatureSourceSplitter(
layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)), layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)),
features, features,
@ -379,10 +379,10 @@ export default class ShowDataLayer {
}) })
} }
perLayer.forEach((fs) => { perLayer.forEach((features) => {
new ShowDataLayer(mlmap, { new ShowDataLayer(mlmap, {
layer: fs.layer.layerDef, layer: features.layer.layerDef,
features: fs, features,
...(options ?? {}), ...(options ?? {}),
}) })
}) })
@ -396,14 +396,10 @@ export default class ShowDataLayer {
const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2),
options.layer.id, options.layer.id,
{ {
cutoff: 2, cutoff: 7,
showSummaryAt: "tilecenter" showSummaryAt: "tilecenter"
}) })
new ShowDataLayer(mlmap, {
features: clustering.summaryPoints,
layer: new LayerConfig(<LayerConfigJson>(<unknown>summaryLayer), "summaryLayer")
// doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom),
})
return clustering return clustering
} }
new ShowDataLayer(mlmap, options) new ShowDataLayer(mlmap, options)
@ -411,7 +407,7 @@ export default class ShowDataLayer {
public static showRange( public static showRange(
map: Store<MlMap>, map: Store<MlMap>,
features: FeatureSource, features: FeatureSource<Feature<Geometry, OsmTags>>,
doShowLayer?: Store<boolean> doShowLayer?: Store<boolean>
): ShowDataLayer { ): ShowDataLayer {
return new ShowDataLayer(map, { return new ShowDataLayer(map, {

View file

@ -1,12 +1,13 @@
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { Store, UIEventSource } from "../../Logic/UIEventSource" import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Feature, Point } from "geojson" import { Feature, Geometry, Point } from "geojson"
import { OsmTags } from "../../Models/OsmFeature"
export interface ShowDataLayerOptions { export interface ShowDataLayerOptions {
/** /**
* Features to show * Features to show
*/ */
features: FeatureSource features: FeatureSource<Feature<Geometry, OsmTags>>
/** /**
* Indication of the current selected element; overrides some filters. * Indication of the current selected element; overrides some filters.
* When a feature is tapped, the feature will be put in there * When a feature is tapped, the feature will be put in there