MapComplete/src/UI/Map/ShowDataLayer.ts

502 lines
18 KiB
TypeScript

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 LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { BBox } from "../../Logic/BBox"
import { Feature, Geometry } from "geojson"
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
import { Utils } from "../../Utils"
import * as range_layer from "../../../assets/layers/range/range.json"
import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter"
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"
import { PointRenderingLayer } from "./PointRenderingLayer"
import { ClusteringFeatureSource } from "../../Logic/FeatureSource/TiledFeatureSource/ClusteringFeatureSource"
import { OsmTags } from "../../Models/OsmFeature"
class LineRenderingLayer {
/**
* These are dynamic properties
* @private
*/
private static readonly lineConfigKeys = [
"color",
"width",
"lineCap",
"offset",
"fill",
"fillColor",
] as const
private static readonly lineConfigKeysColor = ["color", "fillColor"] as const
private static readonly lineConfigKeysNumber = ["width", "offset"] as const
private static missingIdTriggered = false
private readonly _map: MlMap
private readonly _config: LineRenderingConfig
private readonly _visibility?: Store<boolean>
private readonly _fetchStore?: (id: string) => Store<Record<string, string>>
private readonly _onClick?: (feature: Feature) => void
private readonly _layername: string
private readonly _listenerInstalledOn: Set<string> = new Set<string>()
private currentSourceData
constructor(
map: MlMap,
features: FeatureSource,
layername: string,
config: LineRenderingConfig,
visibility?: Store<boolean>,
fetchStore?: (id: string) => Store<Record<string, string>>,
onClick?: (feature: Feature) => void
) {
this._layername = layername
this._map = map
this._config = config
this._visibility = visibility
this._fetchStore = fetchStore
this._onClick = onClick
const updateNeededSrc = new UIEventSource(false)
updateNeededSrc.stabilized(100).addCallbackAndRunD(async (updateNeeded) => {
if (updateNeeded) {
await this.awaitStyleLoaded()
await this.update(features.features.data)
if (
features.features.data.length === 0 &&
"mapcomplete_pedestrian_path_linerendering_2" === this._layername
) {
console.trace(this._layername, "is empty")
}
updateNeededSrc.set(false)
}
})
features.features.addCallbackAndRunD(async (feats) => {
updateNeededSrc.set(true)
this.update(feats)
})
map.on("styledata", () => updateNeededSrc.set(true))
this.awaitStyleLoaded().then(() => {
const feats = features.features.data
if (feats.length > 0) {
this.update(feats)
} else {
updateNeededSrc.set(true)
}
})
}
public destruct(): void {
this._map.removeLayer(this._layername + "_polygon")
}
private async addSymbolLayer(
sourceId: string,
imageAlongWay: { if?: TagsFilter; then: string }[]
) {
const map = this._map
await Promise.allSettled(
imageAlongWay.map(async (img, i) => {
const imgId = img.then.replaceAll(/[/.-]/g, "_")
if (map.getImage(imgId) === undefined) {
const loadedImage = await map.loadImage(img.then)
map.addImage(imgId, loadedImage.data)
}
const spec: AddLayerObject = {
id: "symbol-layer_" + this._layername + "-" + i,
type: "symbol",
source: sourceId,
layout: {
"symbol-placement": "line",
"symbol-spacing": 10,
"icon-allow-overlap": true,
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "map",
"icon-image": imgId,
"icon-size": 0.055,
},
}
const filter = img.if?.asMapboxExpression()
if (filter) {
spec.filter = filter
}
map.addLayer(spec)
})
)
}
/**
* Calculate the feature-state for maplibre
* @param properties
* @private
*/
private calculatePropsFor(
properties: Record<string, string>
): Partial<Record<(typeof LineRenderingLayer.lineConfigKeys)[number], string>> {
const config = this._config
const calculatedProps: Record<string, string | number> = {}
for (const key of LineRenderingLayer.lineConfigKeys) {
calculatedProps[key] = config[key]?.GetRenderValue(properties)?.Subs(properties).txt
}
calculatedProps.fillColor = calculatedProps.fillColor ?? calculatedProps.color
for (const key of LineRenderingLayer.lineConfigKeysColor) {
const v = <string>calculatedProps[key]
if (v === undefined) {
continue
}
if (v.length == 9 && v.startsWith("#")) {
// This includes opacity
calculatedProps[`${key}-opacity`] = parseInt(v.substring(7), 16) / 256
calculatedProps[key] = v.substring(0, 7)
}
}
calculatedProps["fillColor-opacity"] = calculatedProps["fillColor-opacity"] ?? 0.1
for (const key of LineRenderingLayer.lineConfigKeysNumber) {
calculatedProps[key] = Number(calculatedProps[key])
}
return calculatedProps
}
private async awaitStyleLoaded() {
while (!this._map.isStyleLoaded()) {
await Utils.waitFor(100)
}
}
private async update(features: Feature[]) {
const map = this._map
await this.awaitStyleLoaded()
const src = <GeoJSONSource>map.getSource(this._layername)
{
// Add source to the map or update the feature source
if (src === undefined) {
this.currentSourceData = features
map.addSource(this._layername, {
type: "geojson",
data: {
type: "FeatureCollection",
features,
},
promoteId: "id",
})
const linelayer = this._layername + "_line"
const layer: AddLayerObject = {
source: this._layername,
id: linelayer,
type: "line",
paint: {
"line-color": ["feature-state", "color"],
"line-opacity": ["feature-state", "color-opacity"],
"line-width": ["feature-state", "width"],
"line-offset": ["feature-state", "offset"],
},
layout: {
"line-cap": "round",
},
}
if (this._config.dashArray) {
try {
layer.paint["line-dasharray"] =
this._config.dashArray?.split(" ")?.map((s) => Number(s)) ?? null
} catch (e) {
console.error(
`Invalid dasharray in layer ${this._layername}:`,
this._config.dashArray
)
}
}
map.addLayer(layer)
if (this._config.imageAlongWay) {
this.addSymbolLayer(this._layername, this._config.imageAlongWay)
}
for (const feature of features) {
if (!feature.properties.id) {
console.warn("Feature without id:", feature)
continue
}
map.setFeatureState(
{ source: this._layername, id: feature.properties.id },
this.calculatePropsFor(feature.properties)
)
}
if (this._onClick) {
map.on("click", linelayer, (e) => {
// line-layer-listener
e.originalEvent["consumed"] = true
this._onClick(e.features[0])
})
}
const polylayer = this._layername + "_polygon"
map.addLayer({
source: this._layername,
id: polylayer,
type: "fill",
filter: ["in", ["geometry-type"], ["literal", ["Polygon", "MultiPolygon"]]],
layout: {},
paint: {
"fill-color": ["feature-state", "fillColor"],
"fill-opacity": ["feature-state", "fillColor-opacity"],
},
})
if (this._onClick) {
map.on("click", polylayer, (e) => {
// polygon-layer-listener
if (e.originalEvent["consumed"]) {
// This is a polygon beneath a marker, we can ignore it
return
}
e.originalEvent["consumed"] = true
this._onClick(e.features[0])
})
}
this._visibility?.addCallbackAndRunD((visible) => {
try {
map.setLayoutProperty(linelayer, "visibility", visible ? "visible" : "none")
map.setLayoutProperty(polylayer, "visibility", visible ? "visible" : "none")
} catch (e) {
console.warn(
"Error while setting visibility of layers ",
linelayer,
polylayer,
e
)
}
})
} else {
this.currentSourceData = features
src.setData({
type: "FeatureCollection",
features: this.currentSourceData,
})
}
}
for (let i = 0; i < features.length; i++) {
// Installs a listener on the 'Tags' of every individual feature to update the rendering
const feature = features[i]
const id = feature.properties.id ?? feature.id
if (id === undefined) {
if (!LineRenderingLayer.missingIdTriggered) {
console.trace(
"Got a feature without ID; this causes rendering bugs:",
feature,
"from"
)
LineRenderingLayer.missingIdTriggered = true
}
continue
}
if (this._listenerInstalledOn.has(id)) {
continue
}
if (this._fetchStore === undefined) {
map.setFeatureState(
{ source: this._layername, id },
this.calculatePropsFor(feature.properties)
)
} else {
const tags = this._fetchStore(id)
this._listenerInstalledOn.add(id)
tags?.addCallbackAndRunD((properties) => {
// Make sure to use 'getSource' here, the layer names are different!
try {
if (map.getSource(this._layername) === undefined) {
return true
}
} catch (e) {
console.debug("Could not fetch source for", this._layername)
return
}
map.setFeatureState(
{ source: this._layername, id },
this.calculatePropsFor(properties)
)
})
}
}
}
}
export default class ShowDataLayer {
public static rangeLayer = new LayerConfig(
<LayerConfigJson>range_layer,
"ShowDataLayer.ts:range.json"
)
private readonly _options: ShowDataLayerOptions & {
layer: LayerConfig
drawMarkers?: true | boolean
drawLines?: true | boolean
}
private onDestroy: (() => void)[] = []
constructor(
map: Store<MlMap>,
options: ShowDataLayerOptions & {
layer: LayerConfig
drawMarkers?: true | boolean
drawLines?: true | boolean
}
) {
this._options = options
this.onDestroy.push(map.addCallbackAndRunD((map) => this.initDrawFeatures(map)))
}
public static showMultipleLayers(
mlmap: UIEventSource<MlMap>,
features: FeatureSource<Feature<Geometry, Record<string, any> & {id: string}>>,
layers: LayerConfig[],
options?: Partial<Omit<ShowDataLayerOptions, "features" | "layer">>
) {
const perLayer = new PerLayerFeatureSourceSplitter(
layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)),
features,
{
constructStore: (features, layer) => new SimpleFeatureSource(layer, features),
}
)
if (options?.zoomToFeatures) {
options.zoomToFeatures = false
features.features.addCallbackD((features) => {
ShowDataLayer.zoomToCurrentFeatures(mlmap.data, features)
})
mlmap.addCallbackD((map) => {
ShowDataLayer.zoomToCurrentFeatures(map, features.features.data)
})
}
perLayer.forEach((features) => {
new ShowDataLayer(mlmap, {
layer: features.layer.layerDef,
features,
...(options ?? {}),
})
})
}
/**
* Show the data, unless they are clustered.
* This method does _not_ add the clusters themselves to the map,
* this should be done independently. In a themeViewGui, this is done by the 'addSpecialLayers'
* @see ClusterGrouping
* @param mlmap
* @param state
* @param options
*/
public static showLayerClustered(
mlmap: Store<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
}
)
new ShowDataLayer(mlmap, options)
}
public static showRange(
map: Store<MlMap>,
features: FeatureSource<Feature<Geometry, OsmTags>>,
doShowLayer?: Store<boolean>
): ShowDataLayer {
return new ShowDataLayer(map, {
layer: ShowDataLayer.rangeLayer,
features,
doShowLayer,
})
}
private static zoomToCurrentFeatures(map: MlMap, features: Feature[]) {
if (!features || !map || features.length == 0) {
return
}
const bbox = BBox.bboxAroundAll(features.map(BBox.get))
window.requestAnimationFrame(() => {
try {
map.resize()
} catch (e) {
console.error(
"Could not resize the map in preparation of zoomToCurrentFeatures; the error is:",
e
)
}
map.fitBounds(bbox.toLngLat(), {
padding: { top: 10, bottom: 10, left: 10, right: 10 },
animate: false,
})
})
}
private initDrawFeatures(map: MlMap) {
const {
features,
doShowLayer,
fetchStore,
selectedElement,
layer,
drawLines,
drawMarkers,
preprocessPoints,
} = this._options
let onClick = this._options.onClick
if (drawLines !== false) {
for (let i = 0; i < layer.lineRendering.length; i++) {
const lineRenderingConfig = layer.lineRendering[i]
const l = new LineRenderingLayer(
map,
features,
"mapcomplete_" +
(this._options.prefix ?? "") +
layer.id +
"_linerendering_" +
i,
lineRenderingConfig,
doShowLayer,
fetchStore,
onClick
)
this.onDestroy.push(l.destruct)
}
}
if (drawMarkers !== false) {
for (const pointRenderingConfig of layer.mapRendering) {
new PointRenderingLayer(
map,
layer,
features,
pointRenderingConfig,
this._options.metaTags,
doShowLayer,
fetchStore,
onClick,
selectedElement,
preprocessPoints
)
}
}
if (this._options.zoomToFeatures) {
features.features.addCallbackAndRunD((features) =>
ShowDataLayer.zoomToCurrentFeatures(map, features)
)
}
}
}