forked from MapComplete/MapComplete
Merge pull request 'Add local clustering' (#2468) from feature/local-clustering into develop
Reviewed-on: MapComplete/MapComplete#2468
This commit is contained in:
commit
17ce67bf3f
18 changed files with 727 additions and 321 deletions
|
@ -1,25 +1,27 @@
|
||||||
import { Store, UIEventSource } from "../UIEventSource"
|
import { Store, UIEventSource } from "../UIEventSource"
|
||||||
import FilteredLayer from "../../Models/FilteredLayer"
|
import FilteredLayer from "../../Models/FilteredLayer"
|
||||||
import { Feature } from "geojson"
|
import { Feature, Geometry } from "geojson"
|
||||||
|
import { OsmTags } from "../../Models/OsmFeature"
|
||||||
|
|
||||||
export interface FeatureSource<T extends Feature = Feature> {
|
export interface FeatureSource<T extends Feature = Feature<Geometry, OsmTags>> {
|
||||||
features: Store<T[]>
|
features: Store<T[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatableFeatureSource<T extends Feature = Feature> extends FeatureSource<T> {
|
export interface UpdatableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
|
||||||
/**
|
/**
|
||||||
* Forces an update and downloads the data, even if the feature source is supposed to be active
|
* Forces an update and downloads the data, even if the feature source is supposed to be active
|
||||||
*/
|
*/
|
||||||
updateAsync()
|
updateAsync(): void
|
||||||
}
|
}
|
||||||
export interface WritableFeatureSource<T extends Feature = Feature> extends FeatureSource<T> {
|
|
||||||
|
export interface WritableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
|
||||||
features: UIEventSource<T[]>
|
features: UIEventSource<T[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A feature source which only contains features for the defined layer
|
* A feature source which only contains features for the defined layer
|
||||||
*/
|
*/
|
||||||
export interface FeatureSourceForLayer<T extends Feature = Feature> extends FeatureSource<T> {
|
export interface FeatureSourceForLayer<T extends Feature = Feature<Geometry, OsmTags>> extends FeatureSource<T> {
|
||||||
readonly layer: FilteredLayer
|
readonly layer: FilteredLayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,21 +5,23 @@ import { Feature } from "geojson"
|
||||||
import { UIEventSource } from "../UIEventSource"
|
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)
|
* 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.
|
* 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
|
* In any case, this featureSource marks the objects with _matching_layer_id
|
||||||
*/
|
*/
|
||||||
export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = FeatureSource> {
|
export default class PerLayerFeatureSourceSplitter<T extends Feature, SRC extends FeatureSource<T>> {
|
||||||
public readonly perLayer: ReadonlyMap<string, T>
|
public readonly perLayer: ReadonlyMap<string, SRC>
|
||||||
constructor(
|
constructor(
|
||||||
layers: FilteredLayer[],
|
layers: FilteredLayer[],
|
||||||
upstream: FeatureSource,
|
upstream: FeatureSource<T>,
|
||||||
options?: {
|
options?: {
|
||||||
constructStore?: (features: UIEventSource<Feature[]>, layer: FilteredLayer) => T
|
constructStore?: (features: UIEventSource<T[]>, layer: FilteredLayer) => SRC
|
||||||
handleLeftovers?: (featuresWithoutLayer: Feature[]) => void
|
handleLeftovers?: (featuresWithoutLayer: T[]) => void
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const knownLayers = new Map<string, T>()
|
const knownLayers = new Map<string, SRC>()
|
||||||
/**
|
/**
|
||||||
* Keeps track of the ids that are included per layer.
|
* Keeps track of the ids that are included per layer.
|
||||||
* Used to know if the downstream feature source needs to be pinged
|
* Used to know if the downstream feature source needs to be pinged
|
||||||
|
@ -30,12 +32,12 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
|
||||||
const constructStore =
|
const constructStore =
|
||||||
options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store))
|
options?.constructStore ?? ((store, layer) => new SimpleFeatureSource(layer, store))
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
const src = new UIEventSource<Feature[]>([])
|
const src = new UIEventSource<T[]>([])
|
||||||
layerSources.set(layer.layerDef.id, src)
|
layerSources.set(layer.layerDef.id, src)
|
||||||
knownLayers.set(layer.layerDef.id, <T>constructStore(src, layer))
|
knownLayers.set(layer.layerDef.id, <SRC>constructStore(src, layer))
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream.features.addCallbackAndRunD((features) => {
|
upstream.features.addCallbackAndRunD((features: T[]) => {
|
||||||
if (layers === undefined) {
|
if (layers === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -49,7 +51,7 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
|
||||||
*/
|
*/
|
||||||
const hasChanged: boolean[] = layers.map(() => false)
|
const hasChanged: boolean[] = layers.map(() => false)
|
||||||
const newIndices: Set<string>[] = layers.map(() => new Set<string>())
|
const newIndices: Set<string>[] = layers.map(() => new Set<string>())
|
||||||
const noLayerFound: Feature[] = []
|
const noLayerFound: T[] = []
|
||||||
|
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
featuresPerLayer.set(layer.layerDef.id, [])
|
featuresPerLayer.set(layer.layerDef.id, [])
|
||||||
|
@ -113,7 +115,7 @@ export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = Fea
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public forEach(f: (featureSource: T) => void) {
|
public forEach(f: ((src: SRC) => void)) {
|
||||||
for (const fs of this.perLayer.values()) {
|
for (const fs of this.perLayer.values()) {
|
||||||
f(fs)
|
f(fs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { Store, UIEventSource } from "../../UIEventSource"
|
import { Store, UIEventSource } from "../../UIEventSource"
|
||||||
import FilteredLayer from "../../../Models/FilteredLayer"
|
import FilteredLayer from "../../../Models/FilteredLayer"
|
||||||
import { FeatureSource } from "../FeatureSource"
|
import { FeatureSource } from "../FeatureSource"
|
||||||
import { Feature } from "geojson"
|
import { Feature, Geometry } from "geojson"
|
||||||
import { GlobalFilter } from "../../../Models/GlobalFilter"
|
import { GlobalFilter } from "../../../Models/GlobalFilter"
|
||||||
|
import { OsmTags } from "../../../Models/OsmFeature"
|
||||||
|
|
||||||
export default class FilteringFeatureSource implements FeatureSource {
|
export default class FilteringFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> implements FeatureSource<T> {
|
||||||
public features: UIEventSource<Feature[]> = new UIEventSource([])
|
public readonly features: UIEventSource<T[]> = new UIEventSource([])
|
||||||
private readonly upstream: FeatureSource
|
private readonly upstream: FeatureSource<T>
|
||||||
private readonly _fetchStore?: (id: string) => Store<Record<string, string>>
|
private readonly _fetchStore?: (id: string) => Store<Record<string, string>>
|
||||||
private readonly _globalFilters?: Store<GlobalFilter[]>
|
private readonly _globalFilters?: Store<GlobalFilter[]>
|
||||||
private readonly _alreadyRegistered = new Set<Store<any>>()
|
private readonly _alreadyRegistered = new Set<Store<any>>()
|
||||||
|
@ -18,7 +19,7 @@ export default class FilteringFeatureSource implements FeatureSource {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
layer: FilteredLayer,
|
layer: FilteredLayer,
|
||||||
upstream: FeatureSource,
|
upstream: FeatureSource<T>,
|
||||||
fetchStore?: (id: string) => Store<Record<string, string>>,
|
fetchStore?: (id: string) => Store<Record<string, string>>,
|
||||||
globalFilters?: Store<GlobalFilter[]>,
|
globalFilters?: Store<GlobalFilter[]>,
|
||||||
metataggingUpdated?: Store<any>,
|
metataggingUpdated?: Store<any>,
|
||||||
|
@ -40,7 +41,7 @@ export default class FilteringFeatureSource implements FeatureSource {
|
||||||
})
|
})
|
||||||
|
|
||||||
layer.appliedFilters.forEach((value) =>
|
layer.appliedFilters.forEach((value) =>
|
||||||
value.addCallback((_) => {
|
value.addCallback(() => {
|
||||||
this.update()
|
this.update()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -68,7 +69,7 @@ export default class FilteringFeatureSource implements FeatureSource {
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
const layer = this._layer
|
const layer = this._layer
|
||||||
const features: Feature[] = this.upstream.features.data ?? []
|
const features: T[] = this.upstream.features.data ?? []
|
||||||
const includedFeatureIds = new Set<string>()
|
const includedFeatureIds = new Set<string>()
|
||||||
const globalFilters = this._globalFilters?.data?.map((f) => f)
|
const globalFilters = this._globalFilters?.data?.map((f) => f)
|
||||||
const zoomlevel = this._zoomlevel?.data
|
const zoomlevel = this._zoomlevel?.data
|
||||||
|
@ -121,10 +122,9 @@ export default class FilteringFeatureSource implements FeatureSource {
|
||||||
}
|
}
|
||||||
this._alreadyRegistered.add(src)
|
this._alreadyRegistered.add(src)
|
||||||
|
|
||||||
const self = this
|
|
||||||
// Add a callback as a changed tag might change the filter
|
// Add a callback as a changed tag might change the filter
|
||||||
src.addCallbackAndRunD((_) => {
|
src.addCallbackAndRunD(() => {
|
||||||
self._is_dirty.setData(true)
|
this._is_dirty.setData(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
38
src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts
Normal file
38
src/Logic/FeatureSource/Sources/IfVisibleFeatureSource.ts
Normal 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) {
|
||||||
|
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([])
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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 { Store, UIEventSource } from "../../UIEventSource"
|
||||||
import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource"
|
import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource"
|
||||||
import { MvtToGeojson } from "mvt-to-geojson"
|
import { MvtToGeojson } from "mvt-to-geojson"
|
||||||
|
import { OsmTags } from "../../../Models/OsmFeature"
|
||||||
|
|
||||||
export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource {
|
export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource {
|
||||||
public readonly features: Store<GeojsonFeature<Geometry, { [name: string]: any }>[]>
|
public readonly features: Store<GeojsonFeature<Geometry, OsmTags>[]>
|
||||||
public readonly x: number
|
public readonly x: number
|
||||||
public readonly y: number
|
public readonly y: number
|
||||||
public readonly z: number
|
public readonly z: number
|
||||||
private readonly _url: string
|
private readonly _url: string
|
||||||
private readonly _layerName: string
|
|
||||||
private readonly _features: UIEventSource<
|
private readonly _features: UIEventSource<
|
||||||
GeojsonFeature<
|
GeojsonFeature<Geometry, OsmTags>[]
|
||||||
Geometry,
|
> = new UIEventSource<GeojsonFeature<Geometry, OsmTags>[]>([])
|
||||||
{
|
|
||||||
[name: string]: any
|
|
||||||
}
|
|
||||||
>[]
|
|
||||||
> = new UIEventSource<GeojsonFeature<Geometry, { [p: string]: any }>[]>([])
|
|
||||||
private currentlyRunning: Promise<any>
|
private currentlyRunning: Promise<any>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -26,11 +21,9 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
layerName?: string,
|
|
||||||
isActive?: Store<boolean>
|
isActive?: Store<boolean>
|
||||||
) {
|
) {
|
||||||
this._url = url
|
this._url = url
|
||||||
this._layerName = layerName
|
|
||||||
this.x = x
|
this.x = x
|
||||||
this.y = y
|
this.y = y
|
||||||
this.z = z
|
this.z = z
|
||||||
|
@ -61,7 +54,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const buffer = await result.arrayBuffer()
|
const buffer = await result.arrayBuffer()
|
||||||
const features = MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z)
|
const features = <Feature<Geometry, OsmTags>[]>MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z)
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const properties = feature.properties
|
const properties = feature.properties
|
||||||
if (!properties["osm_type"]) {
|
if (!properties["osm_type"]) {
|
||||||
|
|
|
@ -8,7 +8,8 @@ import { Utils } from "../../../Utils"
|
||||||
import { TagsFilter } from "../../Tags/TagsFilter"
|
import { TagsFilter } from "../../Tags/TagsFilter"
|
||||||
import { BBox } from "../../BBox"
|
import { BBox } from "../../BBox"
|
||||||
import { OsmTags } from "../../../Models/OsmFeature"
|
import { OsmTags } from "../../../Models/OsmFeature"
|
||||||
;("use strict")
|
|
||||||
|
("use strict")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper around the 'Overpass'-object.
|
* A wrapper around the 'Overpass'-object.
|
||||||
|
@ -138,7 +139,6 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
this.runningQuery.setData(true)
|
this.runningQuery.setData(true)
|
||||||
console.trace("Overpass feature source: querying geojson")
|
|
||||||
data = (await overpass.queryGeoJson(bounds))[0]
|
data = (await overpass.queryGeoJson(bounds))[0]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.retries.data++
|
this.retries.data++
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import { UIEventSource } from "../../UIEventSource"
|
import { UIEventSource } from "../../UIEventSource"
|
||||||
import FilteredLayer from "../../../Models/FilteredLayer"
|
import FilteredLayer from "../../../Models/FilteredLayer"
|
||||||
import { FeatureSourceForLayer } from "../FeatureSource"
|
import { FeatureSourceForLayer } from "../FeatureSource"
|
||||||
import { Feature } from "geojson"
|
import { Feature, Geometry } from "geojson"
|
||||||
|
import { OsmTags } from "../../../Models/OsmFeature"
|
||||||
|
|
||||||
export default class SimpleFeatureSource implements FeatureSourceForLayer {
|
export default class SimpleFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> implements FeatureSourceForLayer<T> {
|
||||||
public readonly features: UIEventSource<Feature[]>
|
public readonly features: UIEventSource<T[]>
|
||||||
public readonly layer: FilteredLayer
|
public readonly layer: FilteredLayer
|
||||||
|
|
||||||
constructor(layer: FilteredLayer, featureSource?: UIEventSource<Feature[]>) {
|
constructor(layer: FilteredLayer, featureSource?: UIEventSource<T[]>) {
|
||||||
this.layer = layer
|
this.layer = layer
|
||||||
this.features = featureSource ?? new UIEventSource<Feature[]>([])
|
this.features = featureSource ?? new UIEventSource<T[]>([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<T extends Feature<Point> = Feature<Point>> implements FeatureSource<T> {
|
||||||
|
|
||||||
|
private readonly id: string
|
||||||
|
private readonly showSummaryAt: "tilecenter" | "average"
|
||||||
|
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
|
||||||
|
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<Feature<Point, SummaryProperties>[]>([])
|
||||||
|
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<Point, SummaryProperties>[] = []
|
||||||
|
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]))
|
||||||
|
|
||||||
|
ClusterGrouping.singleton.registerSource(summaryPoints)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private createSummaryFeature(features: Feature<Point>[], tileId: number): Feature<Point, SummaryProperties> {
|
||||||
|
|
||||||
|
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<Feature<Point, { total_metric: string }>> {
|
||||||
|
private readonly _features: UIEventSource<Feature<Point, { total_metric: string }>[]> = new UIEventSource([])
|
||||||
|
public readonly features: Store<Feature<Point, { total_metric: string }>[]> = 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<Feature<Point, { total: number, tile_id: number }>[]>[] = []
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
const countPerTile = new Map<number, number>()
|
||||||
|
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<Point, { total_metric: string, id: string }>[] = []
|
||||||
|
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<Feature<Point, SummaryProperties>[]>) {
|
||||||
|
this.allSource.push(features)
|
||||||
|
features.addCallbackAndRun(() => {
|
||||||
|
//this.isDirty.set(true)
|
||||||
|
this.update()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -10,11 +10,12 @@ import {
|
||||||
MultiPolygon,
|
MultiPolygon,
|
||||||
Point,
|
Point,
|
||||||
Polygon,
|
Polygon,
|
||||||
Position,
|
Position
|
||||||
} from "geojson"
|
} from "geojson"
|
||||||
import { Tiles } from "../Models/TileRange"
|
import { Tiles } from "../Models/TileRange"
|
||||||
import { Utils } from "../Utils"
|
import { Utils } from "../Utils"
|
||||||
;("use strict")
|
|
||||||
|
("use strict")
|
||||||
|
|
||||||
export class GeoOperations {
|
export class GeoOperations {
|
||||||
private static readonly _earthRadius: number = 6378137
|
private static readonly _earthRadius: number = 6378137
|
||||||
|
@ -536,10 +537,23 @@ export class GeoOperations {
|
||||||
* @param features
|
* @param features
|
||||||
* @param zoomlevel
|
* @param zoomlevel
|
||||||
*/
|
*/
|
||||||
public static spreadIntoBboxes(features: Feature[], zoomlevel: number): Map<number, Feature[]> {
|
public static spreadIntoBboxes<T extends Feature = Feature>(features: T[], zoomlevel: number): Map<number, T[]> {
|
||||||
const perBbox = new Map<number, Feature[]>()
|
const perBbox = new Map<number, T[]>()
|
||||||
|
const z = zoomlevel
|
||||||
for (const feature of features) {
|
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 bbox = BBox.get(feature)
|
||||||
const tilerange = bbox.expandToTileBounds(zoomlevel).containingTileRange(zoomlevel)
|
const tilerange = bbox.expandToTileBounds(zoomlevel).containingTileRange(zoomlevel)
|
||||||
Tiles.MapRange(tilerange, (x, y) => {
|
Tiles.MapRange(tilerange, (x, y) => {
|
||||||
|
|
|
@ -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 {
|
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",
|
"point",
|
||||||
"centroid",
|
"centroid",
|
||||||
"start",
|
"start",
|
||||||
|
@ -41,16 +44,7 @@ export default class PointRenderingConfig extends WithContextLoader {
|
||||||
"polygon_centroid",
|
"polygon_centroid",
|
||||||
"waypoints",
|
"waypoints",
|
||||||
])
|
])
|
||||||
public readonly location: Set<
|
public readonly location: Set<PointRenderingLocation>
|
||||||
| "point"
|
|
||||||
| "centroid"
|
|
||||||
| "start"
|
|
||||||
| "end"
|
|
||||||
| "projected_centerpoint"
|
|
||||||
| "polygon_centroid"
|
|
||||||
| "waypoints"
|
|
||||||
| string
|
|
||||||
>
|
|
||||||
|
|
||||||
public readonly marker: IconConfig[]
|
public readonly marker: IconConfig[]
|
||||||
public readonly iconBadges: { if: TagsFilter; then: TagRenderingConfig }[]
|
public readonly iconBadges: { if: TagsFilter; then: TagRenderingConfig }[]
|
||||||
|
@ -77,10 +71,10 @@ export default class PointRenderingConfig extends WithContextLoader {
|
||||||
json.location = [json.location]
|
json.location = [json.location]
|
||||||
}
|
}
|
||||||
|
|
||||||
this.location = new Set(json.location)
|
this.location = new Set(<PointRenderingLocation[]>json.location)
|
||||||
|
|
||||||
this.location.forEach((l) => {
|
this.location.forEach((l) => {
|
||||||
const allowed = PointRenderingConfig.allowed_location_codes
|
const allowed = PointRenderingConfig.allowed_location_codes_set
|
||||||
if (!allowed.has(l)) {
|
if (!allowed.has(l)) {
|
||||||
throw `A point rendering has an invalid location: '${l}' is not one of ${Array.from(
|
throw `A point rendering has an invalid location: '${l}' is not one of ${Array.from(
|
||||||
allowed
|
allowed
|
||||||
|
@ -313,10 +307,9 @@ export default class PointRenderingConfig extends WithContextLoader {
|
||||||
}
|
}
|
||||||
const cssLabel = this.labelCss?.GetRenderValue(tags.data)?.txt
|
const cssLabel = this.labelCss?.GetRenderValue(tags.data)?.txt
|
||||||
const cssClassesLabel = this.labelCssClasses?.GetRenderValue(tags.data)?.txt
|
const cssClassesLabel = this.labelCssClasses?.GetRenderValue(tags.data)?.txt
|
||||||
const self = this
|
|
||||||
return new VariableUiElement(
|
return new VariableUiElement(
|
||||||
tags.map((tags) => {
|
tags.map((tags) => {
|
||||||
const label = self.label
|
const label = this.label
|
||||||
?.GetRenderValue(tags)
|
?.GetRenderValue(tags)
|
||||||
?.Subs(tags)
|
?.Subs(tags)
|
||||||
?.SetClass("flex items-center justify-center absolute marker-label")
|
?.SetClass("flex items-center justify-center absolute marker-label")
|
||||||
|
|
|
@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { Changes } from "../../Logic/Osm/Changes"
|
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 { WithLayoutSourceState } from "./WithLayoutSourceState"
|
||||||
import ThemeConfig from "../ThemeConfig/ThemeConfig"
|
import ThemeConfig from "../ThemeConfig/ThemeConfig"
|
||||||
import { Utils } from "../../Utils"
|
import { Utils } from "../../Utils"
|
||||||
|
@ -18,9 +20,7 @@ import { Map as MlMap } from "maplibre-gl"
|
||||||
import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource"
|
import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource"
|
||||||
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
|
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
|
||||||
import SelectedElementTagsUpdater from "../../Logic/Actors/SelectedElementTagsUpdater"
|
import SelectedElementTagsUpdater from "../../Logic/Actors/SelectedElementTagsUpdater"
|
||||||
import NoElementsInViewDetector, {
|
import NoElementsInViewDetector, { FeatureViewState } from "../../Logic/Actors/NoElementsInViewDetector"
|
||||||
FeatureViewState,
|
|
||||||
} from "../../Logic/Actors/NoElementsInViewDetector"
|
|
||||||
|
|
||||||
export class WithChangesState extends WithLayoutSourceState {
|
export class WithChangesState extends WithLayoutSourceState {
|
||||||
readonly changes: Changes
|
readonly changes: Changes
|
||||||
|
@ -219,14 +219,24 @@ export class WithChangesState extends WithLayoutSourceState {
|
||||||
)
|
)
|
||||||
filteringFeatureSource.set(layerName, filtered)
|
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,
|
layer: fs.layer.layerDef,
|
||||||
features: filtered,
|
features: filtered,
|
||||||
doShowLayer,
|
doShowLayer,
|
||||||
metaTags: this.userRelatedState.preferencesAsTags,
|
metaTags: this.userRelatedState.preferencesAsTags,
|
||||||
selectedElement: this.selectedElement,
|
selectedElement: this.selectedElement,
|
||||||
fetchStore: (id) => this.featureProperties.getStore(id),
|
fetchStore: (id) => this.featureProperties.getStore(id),
|
||||||
})
|
})*/
|
||||||
})
|
})
|
||||||
return filteringFeatureSource
|
return filteringFeatureSource
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,9 +18,10 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource"
|
import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource"
|
||||||
import {
|
import {
|
||||||
SummaryTileSource,
|
SummaryTileSource,
|
||||||
SummaryTileSourceRewriter,
|
SummaryTileSourceRewriter
|
||||||
} from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource"
|
} from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource"
|
||||||
import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions"
|
import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions"
|
||||||
|
import { ClusterGrouping } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource"
|
||||||
|
|
||||||
export class WithSpecialLayers extends WithChangesState {
|
export class WithSpecialLayers extends WithChangesState {
|
||||||
readonly favourites: FavouritesFeatureSource
|
readonly favourites: FavouritesFeatureSource
|
||||||
|
@ -63,6 +64,7 @@ export class WithSpecialLayers extends WithChangesState {
|
||||||
this.closestFeatures.registerSource(this.favourites, "favourite")
|
this.closestFeatures.registerSource(this.favourites, "favourite")
|
||||||
|
|
||||||
this.featureSummary = this.setupSummaryLayer()
|
this.featureSummary = this.setupSummaryLayer()
|
||||||
|
this.setupClusterLayer()
|
||||||
this.initActorsSpecialLayers()
|
this.initActorsSpecialLayers()
|
||||||
this.drawSelectedElement()
|
this.drawSelectedElement()
|
||||||
this.drawSpecialLayers()
|
this.drawSpecialLayers()
|
||||||
|
@ -131,6 +133,18 @@ export class WithSpecialLayers extends WithChangesState {
|
||||||
return source
|
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(<LayerConfigJson>(<unknown>summaryLayer), "summaryLayer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) {
|
protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) {
|
||||||
if (!source?.features) {
|
if (!source?.features) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -53,11 +53,20 @@ export class Tiles {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the centerpoint [lon, lat] of the specified tile
|
* Returns the centerpoint [lon, lat] of the specified tile
|
||||||
* @param z
|
* @param z OR tileId
|
||||||
* @param x
|
* @param x
|
||||||
* @param y
|
* @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 [
|
return [
|
||||||
(Tiles.tile2long(x, z) + Tiles.tile2long(x + 1, z)) / 2,
|
(Tiles.tile2long(x, z) + Tiles.tile2long(x + 1, z)) / 2,
|
||||||
(Tiles.tile2lat(y, z) + Tiles.tile2lat(y + 1, z)) / 2,
|
(Tiles.tile2lat(y, z) + Tiles.tile2lat(y + 1, z)) / 2,
|
||||||
|
|
277
src/UI/Map/PointRenderingLayer.ts
Normal file
277
src/UI/Map/PointRenderingLayer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,11 @@
|
||||||
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
|
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
import type { AddLayerObject, Alignment, Map as MlMap } from "maplibre-gl"
|
import type { AddLayerObject, Map as MlMap } from "maplibre-gl"
|
||||||
import { GeoJSONSource, Marker } from "maplibre-gl"
|
import { GeoJSONSource } from "maplibre-gl"
|
||||||
import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
|
import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
|
||||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
|
||||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||||
import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig"
|
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
|
||||||
import { OsmTags } from "../../Models/OsmFeature"
|
|
||||||
import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource"
|
|
||||||
import { BBox } from "../../Logic/BBox"
|
import { BBox } from "../../Logic/BBox"
|
||||||
import { Feature, Point } from "geojson"
|
import { Feature, Geometry } from "geojson"
|
||||||
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
|
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
|
||||||
import { Utils } from "../../Utils"
|
import { Utils } from "../../Utils"
|
||||||
import * as range_layer from "../../../assets/layers/range/range.json"
|
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 SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource"
|
||||||
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
|
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
|
||||||
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
|
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
|
||||||
|
import { PointRenderingLayer } from "./PointRenderingLayer"
|
||||||
class PointRenderingLayer {
|
import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource"
|
||||||
private readonly _config: PointRenderingConfig
|
import { OsmTags } from "../../Models/OsmFeature"
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LineRenderingLayer {
|
class LineRenderingLayer {
|
||||||
/**
|
/**
|
||||||
|
@ -564,7 +361,7 @@ export default class ShowDataLayer {
|
||||||
layers: LayerConfig[],
|
layers: LayerConfig[],
|
||||||
options?: Partial<Omit<ShowDataLayerOptions, "features" | "layer">>
|
options?: Partial<Omit<ShowDataLayerOptions, "features" | "layer">>
|
||||||
) {
|
) {
|
||||||
const perLayer: PerLayerFeatureSourceSplitter<FeatureSourceForLayer> =
|
const perLayer =
|
||||||
new PerLayerFeatureSourceSplitter(
|
new PerLayerFeatureSourceSplitter(
|
||||||
layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)),
|
layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)),
|
||||||
features,
|
features,
|
||||||
|
@ -582,18 +379,41 @@ export default class ShowDataLayer {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
perLayer.forEach((fs) => {
|
perLayer.forEach((features) => {
|
||||||
new ShowDataLayer(mlmap, {
|
new ShowDataLayer(mlmap, {
|
||||||
layer: fs.layer.layerDef,
|
layer: features.layer.layerDef,
|
||||||
features: fs,
|
features,
|
||||||
...(options ?? {}),
|
...(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<MlMap>,
|
||||||
|
state: { mapProperties: { zoom: UIEventSource<number> } },
|
||||||
|
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(
|
public static showRange(
|
||||||
map: Store<MlMap>,
|
map: Store<MlMap>,
|
||||||
features: FeatureSource,
|
features: FeatureSource<Feature<Geometry, OsmTags>>,
|
||||||
doShowLayer?: Store<boolean>
|
doShowLayer?: Store<boolean>
|
||||||
): ShowDataLayer {
|
): ShowDataLayer {
|
||||||
return new ShowDataLayer(map, {
|
return new ShowDataLayer(map, {
|
||||||
|
@ -603,8 +423,6 @@ export default class ShowDataLayer {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public destruct() {}
|
|
||||||
|
|
||||||
private static zoomToCurrentFeatures(map: MlMap, features: Feature[]) {
|
private static zoomToCurrentFeatures(map: MlMap, features: Feature[]) {
|
||||||
if (!features || !map || features.length == 0) {
|
if (!features || !map || features.length == 0) {
|
||||||
return
|
return
|
||||||
|
@ -635,6 +453,7 @@ export default class ShowDataLayer {
|
||||||
layer,
|
layer,
|
||||||
drawLines,
|
drawLines,
|
||||||
drawMarkers,
|
drawMarkers,
|
||||||
|
preprocessPoints
|
||||||
} = this._options
|
} = this._options
|
||||||
let onClick = this._options.onClick
|
let onClick = this._options.onClick
|
||||||
if (!onClick && selectedElement && layer.title !== undefined) {
|
if (!onClick && selectedElement && layer.title !== undefined) {
|
||||||
|
@ -672,7 +491,8 @@ export default class ShowDataLayer {
|
||||||
doShowLayer,
|
doShowLayer,
|
||||||
fetchStore,
|
fetchStore,
|
||||||
onClick,
|
onClick,
|
||||||
selectedElement
|
selectedElement,
|
||||||
|
preprocessPoints
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
|
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
|
||||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
import { Feature } from "geojson"
|
import { Feature, Geometry, Point } from "geojson"
|
||||||
|
import { OsmTags } from "../../Models/OsmFeature"
|
||||||
|
|
||||||
export interface ShowDataLayerOptions {
|
export interface ShowDataLayerOptions {
|
||||||
/**
|
/**
|
||||||
* Features to show
|
* Features to show
|
||||||
*/
|
*/
|
||||||
features: FeatureSource
|
features: FeatureSource<Feature<Geometry, OsmTags>>
|
||||||
/**
|
/**
|
||||||
* Indication of the current selected element; overrides some filters.
|
* Indication of the current selected element; overrides some filters.
|
||||||
* When a feature is tapped, the feature will be put in there
|
* When a feature is tapped, the feature will be put in there
|
||||||
|
@ -31,5 +32,6 @@ export interface ShowDataLayerOptions {
|
||||||
onClick?: (feature: Feature) => void
|
onClick?: (feature: Feature) => void
|
||||||
metaTags?: Store<Record<string, string>>
|
metaTags?: Store<Record<string, string>>
|
||||||
|
|
||||||
prefix?: string
|
prefix?: string,
|
||||||
|
preprocessPoints?: <T extends Feature<Point>>(fs: FeatureSource<T>) => FeatureSource<T>
|
||||||
}
|
}
|
||||||
|
|
84
test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts
Normal file
84
test/Logic/FeatureSource/ClusteringFeatureSource.spec.ts
Normal file
|
@ -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<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
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
Loading…
Add table
Add a link
Reference in a new issue