forked from MapComplete/MapComplete
277 lines
10 KiB
TypeScript
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
|
|
}
|
|
}
|