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 9f3aadd9a..e3b857941 100644 --- a/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/src/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -5,21 +5,23 @@ 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 */ -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 @@ -30,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 } @@ -49,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, []) @@ -113,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/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 new file mode 100644 index 000000000..f8b02db71 --- /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) { + dirty = true + this._features.set([]) + 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/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/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/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 new file mode 100644 index 000000000..bd74c183a --- /dev/null +++ b/src/Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource.ts @@ -0,0 +1,170 @@ +import { FeatureSource } from "../FeatureSource" +import { Feature, Point } from "geojson" +import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource" +import { GeoOperations } from "../../GeoOperations" +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 + + showSummaryAt?: "tilecenter" | "average" +} + +interface SummaryProperties { + id: string, + total: number, + tile_id: number +} + +export class ClusteringFeatureSource = Feature> implements FeatureSource { + + private readonly id: string + private readonly showSummaryAt: "tilecenter" | "average" + 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 + 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 + const summaryPoints = new UIEventSource[]>([]) + currentZoomlevel = currentZoomlevel.stabilized(500).map(z => Math.floor(z)) + 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])) + + ClusterGrouping.singleton.registerSource(summaryPoints) + + } + + + private createSummaryFeature(features: Feature[], tileId: number): Feature { + + let lon: number + let lat: number + const [z, x, y] = Tiles.tile_from_index(tileId) + 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: { + type: "Point", + coordinates: [lon, lat] + }, + properties: { + id: "summary_" + this.id + "_" + tileId, + 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[] = [] + 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 + "_" + now // We add the date to force a fresh ID every time, this makes sure values are updated + }, + 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/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/Models/ThemeViewState/WithSpecialLayers.ts b/src/Models/ThemeViewState/WithSpecialLayers.ts index 2fb653a01..5476651a6 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 @@ -63,6 +64,7 @@ export class WithSpecialLayers extends WithChangesState { this.closestFeatures.registerSource(this.favourites, "favourite") this.featureSummary = this.setupSummaryLayer() + this.setupClusterLayer() this.initActorsSpecialLayers() this.drawSelectedElement() this.drawSpecialLayers() @@ -131,6 +133,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/PointRenderingLayer.ts b/src/UI/Map/PointRenderingLayer.ts new file mode 100644 index 000000000..d79da1dda --- /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..67b6a7285 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 { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" import { BBox } from "../../Logic/BBox" -import { Feature, Point } 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" @@ -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 { OsmTags } from "../../Models/OsmFeature" class LineRenderingLayer { /** @@ -564,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, @@ -582,18 +379,41 @@ 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 ?? {}), }) }) } + /** + * 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 => + new ClusteringFeatureSource(feats, state.mapProperties.zoom.map(z => z + 2), + options.layer.id, + { + cutoff: 7, + showSummaryAt: "tilecenter" + }) + new ShowDataLayer(mlmap, options) + } + public static showRange( map: Store, - features: FeatureSource, + features: FeatureSource>, doShowLayer?: Store ): ShowDataLayer { return new ShowDataLayer(map, { @@ -603,8 +423,6 @@ export default class ShowDataLayer { }) } - public destruct() {} - private static zoomToCurrentFeatures(map: MlMap, features: Feature[]) { if (!features || !map || features.length == 0) { return @@ -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..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 } 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 @@ -31,5 +32,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..d37b9b2e7 --- /dev/null +++ b/test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts @@ -0,0 +1,84 @@ +import { FeatureCollection, Point } from "geojson" +import { describe, it } from "vitest" +import StaticFeatureSource from "../../../src/Logic/FeatureSource/Sources/StaticFeatureSource" +import { + ClusterGrouping, + 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 + }) + const allClusters = ClusterGrouping.singleton.features + expect(allClusters.data.length).to.eq(0) + expect(clusteringSource.features.data.length).to.eq(4) + + zoom.set(13) + + 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) + + + + + }) +})