From 8360ab9a8bec4e74cb9013b97b35009367011295 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 21 Jul 2025 12:57:04 +0200 Subject: [PATCH 1/5] Feature: first version of clustering at low zoom levels, filters don't update yet (WIP) --- .../PerLayerFeatureSourceSplitter.ts | 2 + .../Sources/IfVisibleFeatureSource.ts | 38 +++ .../ClusteringFeatureSource.ts | 87 ++++++ src/Logic/GeoOperations.ts | 24 +- .../ThemeConfig/PointRenderingConfig.ts | 23 +- .../UserMapFeatureswitchState.ts | 23 -- src/Models/ThemeViewState/WithChangesState.ts | 22 +- src/UI/Map/PointRenderingLayer.ts | 277 ++++++++++++++++++ src/UI/Map/ShowDataLayer.ts | 242 ++------------- src/UI/Map/ShowDataLayerOptions.ts | 5 +- .../ClusteringFeatureSource.spec.ts | 81 +++++ 11 files changed, 562 insertions(+), 262 deletions(-) create mode 100644 src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts create mode 100644 src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts create mode 100644 src/UI/Map/PointRenderingLayer.ts create mode 100644 test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts diff --git a/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index 9f3aadd9a..ac9c7cb8e 100644 --- a/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -5,6 +5,8 @@ import { Feature } from "geojson" import { UIEventSource } from "../UIEventSource" /** + * Constructs multiple featureStores based on the given layers, where every constructed feature source will contain features only matching the given layer + * * In some rare cases, some elements are shown on multiple layers (when 'passthrough' is enabled) * 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 diff --git a/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts b/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts new file mode 100644 index 000000000..b52660368 --- /dev/null +++ b/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts @@ -0,0 +1,38 @@ +import { FeatureSource } from "../FeatureSource" +import { Feature } from "geojson" +import { Store, UIEventSource } from "../../UIEventSource" + +export class IfVisibleFeatureSource implements FeatureSource { + + private readonly _features: UIEventSource = new UIEventSource([]) + public readonly features: Store = this._features + + constructor(upstream: FeatureSource, visible: Store) { + + let dirty = false + upstream.features.addCallbackAndRun(features => { + if (!visible.data) { + console.log(">>> not writing data as not visible") + dirty = true + return + } + this._features.set(features) + dirty = false + }) + + visible.addCallbackAndRun(isVisible => { + if (isVisible && dirty) { + this._features.set(upstream.features.data) + dirty = false + } + if (!visible) { + this._features.set([]) + } + + }) + + + } + + +} diff --git a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts new file mode 100644 index 000000000..de2899470 --- /dev/null +++ b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts @@ -0,0 +1,87 @@ +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 = Feature> implements FeatureSource { + + public readonly summaryPoints: FeatureSource + private readonly id: string + features: Store + + /** + *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, + currentZoomlevel: Store, + 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[]>([]) + 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[] = [] + for (const tileIndex of perTile.keys()) { + const tileFeatures: Feature[] = 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[], tileId: number): Feature { + const [z, x, y] = Tiles.tile_from_index(tileId) + const [lon, lat] = Tiles.centerPointOf(z, x, y) + return >{ + type: "Feature", + geometry: { + type: "Point", + coordinates: [lon, lat] + }, + properties: { + id: "summary_" + this.id + "_" + tileId, + z, + total_metric: "" + features.length + } + } + } +} diff --git a/src/Logic/GeoOperations.ts b/src/Logic/GeoOperations.ts index 7e69831dc..e23df3157 100644 --- a/src/Logic/GeoOperations.ts +++ b/src/Logic/GeoOperations.ts @@ -10,11 +10,12 @@ import { MultiPolygon, Point, Polygon, - Position, + Position } from "geojson" import { Tiles } from "../Models/TileRange" import { Utils } from "../Utils" -;("use strict") + +("use strict") export class GeoOperations { private static readonly _earthRadius: number = 6378137 @@ -536,10 +537,23 @@ export class GeoOperations { * @param features * @param zoomlevel */ - public static spreadIntoBboxes(features: Feature[], zoomlevel: number): Map { - const perBbox = new Map() - + public static spreadIntoBboxes(features: T[], zoomlevel: number): Map { + const perBbox = new Map() + const z = zoomlevel for (const feature of features) { + if (feature.geometry.type === "Point") { + const [lon, lat] = feature.geometry.coordinates + const tileXYZ = Tiles.embedded_tile(lat, lon, z) + const tileNumber = Tiles.tile_index(z, tileXYZ.x, tileXYZ.y) + let newFeatureList = perBbox.get(tileNumber) + if (newFeatureList === undefined) { + newFeatureList = [] + perBbox.set(tileNumber, newFeatureList) + } + newFeatureList.push(feature) + + continue + } const bbox = BBox.get(feature) const tilerange = bbox.expandToTileBounds(zoomlevel).containingTileRange(zoomlevel) Tiles.MapRange(tilerange, (x, y) => { diff --git a/src/Models/ThemeConfig/PointRenderingConfig.ts b/src/Models/ThemeConfig/PointRenderingConfig.ts index 49a7d75ed..b9d8750cb 100644 --- a/src/Models/ThemeConfig/PointRenderingConfig.ts +++ b/src/Models/ThemeConfig/PointRenderingConfig.ts @@ -31,8 +31,11 @@ export class IconConfig extends WithContextLoader { } } +export const allowed_location_codes = ["point", "centroid", "start", "end", "projected_centerpoint", "polygon_centroid", "waypoints"] as const +export type PointRenderingLocation = typeof allowed_location_codes[number] + export default class PointRenderingConfig extends WithContextLoader { - static readonly allowed_location_codes: ReadonlySet = new Set([ + static readonly allowed_location_codes_set: ReadonlySet = new Set([ "point", "centroid", "start", @@ -41,16 +44,7 @@ export default class PointRenderingConfig extends WithContextLoader { "polygon_centroid", "waypoints", ]) - public readonly location: Set< - | "point" - | "centroid" - | "start" - | "end" - | "projected_centerpoint" - | "polygon_centroid" - | "waypoints" - | string - > + public readonly location: Set public readonly marker: IconConfig[] public readonly iconBadges: { if: TagsFilter; then: TagRenderingConfig }[] @@ -77,10 +71,10 @@ export default class PointRenderingConfig extends WithContextLoader { json.location = [json.location] } - this.location = new Set(json.location) + this.location = new Set(json.location) this.location.forEach((l) => { - const allowed = PointRenderingConfig.allowed_location_codes + const allowed = PointRenderingConfig.allowed_location_codes_set if (!allowed.has(l)) { throw `A point rendering has an invalid location: '${l}' is not one of ${Array.from( allowed @@ -313,10 +307,9 @@ export default class PointRenderingConfig extends WithContextLoader { } const cssLabel = this.labelCss?.GetRenderValue(tags.data)?.txt const cssClassesLabel = this.labelCssClasses?.GetRenderValue(tags.data)?.txt - const self = this return new VariableUiElement( tags.map((tags) => { - const label = self.label + const label = this.label ?.GetRenderValue(tags) ?.Subs(tags) ?.SetClass("flex items-center justify-center absolute marker-label") diff --git a/src/Models/ThemeViewState/UserMapFeatureswitchState.ts b/src/Models/ThemeViewState/UserMapFeatureswitchState.ts index d4582e74d..53d662427 100644 --- a/src/Models/ThemeViewState/UserMapFeatureswitchState.ts +++ b/src/Models/ThemeViewState/UserMapFeatureswitchState.ts @@ -255,27 +255,4 @@ export class UserMapFeatureswitchState extends WithUserRelatedState { } ) } - - /** - * Shows the current GPS-location marker on the given map. - * This is used to show the location on _other_ maps, e.g. on the map to add a new feature. - * - * This is _NOT_ to be used on the main map! - */ - public showCurrentLocationOn(map: Store) { - const id = "gps_location" - const layer = this.theme.getLayer(id) - if (layer === undefined) { - return - } - if (map === this.map) { - throw "Invalid use of showCurrentLocationOn" - } - const features = this.geolocation.currentUserLocation - return new ShowDataLayer(map, { - features, - layer, - metaTags: this.userRelatedState.preferencesAsTags, - }) - } } diff --git a/src/Models/ThemeViewState/WithChangesState.ts b/src/Models/ThemeViewState/WithChangesState.ts index c53d5e065..43ac6c177 100644 --- a/src/Models/ThemeViewState/WithChangesState.ts +++ b/src/Models/ThemeViewState/WithChangesState.ts @@ -1,5 +1,7 @@ import { Changes } from "../../Logic/Osm/Changes" -import { NewGeometryFromChangesFeatureSource } from "../../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource" +import { + NewGeometryFromChangesFeatureSource +} from "../../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource" import { WithLayoutSourceState } from "./WithLayoutSourceState" import ThemeConfig from "../ThemeConfig/ThemeConfig" import { Utils } from "../../Utils" @@ -18,9 +20,7 @@ import { Map as MlMap } from "maplibre-gl" import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource" import ShowDataLayer from "../../UI/Map/ShowDataLayer" import SelectedElementTagsUpdater from "../../Logic/Actors/SelectedElementTagsUpdater" -import NoElementsInViewDetector, { - FeatureViewState, -} from "../../Logic/Actors/NoElementsInViewDetector" +import NoElementsInViewDetector, { FeatureViewState } from "../../Logic/Actors/NoElementsInViewDetector" export class WithChangesState extends WithLayoutSourceState { readonly changes: Changes @@ -219,14 +219,24 @@ export class WithChangesState extends WithLayoutSourceState { ) filteringFeatureSource.set(layerName, filtered) - new ShowDataLayer(map, { + ShowDataLayer.showLayerClustered(map, + this, + { + layer: fs.layer.layerDef, + features: filtered, + doShowLayer, + metaTags: this.userRelatedState.preferencesAsTags, + selectedElement: this.selectedElement, + fetchStore: (id) => this.featureProperties.getStore(id) + }) + /*new ShowDataLayer(map, { layer: fs.layer.layerDef, features: filtered, doShowLayer, metaTags: this.userRelatedState.preferencesAsTags, selectedElement: this.selectedElement, fetchStore: (id) => this.featureProperties.getStore(id), - }) + })*/ }) return filteringFeatureSource } diff --git a/src/UI/Map/PointRenderingLayer.ts b/src/UI/Map/PointRenderingLayer.ts new file mode 100644 index 000000000..f41154301 --- /dev/null +++ b/src/UI/Map/PointRenderingLayer.ts @@ -0,0 +1,277 @@ +import PointRenderingConfig, { + allowed_location_codes, + PointRenderingLocation +} from "../../Models/ThemeConfig/PointRenderingConfig" +import { ImmutableStore, Store } from "../../Logic/UIEventSource" +import { type Alignment, Map as MlMap, Marker } from "maplibre-gl" +import { Feature, Geometry, Point } from "geojson" +import { OsmId, OsmTags } from "../../Models/OsmFeature" +import LayerConfig from "../../Models/ThemeConfig/LayerConfig" +import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" +import { IfVisibleFeatureSource } from "../../Logic/FeatureSource/Sources/IfVisibleFeatureSource" +import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" +import { GeoOperations } from "../../Logic/GeoOperations" + +export class PointRenderingLayer { + private readonly _config: PointRenderingConfig + private readonly _fetchStore?: (id: string) => Store> + private readonly _map: MlMap + private readonly _onClick: (feature: Feature) => void + private readonly _allMarkers: Map> = new Map() + private readonly _selectedElement: Store<{ properties: { id?: string } }> + private readonly _markedAsSelected: HTMLElement[] = [] + private readonly _metatags: Store> + + constructor( + map: MlMap, + layer: LayerConfig, + features: FeatureSource>, + config: PointRenderingConfig, + metatags?: Store>, + visibility?: Store, + fetchStore?: (id: string) => Store>, + onClick?: (feature: Feature) => void, + selectedElement?: Store<{ properties: { id?: string } }>, + preprocess?: >(fs: FeatureSource) => FeatureSource + ) { + this._config = config + this._map = map + this._metatags = metatags + this._fetchStore = fetchStore + this._onClick = onClick + this._selectedElement = selectedElement + visibility ??= new ImmutableStore(true) + if (!features?.features) { + throw ( + "Could not setup a PointRenderingLayer; features?.features is undefined/null. The layer is " + + layer.id + ) + } + + /** + * Basically 'features', but only if 'visible' is true + */ + const featuresIfVisibleStore: Store<(Feature & { + locationType: PointRenderingLocation + })[]> = + new IfVisibleFeatureSource(features, visibility).features.map(features => + PointRenderingLayer.extractLocations(features, config.location) + ) + + + let featuresToDraw: FeatureSource & { locationType: PointRenderingLocation }> + if (preprocess) { + featuresToDraw = preprocess(new StaticFeatureSource(featuresIfVisibleStore)) + } else { + featuresToDraw = new StaticFeatureSource(featuresIfVisibleStore) + } + + featuresToDraw.features?.addCallbackAndRunD((features) => { + this.updateFeatures(features) + this.hideUnneededElements(features) + }) + + selectedElement?.addCallbackAndRun((selected) => { + this._markedAsSelected.forEach((el) => el.classList.remove("selected")) + this._markedAsSelected.splice(0, this._markedAsSelected.length) + if (selected === undefined) { + return + } + allowed_location_codes.forEach((code) => { + const marker = this._allMarkers.get(selected.properties.id) + .get(code) + ?.getElement() + if (marker === undefined) { + return + } + marker?.classList?.add("selected") + this._markedAsSelected.push(marker) + }) + }) + + + } + + /** + * All locations that this layer should be rendered + * @private + */ + private static extractLocations(features: Feature[], locations: Set): (Feature & { + locationType: PointRenderingLocation + })[] { + const resultingFeatures: (Feature & { locationType: PointRenderingLocation })[] = [] + + function registerFeature(feature: Feature, location: [number, number], locationType: PointRenderingLocation) { + resultingFeatures.push({ + ...feature, + locationType, + geometry: { + type: "Point", + coordinates: location + } + }) + } + + for (const feature of features) { + for (const location of locations) { + if (feature?.geometry === undefined) { + console.warn( + "Got an invalid feature:", + feature, + " while rendering", + location + ) + } + if (location === "waypoints") { + if (feature.geometry.type === "LineString") { + for (const loc of feature.geometry.coordinates) { + registerFeature(feature, <[number, number]>loc, location) + } + } + if ( + feature.geometry.type === "MultiLineString" || + feature.geometry.type === "Polygon" + ) { + for (const coors of feature.geometry.coordinates) { + for (const loc of coors) { + registerFeature(feature, <[number, number]>loc, location) + } + } + } + continue + } + + const loc = GeoOperations.featureToCoordinateWithRenderingType(feature, location) + if (loc === undefined) { + continue + } + registerFeature(feature, loc, location) + } + } + + return resultingFeatures + + } + + /** + * Hides (or shows) all markers as needed which are in the cache + * @private + */ + private hideUnneededElements(featuresToDraw: Feature[]) { + const idsToShow = new Set(featuresToDraw.map(f => f.properties.id)) + + for (const key of this._allMarkers.keys()) { + const shouldBeShown = idsToShow.has(key) + for (const marker of this._allMarkers.get(key).values()) { + + if (!shouldBeShown) { + marker.addClassName("hidden") + } else { + marker.removeClassName("hidden") + } + } + } + } + + private updateFeatures(allPointLocations: (Feature & { locationType: PointRenderingLocation })[]) { + const cache = this._allMarkers + for (const feature of allPointLocations) { + const id = feature.properties.id + const locationType: PointRenderingLocation = feature.locationType + + + let marker = cache.get(id)?.get(locationType) + if (marker) { + const oldLoc = marker.getLngLat() + const loc = feature.geometry.coordinates + if (loc[0] !== oldLoc.lng && loc[1] !== oldLoc.lat) { + marker.setLngLat(<[number, number]>loc) + } + } else { + marker = this.addPoint(feature) + if (!cache.has(id)) { + cache.set(id, new Map()) + } + cache.get(id).set(locationType, marker) + } + + if (this._selectedElement?.data?.properties?.id === id) { + marker.getElement().classList.add("selected") + this._markedAsSelected.push(marker.getElement()) + } + } + } + + /** + * Render the relevant marker at the explicitly given location. + */ + private addPoint(feature: Feature): Marker { + /* + new Marker() + .setLngLat(feature.geometry.coordinates) + .addTo(this._map)*/ + + + let store: Store> + if (this._fetchStore) { + store = this._fetchStore(feature.properties.id) + } else { + store = new ImmutableStore(feature.properties) + } + const { html, iconAnchor } = this._config.RenderIcon(store, { metatags: this._metatags }) + html.SetClass("marker") + if (this._onClick !== undefined) { + html.SetClass("cursor-pointer") + } + const element = html.ConstructElement() + + store.addCallbackAndRunD((tags) => { + if (tags._deleted === "yes") { + html.SetClass("grayscale") + } + }) + + if (this._onClick) { + element.addEventListener("click", (ev) => { + ev.preventDefault() + this._onClick(feature) + // Workaround to signal the MapLibreAdaptor to ignore this click + ev["consumed"] = true + }) + } + + const marker = new Marker({ element }) + .setLngLat(<[number, number]>feature.geometry.coordinates) + .setOffset(iconAnchor) + .addTo(this._map) + + store + .map((tags) => this._config.pitchAlignment.GetRenderValue(tags).Subs(tags).txt) + .addCallbackAndRun((pitchAligment) => + marker.setPitchAlignment(pitchAligment) + ) + store + .map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt) + .addCallbackAndRun((pitchAligment) => + marker.setRotationAlignment(pitchAligment) + ) + + if (feature.geometry.type === "Point") { + // When the tags get 'pinged', check that the location didn't change + store.addCallbackAndRunD(() => { + // Check if the location is still the same + const oldLoc = marker.getLngLat() + const newloc = (feature.geometry).coordinates + if (newloc[0] === oldLoc.lng && newloc[1] === oldLoc.lat) { + return + } + marker.setLngLat({ lon: newloc[0], lat: newloc[1] }) + }) + } + return marker + } +} diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 1eaa8a20e..6349f51e2 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -1,14 +1,11 @@ -import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" -import type { AddLayerObject, Alignment, Map as MlMap } from "maplibre-gl" -import { GeoJSONSource, Marker } from "maplibre-gl" +import { Store, UIEventSource } from "../../Logic/UIEventSource" +import type { AddLayerObject, Map as MlMap } from "maplibre-gl" +import { GeoJSONSource } from "maplibre-gl" import { ShowDataLayerOptions } from "./ShowDataLayerOptions" -import { GeoOperations } from "../../Logic/GeoOperations" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" -import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig" -import { OsmTags } from "../../Models/OsmFeature" import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource" import { BBox } from "../../Logic/BBox" -import { Feature, Point } from "geojson" +import { Feature } from "geojson" import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" import { Utils } from "../../Utils" import * as range_layer from "../../../assets/layers/range/range.json" @@ -17,209 +14,9 @@ import FilteredLayer from "../../Models/FilteredLayer" import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" import { TagsFilter } from "../../Logic/Tags/TagsFilter" import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" - -class PointRenderingLayer { - private readonly _config: PointRenderingConfig - private readonly _visibility?: Store - private readonly _fetchStore?: (id: string) => Store> - private readonly _map: MlMap - private readonly _onClick: (feature: Feature) => void - private readonly _allMarkers: Map = new Map() - private readonly _selectedElement: Store<{ properties: { id?: string } }> - private readonly _markedAsSelected: HTMLElement[] = [] - private readonly _metatags: Store> - private _dirty = false - - constructor( - map: MlMap, - layer: LayerConfig, - features: FeatureSource, - config: PointRenderingConfig, - metatags?: Store>, - visibility?: Store, - fetchStore?: (id: string) => Store>, - onClick?: (feature: Feature) => void, - selectedElement?: Store<{ properties: { id?: string } }> - ) { - this._visibility = visibility - this._config = config - this._map = map - this._metatags = metatags - this._fetchStore = fetchStore - this._onClick = onClick - this._selectedElement = selectedElement - if (!features?.features) { - throw ( - "Could not setup a PointRenderingLayer; features?.features is undefined/null. The layer is " + - layer.id - ) - } - features.features?.addCallbackAndRunD((features) => this.updateFeatures(features)) - visibility?.addCallbackAndRunD((visible) => { - if (visible === true && this._dirty) { - this.updateFeatures(features.features.data) - } - this.setVisibility(visible) - }) - selectedElement?.addCallbackAndRun((selected) => { - this._markedAsSelected.forEach((el) => el.classList.remove("selected")) - this._markedAsSelected.splice(0, this._markedAsSelected.length) - if (selected === undefined) { - return - } - PointRenderingConfig.allowed_location_codes.forEach((code) => { - const marker = this._allMarkers - .get(selected.properties?.id + "-" + code) - ?.getElement() - if (marker === undefined) { - return - } - marker?.classList?.add("selected") - this._markedAsSelected.push(marker) - }) - }) - } - - private updateFeatures(features: Feature[]) { - if (this._visibility?.data === false) { - this._dirty = true - return - } - this._dirty = false - const cache = this._allMarkers - const unseenKeys = new Set(cache.keys()) - for (const location of this._config.location) { - for (const feature of features) { - if (feature?.geometry === undefined) { - console.warn( - "Got an invalid feature:", - features, - " while rendering", - location, - "of", - this._config - ) - } - const id = feature.properties.id + "-" + location - unseenKeys.delete(id) - - if (location === "waypoints") { - if (feature.geometry.type === "LineString") { - for (const loc of feature.geometry.coordinates) { - this.addPoint(feature, <[number, number]>loc) - } - } - if ( - feature.geometry.type === "MultiLineString" || - feature.geometry.type === "Polygon" - ) { - for (const coors of feature.geometry.coordinates) { - for (const loc of coors) { - this.addPoint(feature, <[number, number]>loc) - } - } - } - continue - } - - const loc = GeoOperations.featureToCoordinateWithRenderingType(feature, location) - if (loc === undefined) { - continue - } - - if (cache.has(id)) { - const cached = cache.get(id) - const oldLoc = cached.getLngLat() - if (loc[0] !== oldLoc.lng && loc[1] !== oldLoc.lat) { - cached.setLngLat(loc) - } - continue - } - - const marker = this.addPoint(feature, loc) - if (this._selectedElement?.data === feature.properties.id) { - marker.getElement().classList.add("selected") - this._markedAsSelected.push(marker.getElement()) - } - cache.set(id, marker) - } - } - - for (const unseenKey of unseenKeys) { - cache.get(unseenKey).remove() - cache.delete(unseenKey) - } - } - - private setVisibility(visible: boolean) { - for (const marker of this._allMarkers.values()) { - if (visible) { - marker.getElement().classList.remove("hidden") - } else { - marker.getElement().classList.add("hidden") - } - } - } - - private addPoint(feature: Feature, loc: [number, number]): Marker { - let store: Store> - if (this._fetchStore) { - store = this._fetchStore(feature.properties.id) - } else { - store = new ImmutableStore(feature.properties) - } - const { html, iconAnchor } = this._config.RenderIcon(store, { metatags: this._metatags }) - html.SetClass("marker") - if (this._onClick !== undefined) { - html.SetClass("cursor-pointer") - } - const el = html.ConstructElement() - - store.addCallbackAndRunD((tags) => { - if (tags._deleted === "yes") { - html.SetClass("grayscale") - } - }) - - if (this._onClick) { - el.addEventListener("click", (ev) => { - ev.preventDefault() - this._onClick(feature) - // Workaround to signal the MapLibreAdaptor to ignore this click - ev["consumed"] = true - }) - } - - const marker = new Marker({ element: el }) - .setLngLat(loc) - .setOffset(iconAnchor) - .addTo(this._map) - store - .map((tags) => this._config.pitchAlignment.GetRenderValue(tags).Subs(tags).txt) - .addCallbackAndRun((pitchAligment) => - marker.setPitchAlignment(pitchAligment) - ) - store - .map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt) - .addCallbackAndRun((pitchAligment) => - marker.setRotationAlignment(pitchAligment) - ) - - if (feature.geometry.type === "Point") { - // When the tags get 'pinged', check that the location didn't change - store.addCallbackAndRunD(() => { - // Check if the location is still the same - const oldLoc = marker.getLngLat() - const newloc = (feature.geometry).coordinates - if (newloc[0] === oldLoc.lng && newloc[1] === oldLoc.lat) { - return - } - marker.setLngLat({ lon: newloc[0], lat: newloc[1] }) - }) - } - return marker - } -} +import { PointRenderingLayer } from "./PointRenderingLayer" +import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" +import summaryLayer from "../../../public/assets/generated/layers/summary.json" class LineRenderingLayer { /** @@ -554,6 +351,7 @@ export default class ShowDataLayer { drawLines?: true | boolean } ) { + console.trace("Creating a data layer for", options.layer.id) this._options = options this.onDestroy.push(map.addCallbackAndRunD((map) => this.initDrawFeatures(map))) } @@ -591,6 +389,26 @@ export default class ShowDataLayer { }) } + public static showLayerClustered(mlmap: Store, + state: { mapProperties: { zoom: UIEventSource } }, + options: ShowDataLayerOptions & { layer: LayerConfig } + ) { + options.preprocessPoints = feats => { + const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), + options.layer.id, + { + cutoff: 5 + }) + 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) + } + public static showRange( map: Store, features: FeatureSource, @@ -635,6 +453,7 @@ export default class ShowDataLayer { layer, drawLines, drawMarkers, + preprocessPoints } = this._options let onClick = this._options.onClick if (!onClick && selectedElement && layer.title !== undefined) { @@ -672,7 +491,8 @@ export default class ShowDataLayer { doShowLayer, fetchStore, onClick, - selectedElement + selectedElement, + preprocessPoints ) } } diff --git a/src/UI/Map/ShowDataLayerOptions.ts b/src/UI/Map/ShowDataLayerOptions.ts index e848ead21..d5e7fe078 100644 --- a/src/UI/Map/ShowDataLayerOptions.ts +++ b/src/UI/Map/ShowDataLayerOptions.ts @@ -1,6 +1,6 @@ import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" import { Store, UIEventSource } from "../../Logic/UIEventSource" -import { Feature } from "geojson" +import { Feature, Point } from "geojson" export interface ShowDataLayerOptions { /** @@ -31,5 +31,6 @@ export interface ShowDataLayerOptions { onClick?: (feature: Feature) => void metaTags?: Store> - prefix?: string + prefix?: string, + preprocessPoints?: >(fs: FeatureSource) => FeatureSource } diff --git a/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts b/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts new file mode 100644 index 000000000..eae7f554b --- /dev/null +++ b/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts @@ -0,0 +1,81 @@ +import { FeatureCollection, Point } from "geojson" +import { describe, it } from "vitest" +import StaticFeatureSource from "../../../src/Logic/FeatureSource/Sources/StaticFeatureSource" +import { ClusteringFeatureSource } from "../../../src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" +import { UIEventSource } from "../../../src/Logic/UIEventSource" +import { expect } from "chai" + +const points: FeatureCollection = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {name: "a"}, + "geometry": { + "coordinates": [ + 9.759318139161195, + 55.56552169756637 + ], + "type": "Point" + } + }, + { + "type": "Feature", + "properties": {name: "b"}, + "geometry": { + "coordinates": [ + 9.759768615515327, + 55.56569930560951 + ], + "type": "Point" + } + }, + { + "type": "Feature", + "properties": {name: "c"}, + "geometry": { + "coordinates": [ + 9.75879327221594, + 55.56569229478089 + ], + "type": "Point" + } + }, + { + "type": "Feature", + "properties": {name: "d"}, + "geometry": { + "coordinates": [ + 9.759380131319915, + 55.56507066300628 + ], + "type": "Point" + } + } + ] +} + +describe("ClusteringFeatureSource", () => { + it("smallTest", () => { + const source = new StaticFeatureSource(points.features) + const zoom = new UIEventSource(19) + // On zoomlevel 19, all points are in a different tile + const clusteringSource = new ClusteringFeatureSource(source, zoom, "test", { + cutoff: 2, + dontClusterAboveZoom: 100 + }) + expect(clusteringSource.summaryPoints.features.data.length).to.eq(0) + expect(clusteringSource.features.data.length).to.eq(4) + + zoom.set(13) + + const summary = clusteringSource.summaryPoints.features.data + expect(summary.length).to.eq(1) + expect(summary[0].properties["total_metric"]).to.eq("4") + expect(clusteringSource.features.data.length).to.eq(0) + + + + + }) +}) From 5bc8f11d24423478223bbd25f7dc2ac766b894d5 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 21 Jul 2025 22:02:04 +0200 Subject: [PATCH 2/5] Feature: second iteration of clustering --- .../Sources/OverpassFeatureSource.ts | 4 ++-- .../ClusteringFeatureSource.ts | 22 ++++++++++++++++++- src/UI/Map/ShowDataLayer.ts | 4 ++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts b/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts index e78d09fa5..f2443ff9d 100644 --- a/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts @@ -8,7 +8,8 @@ import { Utils } from "../../../Utils" import { TagsFilter } from "../../Tags/TagsFilter" import { BBox } from "../../BBox" import { OsmTags } from "../../../Models/OsmFeature" -;("use strict") + +("use strict") /** * A wrapper around the 'Overpass'-object. @@ -138,7 +139,6 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource { return undefined } this.runningQuery.setData(true) - console.trace("Overpass feature source: querying geojson") data = (await overpass.queryGeoJson(bounds))[0] } catch (e) { this.retries.data++ diff --git a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts index de2899470..71f89b518 100644 --- a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts +++ b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts @@ -15,12 +15,15 @@ export interface ClusteringOptions { * drop those features and emit a summary tile instead */ cutoff?: 20 | number + + showSummaryAt?: "tilecenter" | "average" } export class ClusteringFeatureSource = Feature> implements FeatureSource { public readonly summaryPoints: FeatureSource private readonly id: string + private readonly showSummaryAt: "tilecenter" | "average" features: Store /** @@ -35,6 +38,7 @@ export class ClusteringFeatureSource = Feature> id: string, options?: ClusteringOptions) { this.id = id + this.showSummaryAt = options?.showSummaryAt ?? "average" const clusterCutoff = options?.dontClusterAboveZoom ?? 17 const doCluster = options?.dontClusterAboveZoom === undefined ? new ImmutableStore(true) : currentZoomlevel.map(zoom => zoom <= clusterCutoff) const cutoff = options?.cutoff ?? 20 @@ -42,6 +46,7 @@ export class ClusteringFeatureSource = Feature> currentZoomlevel = currentZoomlevel.stabilized(500) this.summaryPoints = new StaticFeatureSource(summaryPoints) this.features = (upstream.features.map(features => { + console.log(">>> Updating features in clusters ", this.id, ":", features) if (!doCluster.data) { summaryPoints.set([]) return features @@ -69,8 +74,23 @@ export class ClusteringFeatureSource = Feature> private createSummaryFeature(features: Feature[], tileId: number): Feature { + + let lon: number + let lat: number const [z, x, y] = Tiles.tile_from_index(tileId) - const [lon, lat] = Tiles.centerPointOf(z, x, y) + if (this.showSummaryAt === "tilecenter") { + [lon, lat] = Tiles.centerPointOf(z, x, y) + } else { + let lonSum = 0 + let latSum = 0 + for (const feature of features) { + const [lon, lat] = feature.geometry.coordinates + lonSum += lon + latSum += lat + } + lon = lonSum / features.length + lat = latSum / features.length + } return >{ type: "Feature", geometry: { diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 6349f51e2..5b28747f4 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -351,7 +351,6 @@ export default class ShowDataLayer { drawLines?: true | boolean } ) { - console.trace("Creating a data layer for", options.layer.id) this._options = options this.onDestroy.push(map.addCallbackAndRunD((map) => this.initDrawFeatures(map))) } @@ -397,7 +396,8 @@ export default class ShowDataLayer { const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), options.layer.id, { - cutoff: 5 + cutoff: 2, + showSummaryAt: "tilecenter" }) new ShowDataLayer(mlmap, { features: clustering.summaryPoints, From 0048c091d0439f8306e5a14b612ca8522e39e456 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 24 Jul 2025 15:57:30 +0200 Subject: [PATCH 3/5] 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 a0a32d29c..076302f43 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 ac9c7cb8e..e3b857941 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 619424df1..184259b3c 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 c280bebcb..f9880b171 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 71f89b518..c5c629600 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 1aeccfb6e..071e67e32 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 d4494da6d..8d1d87f0b 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 5b28747f4..4d1e5c044 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 d5e7fe078..5c1371aac 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 From 63e9d2025564eea385eafac47d4a2e2f358fc972 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 24 Jul 2025 19:28:34 +0200 Subject: [PATCH 4/5] Feature(clustering): fix count update on filters, fix hiding when zoomed out to much --- .../Sources/FilteringFeatureSource.ts | 20 +++++++++---------- .../Sources/IfVisibleFeatureSource.ts | 2 +- .../ClusteringFeatureSource.ts | 3 ++- src/UI/Map/PointRenderingLayer.ts | 4 ++-- src/UI/Map/ShowDataLayer.ts | 18 ++++++++++------- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/Logic/FeatureSource/Sources/FilteringFeatureSource.ts b/src/Logic/FeatureSource/Sources/FilteringFeatureSource.ts index cc4980a2a..9e362369c 100644 --- a/src/Logic/FeatureSource/Sources/FilteringFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/FilteringFeatureSource.ts @@ -1,12 +1,13 @@ import { Store, UIEventSource } from "../../UIEventSource" import FilteredLayer from "../../../Models/FilteredLayer" import { FeatureSource } from "../FeatureSource" -import { Feature } from "geojson" +import { Feature, Geometry } from "geojson" import { GlobalFilter } from "../../../Models/GlobalFilter" +import { OsmTags } from "../../../Models/OsmFeature" -export default class FilteringFeatureSource implements FeatureSource { - public features: UIEventSource = new UIEventSource([]) - private readonly upstream: FeatureSource +export default class FilteringFeatureSource> implements FeatureSource { + public readonly features: UIEventSource = new UIEventSource([]) + private readonly upstream: FeatureSource private readonly _fetchStore?: (id: string) => Store> private readonly _globalFilters?: Store private readonly _alreadyRegistered = new Set>() @@ -18,7 +19,7 @@ export default class FilteringFeatureSource implements FeatureSource { constructor( layer: FilteredLayer, - upstream: FeatureSource, + upstream: FeatureSource, fetchStore?: (id: string) => Store>, globalFilters?: Store, metataggingUpdated?: Store, @@ -40,7 +41,7 @@ export default class FilteringFeatureSource implements FeatureSource { }) layer.appliedFilters.forEach((value) => - value.addCallback((_) => { + value.addCallback(() => { this.update() }) ) @@ -68,7 +69,7 @@ export default class FilteringFeatureSource implements FeatureSource { private update() { const layer = this._layer - const features: Feature[] = this.upstream.features.data ?? [] + const features: T[] = this.upstream.features.data ?? [] const includedFeatureIds = new Set() const globalFilters = this._globalFilters?.data?.map((f) => f) const zoomlevel = this._zoomlevel?.data @@ -121,10 +122,9 @@ export default class FilteringFeatureSource implements FeatureSource { } this._alreadyRegistered.add(src) - const self = this // Add a callback as a changed tag might change the filter - src.addCallbackAndRunD((_) => { - self._is_dirty.setData(true) + src.addCallbackAndRunD(() => { + this._is_dirty.setData(true) }) } } diff --git a/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts b/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts index b52660368..f8b02db71 100644 --- a/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts @@ -12,8 +12,8 @@ export class IfVisibleFeatureSource implements FeatureSource< let dirty = false upstream.features.addCallbackAndRun(features => { if (!visible.data) { - console.log(">>> not writing data as not visible") dirty = true + this._features.set([]) return } this._features.set(features) diff --git a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts index c5c629600..bd74c183a 100644 --- a/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts +++ b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts @@ -140,13 +140,14 @@ export class ClusterGrouping implements FeatureSource[] = [] + const now = new Date().getTime() + "" 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 + id: "clustered_all_" + tileId + "_" + now // We add the date to force a fresh ID every time, this makes sure values are updated }, geometry: { type: "Point", diff --git a/src/UI/Map/PointRenderingLayer.ts b/src/UI/Map/PointRenderingLayer.ts index f41154301..d79da1dda 100644 --- a/src/UI/Map/PointRenderingLayer.ts +++ b/src/UI/Map/PointRenderingLayer.ts @@ -79,12 +79,12 @@ export class PointRenderingLayer { } allowed_location_codes.forEach((code) => { const marker = this._allMarkers.get(selected.properties.id) - .get(code) + ?.get(code) ?.getElement() if (marker === undefined) { return } - marker?.classList?.add("selected") + marker.classList?.add("selected") this._markedAsSelected.push(marker) }) }) diff --git a/src/UI/Map/ShowDataLayer.ts b/src/UI/Map/ShowDataLayer.ts index 4d1e5c044..67b6a7285 100644 --- a/src/UI/Map/ShowDataLayer.ts +++ b/src/UI/Map/ShowDataLayer.ts @@ -388,20 +388,26 @@ export default class ShowDataLayer { }) } + /** + * Show the data, unless they are clustered. + * This method does _not_ add the clusters themselves to the map, + * this should be done independently. In a themeViewGui, this is done by the 'addSpecialLayers' + * @see ClusterGrouping + * @param mlmap + * @param state + * @param options + */ public static showLayerClustered(mlmap: Store, state: { mapProperties: { zoom: UIEventSource } }, options: ShowDataLayerOptions & { layer: LayerConfig } ) { - options.preprocessPoints = feats => { - const clustering = new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), + options.preprocessPoints = feats => + new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), options.layer.id, { cutoff: 7, showSummaryAt: "tilecenter" }) - - return clustering - } new ShowDataLayer(mlmap, options) } @@ -417,8 +423,6 @@ export default class ShowDataLayer { }) } - public destruct() {} - private static zoomToCurrentFeatures(map: MlMap, features: Feature[]) { if (!features || !map || features.length == 0) { return From 520e4d8a894c26bb826c36f6f6ae8e9742c71b16 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Fri, 25 Jul 2025 19:55:08 +0200 Subject: [PATCH 5/5] Fix: fix tests --- .../FeatureSource/ClusteringFeatureSource.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts b/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts index eae7f554b..d37b9b2e7 100644 --- a/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts +++ b/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts @@ -1,7 +1,10 @@ import { FeatureCollection, Point } from "geojson" import { describe, it } from "vitest" import StaticFeatureSource from "../../../src/Logic/FeatureSource/Sources/StaticFeatureSource" -import { ClusteringFeatureSource } from "../../../src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" +import { + ClusterGrouping, + ClusteringFeatureSource +} from "../../../src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource" import { UIEventSource } from "../../../src/Logic/UIEventSource" import { expect } from "chai" @@ -64,14 +67,14 @@ describe("ClusteringFeatureSource", () => { cutoff: 2, dontClusterAboveZoom: 100 }) - expect(clusteringSource.summaryPoints.features.data.length).to.eq(0) + const allClusters = ClusterGrouping.singleton.features + expect(allClusters.data.length).to.eq(0) expect(clusteringSource.features.data.length).to.eq(4) zoom.set(13) - const summary = clusteringSource.summaryPoints.features.data - expect(summary.length).to.eq(1) - expect(summary[0].properties["total_metric"]).to.eq("4") + expect(allClusters.data.length).to.eq(1) + expect(allClusters.data[0].properties["total_metric"]).to.eq("4") expect(clusteringSource.features.data.length).to.eq(0)