From 0048c091d0439f8306e5a14b612ca8522e39e456 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 24 Jul 2025 15:57:30 +0200 Subject: [PATCH] Feature: more or less working version of clustering, clusters of multiple layers are joined --- src/Logic/FeatureSource/FeatureSource.ts | 14 ++-- .../PerLayerFeatureSourceSplitter.ts | 22 ++--- src/Logic/FeatureSource/Sources/MvtSource.ts | 19 ++--- .../Sources/SimpleFeatureSource.ts | 11 +-- .../ClusteringFeatureSource.ts | 84 ++++++++++++++++--- .../ThemeViewState/WithSpecialLayers.ts | 16 +++- src/Models/TileRange.ts | 13 ++- src/UI/Map/ShowDataLayer.ts | 24 +++--- src/UI/Map/ShowDataLayerOptions.ts | 5 +- 9 files changed, 143 insertions(+), 65 deletions(-) diff --git a/src/Logic/FeatureSource/FeatureSource.ts b/src/Logic/FeatureSource/FeatureSource.ts index a0a32d29c2..076302f434 100644 --- a/src/Logic/FeatureSource/FeatureSource.ts +++ b/src/Logic/FeatureSource/FeatureSource.ts @@ -1,25 +1,27 @@ import { Store, UIEventSource } from "../UIEventSource" import FilteredLayer from "../../Models/FilteredLayer" -import { Feature } from "geojson" +import { Feature, Geometry } from "geojson" +import { OsmTags } from "../../Models/OsmFeature" -export interface FeatureSource { +export interface FeatureSource> { features: Store } -export interface UpdatableFeatureSource extends FeatureSource { +export interface UpdatableFeatureSource> extends FeatureSource { /** * Forces an update and downloads the data, even if the feature source is supposed to be active */ - updateAsync() + updateAsync(): void } -export interface WritableFeatureSource extends FeatureSource { + +export interface WritableFeatureSource> extends FeatureSource { features: UIEventSource } /** * A feature source which only contains features for the defined layer */ -export interface FeatureSourceForLayer extends FeatureSource { +export interface FeatureSourceForLayer> extends FeatureSource { readonly layer: FilteredLayer } diff --git a/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index ac9c7cb8e4..e3b8579415 100644 --- a/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -11,17 +11,17 @@ import { UIEventSource } from "../UIEventSource" * 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 */ -export default class PerLayerFeatureSourceSplitter { - public readonly perLayer: ReadonlyMap +export default class PerLayerFeatureSourceSplitter> { + public readonly perLayer: ReadonlyMap constructor( layers: FilteredLayer[], - upstream: FeatureSource, + upstream: FeatureSource, options?: { - constructStore?: (features: UIEventSource, layer: FilteredLayer) => T - handleLeftovers?: (featuresWithoutLayer: Feature[]) => void + constructStore?: (features: UIEventSource, layer: FilteredLayer) => SRC + handleLeftovers?: (featuresWithoutLayer: T[]) => void } ) { - const knownLayers = new Map() + const knownLayers = new Map() /** * Keeps track of the ids that are included per layer. * Used to know if the downstream feature source needs to be pinged @@ -32,12 +32,12 @@ export default class PerLayerFeatureSourceSplitter new SimpleFeatureSource(layer, store)) for (const layer of layers) { - const src = new UIEventSource([]) + const src = new UIEventSource([]) layerSources.set(layer.layerDef.id, src) - knownLayers.set(layer.layerDef.id, constructStore(src, layer)) + knownLayers.set(layer.layerDef.id, constructStore(src, layer)) } - upstream.features.addCallbackAndRunD((features) => { + upstream.features.addCallbackAndRunD((features: T[]) => { if (layers === undefined) { return } @@ -51,7 +51,7 @@ export default class PerLayerFeatureSourceSplitter false) const newIndices: Set[] = layers.map(() => new Set()) - const noLayerFound: Feature[] = [] + const noLayerFound: T[] = [] for (const layer of layers) { featuresPerLayer.set(layer.layerDef.id, []) @@ -115,7 +115,7 @@ export default class PerLayerFeatureSourceSplitter void) { + public forEach(f: ((src: SRC) => void)) { for (const fs of this.perLayer.values()) { f(fs) } diff --git a/src/Logic/FeatureSource/Sources/MvtSource.ts b/src/Logic/FeatureSource/Sources/MvtSource.ts index 619424df14..184259b3c8 100644 --- a/src/Logic/FeatureSource/Sources/MvtSource.ts +++ b/src/Logic/FeatureSource/Sources/MvtSource.ts @@ -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 { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource" import { MvtToGeojson } from "mvt-to-geojson" +import { OsmTags } from "../../../Models/OsmFeature" export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource { - public readonly features: Store[]> + public readonly features: Store[]> public readonly x: number public readonly y: number public readonly z: number private readonly _url: string - private readonly _layerName: string private readonly _features: UIEventSource< - GeojsonFeature< - Geometry, - { - [name: string]: any - } - >[] - > = new UIEventSource[]>([]) + GeojsonFeature[] + > = new UIEventSource[]>([]) private currentlyRunning: Promise constructor( @@ -26,11 +21,9 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature x: number, y: number, z: number, - layerName?: string, isActive?: Store ) { this._url = url - this._layerName = layerName this.x = x this.y = y this.z = z @@ -61,7 +54,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature return } const buffer = await result.arrayBuffer() - const features = MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z) + const features = []>MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z) for (const feature of features) { const properties = feature.properties if (!properties["osm_type"]) { diff --git a/src/Logic/FeatureSource/Sources/SimpleFeatureSource.ts b/src/Logic/FeatureSource/Sources/SimpleFeatureSource.ts index c280bebcbd..f9880b1712 100644 --- a/src/Logic/FeatureSource/Sources/SimpleFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/SimpleFeatureSource.ts @@ -1,14 +1,15 @@ import { UIEventSource } from "../../UIEventSource" import FilteredLayer from "../../../Models/FilteredLayer" import { FeatureSourceForLayer } from "../FeatureSource" -import { Feature } from "geojson" +import { Feature, Geometry } from "geojson" +import { OsmTags } from "../../../Models/OsmFeature" -export default class SimpleFeatureSource implements FeatureSourceForLayer { - public readonly features: UIEventSource +export default class SimpleFeatureSource> implements FeatureSourceForLayer { + public readonly features: UIEventSource public readonly layer: FilteredLayer - constructor(layer: FilteredLayer, featureSource?: UIEventSource) { + constructor(layer: FilteredLayer, featureSource?: UIEventSource) { this.layer = layer - this.features = featureSource ?? new UIEventSource([]) + this.features = featureSource ?? new UIEventSource([]) } } diff --git a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts index 71f89b5187..c5c6296005 100644 --- a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts +++ b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts @@ -2,7 +2,6 @@ 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 { @@ -19,9 +18,14 @@ export interface ClusteringOptions { showSummaryAt?: "tilecenter" | "average" } +interface SummaryProperties { + id: string, + total: number, + tile_id: number +} + export class ClusteringFeatureSource = Feature> implements FeatureSource { - public readonly summaryPoints: FeatureSource private readonly id: string private readonly showSummaryAt: "tilecenter" | "average" features: Store @@ -42,11 +46,9 @@ export class ClusteringFeatureSource = Feature> 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[]>([]) - currentZoomlevel = currentZoomlevel.stabilized(500) - this.summaryPoints = new StaticFeatureSource(summaryPoints) + const summaryPoints = new UIEventSource[]>([]) + currentZoomlevel = currentZoomlevel.stabilized(500).map(z => Math.floor(z)) this.features = (upstream.features.map(features => { - console.log(">>> Updating features in clusters ", this.id, ":", features) if (!doCluster.data) { summaryPoints.set([]) return features @@ -55,7 +57,7 @@ export class ClusteringFeatureSource = Feature> const z = currentZoomlevel.data const perTile = GeoOperations.spreadIntoBboxes(features, z) const resultingFeatures = [] - const summary: Feature[] = [] + const summary: Feature[] = [] for (const tileIndex of perTile.keys()) { const tileFeatures: Feature[] = perTile.get(tileIndex) if (tileFeatures.length > cutoff) { @@ -69,11 +71,12 @@ export class ClusteringFeatureSource = Feature> }, [doCluster, currentZoomlevel])) + ClusterGrouping.singleton.registerSource(summaryPoints) } - private createSummaryFeature(features: Feature[], tileId: number): Feature { + private createSummaryFeature(features: Feature[], tileId: number): Feature { let lon: number let lat: number @@ -91,7 +94,7 @@ export class ClusteringFeatureSource = Feature> lon = lonSum / features.length lat = latSum / features.length } - return >{ + return { type: "Feature", geometry: { type: "Point", @@ -99,9 +102,68 @@ export class ClusteringFeatureSource = Feature> }, properties: { id: "summary_" + this.id + "_" + tileId, - z, - total_metric: "" + features.length + tile_id: tileId, + total: features.length } } } } + +/** + * Groups multiple summaries together + */ +export class ClusterGrouping implements FeatureSource> { + private readonly _features: UIEventSource[]> = new UIEventSource([]) + public readonly features: Store[]> = 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[]>[] = [] + + private update() { + const countPerTile = new Map() + 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[] = [] + 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[]>) { + this.allSource.push(features) + features.addCallbackAndRun(() => { + //this.isDirty.set(true) + this.update() + }) + } + +} diff --git a/src/Models/ThemeViewState/WithSpecialLayers.ts b/src/Models/ThemeViewState/WithSpecialLayers.ts index 1aeccfb6ef..071e67e326 100644 --- a/src/Models/ThemeViewState/WithSpecialLayers.ts +++ b/src/Models/ThemeViewState/WithSpecialLayers.ts @@ -18,9 +18,10 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource" import { SummaryTileSource, - SummaryTileSourceRewriter, + SummaryTileSourceRewriter } from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource" import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions" +import { ClusterGrouping } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" export class WithSpecialLayers extends WithChangesState { readonly favourites: FavouritesFeatureSource @@ -61,6 +62,7 @@ export class WithSpecialLayers extends WithChangesState { this.closestFeatures.registerSource(this.favourites, "favourite") this.featureSummary = this.setupSummaryLayer() + this.setupClusterLayer() this.initActorsSpecialLayers() this.drawSelectedElement() this.drawSpecialLayers() @@ -128,6 +130,18 @@ export class WithSpecialLayers extends WithChangesState { 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((summaryLayer), "summaryLayer") + }) + } + protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) { if (!source?.features) { return diff --git a/src/Models/TileRange.ts b/src/Models/TileRange.ts index d4494da6df..8d1d87f0bc 100644 --- a/src/Models/TileRange.ts +++ b/src/Models/TileRange.ts @@ -53,11 +53,20 @@ export class Tiles { /** * Returns the centerpoint [lon, lat] of the specified tile - * @param z + * @param z OR tileId * @param x * @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 [ (Tiles.tile2long(x, z) + Tiles.tile2long(x + 1, z)) / 2, (Tiles.tile2lat(y, z) + Tiles.tile2lat(y + 1, z)) / 2, diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 5b28747f47..4d1e5c0447 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -3,9 +3,9 @@ import type { AddLayerObject, Map as MlMap } from "maplibre-gl" import { GeoJSONSource } from "maplibre-gl" import { ShowDataLayerOptions } from "./ShowDataLayerOptions" 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 { Feature } from "geojson" +import { Feature, Geometry } from "geojson" import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" import { Utils } from "../../Utils" 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 { PointRenderingLayer } from "./PointRenderingLayer" import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" -import summaryLayer from "../../../public/assets/generated/layers/summary.json" +import { OsmTags } from "../../Models/OsmFeature" class LineRenderingLayer { /** @@ -361,7 +361,7 @@ export default class ShowDataLayer { layers: LayerConfig[], options?: Partial> ) { - const perLayer: PerLayerFeatureSourceSplitter = + const perLayer = new PerLayerFeatureSourceSplitter( layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)), features, @@ -379,10 +379,10 @@ export default class ShowDataLayer { }) } - perLayer.forEach((fs) => { + perLayer.forEach((features) => { new ShowDataLayer(mlmap, { - layer: fs.layer.layerDef, - features: fs, + layer: features.layer.layerDef, + features, ...(options ?? {}), }) }) @@ -396,14 +396,10 @@ export default class ShowDataLayer { const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), options.layer.id, { - cutoff: 2, + cutoff: 7, showSummaryAt: "tilecenter" }) - new ShowDataLayer(mlmap, { - features: clustering.summaryPoints, - layer: new LayerConfig((summaryLayer), "summaryLayer") - // doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom), - }) + return clustering } new ShowDataLayer(mlmap, options) @@ -411,7 +407,7 @@ export default class ShowDataLayer { public static showRange( map: Store, - features: FeatureSource, + features: FeatureSource>, doShowLayer?: Store ): ShowDataLayer { return new ShowDataLayer(map, { diff --git a/src/UI/Map/ShowDataLayerOptions.ts b/src/UI/Map/ShowDataLayerOptions.ts index d5e7fe0780..5c1371aac6 100644 --- a/src/UI/Map/ShowDataLayerOptions.ts +++ b/src/UI/Map/ShowDataLayerOptions.ts @@ -1,12 +1,13 @@ import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" 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 { /** * Features to show */ - features: FeatureSource + features: FeatureSource> /** * Indication of the current selected element; overrides some filters. * When a feature is tapped, the feature will be put in there