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 private readonly _fetchStore?: (id: string) => Store> private readonly _onClick?: (feature: Feature) => void private readonly _layername: string private readonly _listenerInstalledOn: Set = new Set() private currentSourceData constructor( map: MlMap, features: FeatureSource, layername: string, config: LineRenderingConfig, visibility?: Store, fetchStore?: (id: string) => Store>, 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 ): Partial> { const config = this._config const calculatedProps: Record = {} 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 = 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 = 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( range_layer, "ShowDataLayer.ts:range.json" ) private readonly _options: ShowDataLayerOptions & { layer: LayerConfig drawMarkers?: true | boolean drawLines?: true | boolean } private onDestroy: (() => void)[] = [] constructor( map: Store, 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, features: FeatureSource & {id: string}>>, layers: LayerConfig[], options?: Partial> ) { 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, state: { mapProperties: { zoom: UIEventSource } }, 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, features: FeatureSource>, doShowLayer?: Store ): 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) ) } } }