MapComplete/src/UI/Map/PointRenderingLayer.ts

277 lines
10 KiB
TypeScript

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