From 8360ab9a8bec4e74cb9013b97b35009367011295 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 21 Jul 2025 12:57:04 +0200 Subject: [PATCH] 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) + + + + + }) +})