Feature: first version of clustering at low zoom levels, filters don't update yet (WIP)

This commit is contained in:
Pieter Vander Vennet 2025-07-21 12:57:04 +02:00
parent 4e033a93a5
commit 8360ab9a8b
11 changed files with 562 additions and 262 deletions

View file

@ -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

View file

@ -0,0 +1,38 @@
import { FeatureSource } from "../FeatureSource"
import { Feature } from "geojson"
import { Store, UIEventSource } from "../../UIEventSource"
export class IfVisibleFeatureSource<T extends Feature> implements FeatureSource<T> {
private readonly _features: UIEventSource<T[]> = new UIEventSource<T[]>([])
public readonly features: Store<T[]> = this._features
constructor(upstream: FeatureSource<T>, visible: Store<boolean>) {
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([])
}
})
}
}

View file

@ -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<T extends Feature<Point> = Feature<Point>> implements FeatureSource<T> {
public readonly summaryPoints: FeatureSource
private readonly id: string
features: Store<T[]>
/**
*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<T>,
currentZoomlevel: Store<number>,
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<Feature<Point>[]>([])
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<Point>[] = []
for (const tileIndex of perTile.keys()) {
const tileFeatures: Feature<Point>[] = 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<Point>[], tileId: number): Feature<Point> {
const [z, x, y] = Tiles.tile_from_index(tileId)
const [lon, lat] = Tiles.centerPointOf(z, x, y)
return <Feature<Point>>{
type: "Feature",
geometry: {
type: "Point",
coordinates: [lon, lat]
},
properties: {
id: "summary_" + this.id + "_" + tileId,
z,
total_metric: "" + features.length
}
}
}
}

View file

@ -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<number, Feature[]> {
const perBbox = new Map<number, Feature[]>()
public static spreadIntoBboxes<T extends Feature = Feature>(features: T[], zoomlevel: number): Map<number, T[]> {
const perBbox = new Map<number, T[]>()
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) => {

View file

@ -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<string> = new Set<string>([
static readonly allowed_location_codes_set: ReadonlySet<PointRenderingLocation> = new Set<PointRenderingLocation>([
"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<PointRenderingLocation>
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(<PointRenderingLocation[]>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")

View file

@ -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<MlMap>) {
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,
})
}
}

View file

@ -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
}

View file

@ -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<Record<string, string>>
private readonly _map: MlMap
private readonly _onClick: (feature: Feature) => void
private readonly _allMarkers: Map<OsmId, Map<PointRenderingLocation, Marker>> = new Map()
private readonly _selectedElement: Store<{ properties: { id?: string } }>
private readonly _markedAsSelected: HTMLElement[] = []
private readonly _metatags: Store<Record<string, string>>
constructor(
map: MlMap,
layer: LayerConfig,
features: FeatureSource<Feature<Geometry, { id: string }>>,
config: PointRenderingConfig,
metatags?: Store<Record<string, string>>,
visibility?: Store<boolean>,
fetchStore?: (id: string) => Store<Record<string, string>>,
onClick?: (feature: Feature) => void,
selectedElement?: Store<{ properties: { id?: string } }>,
preprocess?: <T extends Feature<Point>>(fs: FeatureSource<T>) => FeatureSource<T>
) {
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<Point, { id: string }> & {
locationType: PointRenderingLocation
})[]> =
new IfVisibleFeatureSource(features, visibility).features.map(features =>
PointRenderingLayer.extractLocations(features, config.location)
)
let featuresToDraw: FeatureSource<Feature<Point, { id: string }> & { 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(<OsmId>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<Geometry, {
id: string
}>[], locations: Set<PointRenderingLocation>): (Feature<Point, { id: string }> & {
locationType: PointRenderingLocation
})[] {
const resultingFeatures: (Feature<Point, { id: string }> & { locationType: PointRenderingLocation })[] = []
function registerFeature(feature: Feature<any, {
id: string
}>, 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<Geometry, { id: string }>[]) {
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<Point> & { locationType: PointRenderingLocation })[]) {
const cache = this._allMarkers
for (const feature of allPointLocations) {
const id = <OsmId>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<Point>): Marker {
/*
new Marker()
.setLngLat(feature.geometry.coordinates)
.addTo(this._map)*/
let store: Store<Record<string, string>>
if (this._fetchStore) {
store = this._fetchStore(feature.properties.id)
} else {
store = new ImmutableStore(<OsmTags>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(<Alignment>pitchAligment)
)
store
.map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt)
.addCallbackAndRun((pitchAligment) =>
marker.setRotationAlignment(<Alignment>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 = (<Point>feature.geometry).coordinates
if (newloc[0] === oldLoc.lng && newloc[1] === oldLoc.lat) {
return
}
marker.setLngLat({ lon: newloc[0], lat: newloc[1] })
})
}
return marker
}
}

View file

@ -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<boolean>
private readonly _fetchStore?: (id: string) => Store<Record<string, string>>
private readonly _map: MlMap
private readonly _onClick: (feature: Feature) => void
private readonly _allMarkers: Map<string, Marker> = new Map<string, Marker>()
private readonly _selectedElement: Store<{ properties: { id?: string } }>
private readonly _markedAsSelected: HTMLElement[] = []
private readonly _metatags: Store<Record<string, string>>
private _dirty = false
constructor(
map: MlMap,
layer: LayerConfig,
features: FeatureSource,
config: PointRenderingConfig,
metatags?: Store<Record<string, string>>,
visibility?: Store<boolean>,
fetchStore?: (id: string) => Store<Record<string, string>>,
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<Record<string, string>>
if (this._fetchStore) {
store = this._fetchStore(feature.properties.id)
} else {
store = new ImmutableStore(<OsmTags>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(<Alignment>pitchAligment)
)
store
.map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt)
.addCallbackAndRun((pitchAligment) =>
marker.setRotationAlignment(<Alignment>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 = (<Point>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<MlMap>,
state: { mapProperties: { zoom: UIEventSource<number> } },
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(<LayerConfigJson>(<unknown>summaryLayer), "summaryLayer")
// doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom),
})
return clustering
}
new ShowDataLayer(mlmap, options)
}
public static showRange(
map: Store<MlMap>,
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
)
}
}

View file

@ -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<Record<string, string>>
prefix?: string
prefix?: string,
preprocessPoints?: <T extends Feature<Point>>(fs: FeatureSource<T>) => FeatureSource<T>
}

View file

@ -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<Point> = {
"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)
})
})