forked from MapComplete/MapComplete
		
	Refactoring: move all code files into a src directory
This commit is contained in:
		
							parent
							
								
									de99f56ca8
								
							
						
					
					
						commit
						e75d2789d2
					
				
					 389 changed files with 0 additions and 12 deletions
				
			
		
							
								
								
									
										541
									
								
								src/UI/Map/MapLibreAdaptor.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										541
									
								
								src/UI/Map/MapLibreAdaptor.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,541 @@ | |||
| import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
| import type { Map as MLMap } from "maplibre-gl" | ||||
| import { Map as MlMap, SourceSpecification } from "maplibre-gl" | ||||
| import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers" | ||||
| import { Utils } from "../../Utils" | ||||
| import { BBox } from "../../Logic/BBox" | ||||
| import { ExportableMap, MapProperties } from "../../Models/MapProperties" | ||||
| import SvelteUIElement from "../Base/SvelteUIElement" | ||||
| import MaplibreMap from "./MaplibreMap.svelte" | ||||
| import { RasterLayerProperties } from "../../Models/RasterLayerProperties" | ||||
| import * as htmltoimage from "html-to-image" | ||||
| 
 | ||||
| /** | ||||
|  * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties` | ||||
|  */ | ||||
| export class MapLibreAdaptor implements MapProperties, ExportableMap { | ||||
|     private static maplibre_control_handlers = [ | ||||
|         // "scrollZoom",
 | ||||
|         // "boxZoom",
 | ||||
|         // "doubleClickZoom",
 | ||||
|         "dragRotate", | ||||
|         "dragPan", | ||||
|         "keyboard", | ||||
|         "touchZoomRotate", | ||||
|     ] | ||||
|     private static maplibre_zoom_handlers = [ | ||||
|         "scrollZoom", | ||||
|         "boxZoom", | ||||
|         "doubleClickZoom", | ||||
|         "touchZoomRotate", | ||||
|     ] | ||||
|     readonly location: UIEventSource<{ lon: number; lat: number }> | ||||
|     readonly zoom: UIEventSource<number> | ||||
|     readonly bounds: UIEventSource<BBox> | ||||
|     readonly rasterLayer: UIEventSource<RasterLayerPolygon | undefined> | ||||
|     readonly maxbounds: UIEventSource<BBox | undefined> | ||||
|     readonly allowMoving: UIEventSource<true | boolean | undefined> | ||||
|     readonly allowZooming: UIEventSource<true | boolean | undefined> | ||||
|     readonly lastClickLocation: Store<undefined | { lon: number; lat: number }> | ||||
|     readonly minzoom: UIEventSource<number> | ||||
|     readonly maxzoom: UIEventSource<number> | ||||
|     private readonly _maplibreMap: Store<MLMap> | ||||
|     /** | ||||
|      * Used for internal bookkeeping (to remove a rasterLayer when done loading) | ||||
|      * @private | ||||
|      */ | ||||
|     private _currentRasterLayer: string | ||||
| 
 | ||||
|     constructor(maplibreMap: Store<MLMap>, state?: Partial<MapProperties>) { | ||||
|         this._maplibreMap = maplibreMap | ||||
| 
 | ||||
|         this.location = state?.location ?? new UIEventSource(undefined) | ||||
|         if (this.location.data) { | ||||
|             // The MapLibre adaptor updates the element in the location and then pings them
 | ||||
|             // Often, code setting this up doesn't expect the object they pass in to be changed, so we create a copy
 | ||||
|             this.location.setData({ ...this.location.data }) | ||||
|         } | ||||
|         this.zoom = state?.zoom ?? new UIEventSource(1) | ||||
|         this.minzoom = state?.minzoom ?? new UIEventSource(0) | ||||
|         this.maxzoom = state?.maxzoom ?? new UIEventSource(24) | ||||
|         this.zoom.addCallbackAndRunD((z) => { | ||||
|             if (z < this.minzoom.data) { | ||||
|                 this.zoom.setData(this.minzoom.data) | ||||
|             } | ||||
|             const max = Math.min(24, this.maxzoom.data ?? 24) | ||||
|             if (z > max) { | ||||
|                 this.zoom.setData(max) | ||||
|             } | ||||
|         }) | ||||
|         this.maxbounds = state?.maxbounds ?? new UIEventSource(undefined) | ||||
|         this.allowMoving = state?.allowMoving ?? new UIEventSource(true) | ||||
|         this.allowZooming = state?.allowZooming ?? new UIEventSource(true) | ||||
|         this.bounds = state?.bounds ?? new UIEventSource(undefined) | ||||
|         this.rasterLayer = | ||||
|             state?.rasterLayer ?? new UIEventSource<RasterLayerPolygon | undefined>(undefined) | ||||
| 
 | ||||
|         const lastClickLocation = new UIEventSource<{ lon: number; lat: number }>(undefined) | ||||
|         this.lastClickLocation = lastClickLocation | ||||
|         const self = this | ||||
| 
 | ||||
|         function handleClick(e) { | ||||
|             if (e.originalEvent["consumed"]) { | ||||
|                 // Workaround, 'ShowPointLayer' sets this flag
 | ||||
|                 return | ||||
|             } | ||||
|             const lon = e.lngLat.lng | ||||
|             const lat = e.lngLat.lat | ||||
|             lastClickLocation.setData({ lon, lat }) | ||||
|         } | ||||
| 
 | ||||
|         maplibreMap.addCallbackAndRunD((map) => { | ||||
|             map.on("load", () => { | ||||
|                 self.setBackground() | ||||
|                 self.MoveMapToCurrentLoc(self.location.data) | ||||
|                 self.SetZoom(self.zoom.data) | ||||
|                 self.setMaxBounds(self.maxbounds.data) | ||||
|                 self.setAllowMoving(self.allowMoving.data) | ||||
|                 self.setAllowZooming(self.allowZooming.data) | ||||
|                 self.setMinzoom(self.minzoom.data) | ||||
|                 self.setMaxzoom(self.maxzoom.data) | ||||
|                 self.setBounds(self.bounds.data) | ||||
|                 this.updateStores(true) | ||||
|             }) | ||||
|             self.MoveMapToCurrentLoc(self.location.data) | ||||
|             self.SetZoom(self.zoom.data) | ||||
|             self.setMaxBounds(self.maxbounds.data) | ||||
|             self.setAllowMoving(self.allowMoving.data) | ||||
|             self.setAllowZooming(self.allowZooming.data) | ||||
|             self.setMinzoom(self.minzoom.data) | ||||
|             self.setMaxzoom(self.maxzoom.data) | ||||
|             self.setBounds(self.bounds.data) | ||||
|             this.updateStores(true) | ||||
|             map.on("moveend", () => this.updateStores()) | ||||
|             map.on("click", (e) => { | ||||
|                 handleClick(e) | ||||
|             }) | ||||
|             map.on("contextmenu", (e) => { | ||||
|                 handleClick(e) | ||||
|             }) | ||||
|             map.on("dblclick", (e) => { | ||||
|                 handleClick(e) | ||||
|             }) | ||||
|         }) | ||||
| 
 | ||||
|         this.rasterLayer.addCallback((_) => | ||||
|             self.setBackground().catch((_) => { | ||||
|                 console.error("Could not set background") | ||||
|             }) | ||||
|         ) | ||||
| 
 | ||||
|         this.location.addCallbackAndRunD((loc) => { | ||||
|             self.MoveMapToCurrentLoc(loc) | ||||
|         }) | ||||
|         this.zoom.addCallbackAndRunD((z) => self.SetZoom(z)) | ||||
|         this.maxbounds.addCallbackAndRun((bbox) => self.setMaxBounds(bbox)) | ||||
|         this.allowMoving.addCallbackAndRun((allowMoving) => self.setAllowMoving(allowMoving)) | ||||
|         this.allowZooming.addCallbackAndRun((allowZooming) => self.setAllowZooming(allowZooming)) | ||||
|         this.bounds.addCallbackAndRunD((bounds) => self.setBounds(bounds)) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience constructor | ||||
|      */ | ||||
|     public static construct(): { | ||||
|         map: Store<MLMap> | ||||
|         ui: SvelteUIElement | ||||
|         mapproperties: MapProperties | ||||
|     } { | ||||
|         const mlmap = new UIEventSource<MlMap>(undefined) | ||||
|         return { | ||||
|             map: mlmap, | ||||
|             ui: new SvelteUIElement(MaplibreMap, { | ||||
|                 map: mlmap, | ||||
|             }), | ||||
|             mapproperties: new MapLibreAdaptor(mlmap), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static prepareWmsSource(layer: RasterLayerProperties): SourceSpecification { | ||||
|         return { | ||||
|             type: "raster", | ||||
|             // use the tiles option to specify a 256WMS tile source URL
 | ||||
|             // https://maplibre.org/maplibre-gl-js-docs/style-spec/sources/
 | ||||
|             tiles: [MapLibreAdaptor.prepareWmsURL(layer.url, layer["tile-size"] ?? 256)], | ||||
|             tileSize: layer["tile-size"] ?? 256, | ||||
|             minzoom: layer["min_zoom"] ?? 1, | ||||
|             maxzoom: layer["max_zoom"] ?? 25, | ||||
|             //  scheme: background["type"] === "tms" ? "tms" : "xyz",
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static setDpi( | ||||
|         drawOn: HTMLCanvasElement, | ||||
|         ctx: CanvasRenderingContext2D, | ||||
|         dpiFactor: number | ||||
|     ) { | ||||
|         drawOn.style.width = drawOn.style.width || drawOn.width + "px" | ||||
|         drawOn.style.height = drawOn.style.height || drawOn.height + "px" | ||||
| 
 | ||||
|         // Resize canvas and scale future draws.
 | ||||
|         drawOn.width = Math.ceil(drawOn.width * dpiFactor) | ||||
|         drawOn.height = Math.ceil(drawOn.height * dpiFactor) | ||||
|         ctx.scale(dpiFactor, dpiFactor) | ||||
|         console.log( | ||||
|             "Resizing canvas with setDPI:", | ||||
|             drawOn.width, | ||||
|             drawOn.height, | ||||
|             drawOn.style.width, | ||||
|             drawOn.style.height | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepares an ELI-URL to be compatible with mapbox | ||||
|      */ | ||||
|     private static prepareWmsURL(url: string, size: number = 256): string { | ||||
|         // ELI:  LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&CRS={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}&VERSION=1.3.0&SERVICE=WMS&REQUEST=GetMap
 | ||||
|         // PROD: SERVICE=WMS&REQUEST=GetMap&LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&TRANSPARENT=false&VERSION=1.3.0&WIDTH=256&HEIGHT=256&CRS=EPSG:3857&BBOX=488585.4847988467,6590094.830634755,489196.9810251281,6590706.32686104
 | ||||
| 
 | ||||
|         const toReplace = { | ||||
|             "{bbox}": "{bbox-epsg-3857}", | ||||
|             "{proj}": "EPSG:3857", | ||||
|             "{width}": "" + size, | ||||
|             "{height}": "" + size, | ||||
|             "{zoom}": "{z}", | ||||
|         } | ||||
| 
 | ||||
|         for (const key in toReplace) { | ||||
|             url = url.replace(new RegExp(key), toReplace[key]) | ||||
|         } | ||||
| 
 | ||||
|         const subdomains = url.match(/\{switch:([a-zA-Z0-9,]*)}/) | ||||
|         if (subdomains !== null) { | ||||
|             const options = subdomains[1].split(",") | ||||
|             const option = options[Math.floor(Math.random() * options.length)] | ||||
|             url = url.replace(subdomains[0], option) | ||||
|         } | ||||
| 
 | ||||
|         return url | ||||
|     } | ||||
| 
 | ||||
|     public async exportAsPng(dpiFactor: number): Promise<Blob> { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return undefined | ||||
|         } | ||||
|         const drawOn = document.createElement("canvas") | ||||
|         drawOn.width = map.getCanvas().width | ||||
|         drawOn.height = map.getCanvas().height | ||||
| 
 | ||||
|         console.log("Canvas size:", drawOn.width, drawOn.height) | ||||
|         const ctx = drawOn.getContext("2d") | ||||
|         // Set up CSS size.
 | ||||
|         MapLibreAdaptor.setDpi(drawOn, ctx, dpiFactor / map.getPixelRatio()) | ||||
| 
 | ||||
|         await this.exportBackgroundOnCanvas(ctx) | ||||
| 
 | ||||
|         console.log("Getting markers") | ||||
|         // MapLibreAdaptor.setDpi(drawOn, ctx, 1)
 | ||||
|         const markers = await this.drawMarkers(dpiFactor) | ||||
|         console.log( | ||||
|             "Drawing markers (" + | ||||
|                 markers.width + | ||||
|                 "*" + | ||||
|                 markers.height + | ||||
|                 ") onto drawOn (" + | ||||
|                 drawOn.width + | ||||
|                 "*" + | ||||
|                 drawOn.height + | ||||
|                 ")" | ||||
|         ) | ||||
|         ctx.drawImage(markers, 0, 0, drawOn.width, drawOn.height) | ||||
|         ctx.scale(dpiFactor, dpiFactor) | ||||
|         this._maplibreMap.data?.resize() | ||||
|         return await new Promise<Blob>((resolve) => drawOn.toBlob((blob) => resolve(blob))) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Exports the background map and lines to PNG. | ||||
|      * Markers are _not_ rendered | ||||
|      */ | ||||
|     private async exportBackgroundOnCanvas(ctx: CanvasRenderingContext2D): Promise<void> { | ||||
|         const map = this._maplibreMap.data | ||||
|         // We draw the maplibre-map onto the canvas. This does not export markers
 | ||||
|         // Inspiration by https://github.com/mapbox/mapbox-gl-js/issues/2766
 | ||||
| 
 | ||||
|         // Total hack - see https://stackoverflow.com/questions/42483449/mapbox-gl-js-export-map-to-png-or-pdf
 | ||||
|         const promise = new Promise<void>((resolve) => { | ||||
|             map.once("render", () => { | ||||
|                 ctx.drawImage(map.getCanvas(), 0, 0) | ||||
|                 resolve() | ||||
|             }) | ||||
|         }) | ||||
| 
 | ||||
|         while (!map.isStyleLoaded()) { | ||||
|             console.log("Waiting to fully load the style...") | ||||
|             await Utils.waitFor(100) | ||||
|         } | ||||
|         map.triggerRepaint() | ||||
|         await promise | ||||
|         map.resize() | ||||
|     } | ||||
| 
 | ||||
|     private async drawMarkers(dpiFactor: number): Promise<HTMLCanvasElement> { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return undefined | ||||
|         } | ||||
|         const width = map.getCanvas().clientWidth | ||||
|         const height = map.getCanvas().clientHeight | ||||
|         console.log( | ||||
|             "Canvas size markers:", | ||||
|             map.getCanvas().width, | ||||
|             map.getCanvas().height, | ||||
|             "canvasClientRect:", | ||||
|             width, | ||||
|             height | ||||
|         ) | ||||
|         map.getCanvas().style.display = "none" | ||||
|         const img = await htmltoimage.toCanvas(map.getCanvasContainer(), { | ||||
|             pixelRatio: dpiFactor, | ||||
|             canvasWidth: width, | ||||
|             canvasHeight: height, | ||||
|             width: width, | ||||
|             height: height, | ||||
|         }) | ||||
|         map.getCanvas().style.display = "unset" | ||||
|         return img | ||||
|     } | ||||
| 
 | ||||
|     private updateStores(isSetup: boolean = false): void { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         const { lng, lat } = map.getCenter() | ||||
|         if (lng === 0 && lat === 0) { | ||||
|             return | ||||
|         } | ||||
|         if (this.location.data === undefined) { | ||||
|             this.location.setData({ lon: lng, lat }) | ||||
|         } else if (!isSetup) { | ||||
|             const lon = map.getCenter().lng | ||||
|             const lat = map.getCenter().lat | ||||
|             this.location.setData({ lon, lat }) | ||||
|         } | ||||
|         this.zoom.setData(Math.round(map.getZoom() * 10) / 10) | ||||
|         const bounds = map.getBounds() | ||||
|         const bbox = new BBox([ | ||||
|             [bounds.getEast(), bounds.getNorth()], | ||||
|             [bounds.getWest(), bounds.getSouth()], | ||||
|         ]) | ||||
|         if (this.bounds.data === undefined || !isSetup) { | ||||
|             this.bounds.setData(bbox) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private SetZoom(z: number): void { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map || z === undefined) { | ||||
|             return | ||||
|         } | ||||
|         if (Math.abs(map.getZoom() - z) > 0.01) { | ||||
|             map.setZoom(z) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private MoveMapToCurrentLoc(loc: { lat: number; lon: number }): void { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map || loc === undefined) { | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         const center = map.getCenter() | ||||
|         if (center.lng !== loc.lon || center.lat !== loc.lat) { | ||||
|             if (isNaN(loc.lon) || isNaN(loc.lat)) { | ||||
|                 console.error("Got invalid lat or lon, not setting") | ||||
|             } else { | ||||
|                 map.setCenter({ lng: loc.lon, lat: loc.lat }) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async awaitStyleIsLoaded(): Promise<void> { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         while (!map?.isStyleLoaded()) { | ||||
|             await Utils.waitFor(250) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private removeCurrentLayer(map: MLMap): void { | ||||
|         if (this._currentRasterLayer) { | ||||
|             // hide the previous layer
 | ||||
|             try { | ||||
|                 if (map.getLayer(this._currentRasterLayer)) { | ||||
|                     map.removeLayer(this._currentRasterLayer) | ||||
|                 } | ||||
|                 if (map.getSource(this._currentRasterLayer)) { | ||||
|                     map.removeSource(this._currentRasterLayer) | ||||
|                 } | ||||
|                 this._currentRasterLayer = undefined | ||||
|             } catch (e) { | ||||
|                 console.warn("Could not remove the previous layer") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async setBackground(): Promise<void> { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         const background: RasterLayerProperties = this.rasterLayer?.data?.properties | ||||
|         console.log("Setting background to", background) | ||||
|         if (!background) { | ||||
|             console.error( | ||||
|                 "Attempting to 'setBackground', but the background is", | ||||
|                 background, | ||||
|                 "for", | ||||
|                 map.getCanvas() | ||||
|             ) | ||||
|             return | ||||
|         } | ||||
|         if (this._currentRasterLayer === background.id) { | ||||
|             // already the correct background layer, nothing to do
 | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         if (!background?.url) { | ||||
|             // no background to set
 | ||||
|             this.removeCurrentLayer(map) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         if (background.type === "vector") { | ||||
|             console.log("Background layer is vector", background.id) | ||||
|             this.removeCurrentLayer(map) | ||||
|             map.setStyle(background.url) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         let addLayerBeforeId = "aeroway_fill" // this is the first non-landuse item in the stylesheet, we add the raster layer before the roads but above the landuse
 | ||||
|         if (background.category === "osmbasedmap" || background.category === "map") { | ||||
|             // The background layer is already an OSM-based map or another map, so we don't want anything from the baselayer
 | ||||
|             addLayerBeforeId = undefined | ||||
|             this.removeCurrentLayer(map) | ||||
|         } else { | ||||
|             // Make sure that the default maptiler style is loaded as it gives an overlay with roads
 | ||||
|             const maptiler = AvailableRasterLayers.maplibre.properties | ||||
|             if (!map.getSource(maptiler.id)) { | ||||
|                 this.removeCurrentLayer(map) | ||||
|                 map.addSource(maptiler.id, MapLibreAdaptor.prepareWmsSource(maptiler)) | ||||
|                 map.setStyle(maptiler.url) | ||||
|                 await this.awaitStyleIsLoaded() | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (!map.getLayer(addLayerBeforeId)) { | ||||
|             addLayerBeforeId = undefined | ||||
|         } | ||||
|         if (!map.getSource(background.id)) { | ||||
|             map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background)) | ||||
|         } | ||||
|         map.resize() | ||||
|         if (!map.getLayer(background.id)) { | ||||
|             map.addLayer( | ||||
|                 { | ||||
|                     id: background.id, | ||||
|                     type: "raster", | ||||
|                     source: background.id, | ||||
|                     paint: {}, | ||||
|                 }, | ||||
|                 addLayerBeforeId | ||||
|             ) | ||||
|         } | ||||
|         await this.awaitStyleIsLoaded() | ||||
|         this.removeCurrentLayer(map) | ||||
|         this._currentRasterLayer = background?.id | ||||
|     } | ||||
| 
 | ||||
|     private setMaxBounds(bbox: undefined | BBox) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         if (bbox) { | ||||
|             map?.setMaxBounds(bbox.toLngLat()) | ||||
|         } else { | ||||
|             map?.setMaxBounds(null) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private setAllowMoving(allow: true | boolean | undefined) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         if (allow === false) { | ||||
|             for (const id of MapLibreAdaptor.maplibre_control_handlers) { | ||||
|                 map[id].disable() | ||||
|             } | ||||
|         } else { | ||||
|             for (const id of MapLibreAdaptor.maplibre_control_handlers) { | ||||
|                 map[id].enable() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private setMinzoom(minzoom: number) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         map.setMinZoom(minzoom) | ||||
|     } | ||||
| 
 | ||||
|     private setMaxzoom(maxzoom: number) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         map.setMaxZoom(maxzoom) | ||||
|     } | ||||
| 
 | ||||
|     private setAllowZooming(allow: true | boolean | undefined) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map) { | ||||
|             return | ||||
|         } | ||||
|         if (allow === false) { | ||||
|             for (const id of MapLibreAdaptor.maplibre_zoom_handlers) { | ||||
|                 map[id].disable() | ||||
|             } | ||||
|         } else { | ||||
|             for (const id of MapLibreAdaptor.maplibre_zoom_handlers) { | ||||
|                 map[id].enable() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private setBounds(bounds: BBox) { | ||||
|         const map = this._maplibreMap.data | ||||
|         if (!map || bounds === undefined) { | ||||
|             return | ||||
|         } | ||||
|         const oldBounds = map.getBounds() | ||||
|         const e = 0.0000001 | ||||
|         const hasDiff = | ||||
|             Math.abs(oldBounds.getWest() - bounds.getWest()) > e && | ||||
|             Math.abs(oldBounds.getEast() - bounds.getEast()) > e && | ||||
|             Math.abs(oldBounds.getNorth() - bounds.getNorth()) > e && | ||||
|             Math.abs(oldBounds.getSouth() - bounds.getSouth()) > e | ||||
|         if (!hasDiff) { | ||||
|             return | ||||
|         } | ||||
|         map.fitBounds(bounds.toLngLat()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										50
									
								
								src/UI/Map/MaplibreMap.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/UI/Map/MaplibreMap.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| <script lang="ts"> | ||||
|   /** | ||||
|    * The 'MaplibreMap' maps various event sources onto MapLibre. | ||||
|    * | ||||
|    * As it replaces the old 'MinimapObj' onto MapLibre and the existing codebase, this is sometimes a bit awkward | ||||
|    */ | ||||
|   import { onMount } from "svelte" | ||||
|   import { Map } from "@onsvisual/svelte-maps" | ||||
|   import type { Map as MaplibreMap } from "maplibre-gl" | ||||
|   import type {Readable, Writable} from "svelte/store" | ||||
|   import { AvailableRasterLayers } from "../../Models/RasterLayers" | ||||
|   import {writable} from "svelte/store"; | ||||
| 
 | ||||
|   /** | ||||
|    * Beware: this map will _only_ be set by this component | ||||
|    * It should thus be treated as a 'store' by external parties | ||||
|    */ | ||||
|   export let map: Writable<MaplibreMap> | ||||
| 
 | ||||
|   export let attribution = false | ||||
|   export let center: Readable<{ lng: number ,lat : number }> = writable({lng: 0, lat: 0}) | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     $map.on("load", function () { | ||||
|       $map.resize() | ||||
|     }) | ||||
|   }) | ||||
|   const styleUrl = AvailableRasterLayers.maplibre.properties.url | ||||
| </script> | ||||
| 
 | ||||
| <main> | ||||
|   <Map | ||||
|     bind:center | ||||
|     bind:map={$map} | ||||
|     {attribution} | ||||
|     css="./maplibre-gl.css" | ||||
|     id="map" | ||||
|     location={{ lng: 0, lat: 0, zoom: 0 }} | ||||
|     maxzoom="24" | ||||
|     style={styleUrl} | ||||
|   /> | ||||
| </main> | ||||
| 
 | ||||
| <style> | ||||
|   main { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     position: relative; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										69
									
								
								src/UI/Map/OverlayMap.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/UI/Map/OverlayMap.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| <script lang="ts"> | ||||
|   /** | ||||
|    * The overlay map is a bit a weird map: | ||||
|    * it is a HTML-component which is intended to be placed _over_ another map. | ||||
|    * It will align itself in order to seamlessly show the same location; but possibly in a different style | ||||
|    */ | ||||
|   import MaplibreMap from "./MaplibreMap.svelte" | ||||
|   import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import { Map as MlMap } from "maplibre-gl" | ||||
|   import { MapLibreAdaptor } from "./MapLibreAdaptor" | ||||
|   import type { MapProperties } from "../../Models/MapProperties" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import type { RasterLayerPolygon } from "../../Models/RasterLayers" | ||||
| 
 | ||||
|   export let placedOverMapProperties: MapProperties | ||||
|   export let placedOverMap: UIEventSource<MlMap> | ||||
| 
 | ||||
|   export let rasterLayer: UIEventSource<RasterLayerPolygon> | ||||
| 
 | ||||
|   export let visible: Store<boolean> = undefined | ||||
|   let altmap: UIEventSource<MlMap> = new UIEventSource(undefined) | ||||
|   let altproperties = new MapLibreAdaptor(altmap, { | ||||
|     rasterLayer, | ||||
|     zoom: UIEventSource.feedFrom(placedOverMapProperties.zoom), | ||||
|   }) | ||||
|   altproperties.allowMoving.setData(false) | ||||
|   altproperties.allowZooming.setData(false) | ||||
| 
 | ||||
|   function pixelCenterOf(map: UIEventSource<MlMap>): [number, number] { | ||||
|     const rect = map?.data?.getCanvas()?.getBoundingClientRect() | ||||
|     if (!rect) { | ||||
|       return undefined | ||||
|     } | ||||
|     const x = (rect.left + rect.right) / 2 | ||||
|     const y = (rect.top + rect.bottom) / 2 | ||||
|     return [x, y] | ||||
|   } | ||||
| 
 | ||||
|   function updateLocation() { | ||||
|     if (!placedOverMap.data || !altmap.data) { | ||||
|       return | ||||
|     } | ||||
|     altmap.data.resize() | ||||
|     const { lon, lat } = placedOverMapProperties.location.data | ||||
|     const altMapCenter = pixelCenterOf(altmap) | ||||
|     const c = placedOverMap.data.unproject(altMapCenter) | ||||
|     altproperties.location.setData({ lon: c.lng, lat: c.lat }) | ||||
|   } | ||||
| 
 | ||||
|   onDestroy(placedOverMapProperties.location.addCallbackAndRunD(updateLocation)) | ||||
|   updateLocation() | ||||
|   window.setTimeout(updateLocation, 150) | ||||
|   window.setTimeout(updateLocation, 500) | ||||
| 
 | ||||
|   if (visible) { | ||||
|     onDestroy( | ||||
|       visible?.addCallbackAndRunD((v) => { | ||||
|         if (!v) { | ||||
|           return | ||||
|         } | ||||
|         updateLocation() | ||||
|         window.setTimeout(updateLocation, 150) | ||||
|         window.setTimeout(updateLocation, 500) | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <MaplibreMap map={altmap} /> | ||||
							
								
								
									
										94
									
								
								src/UI/Map/RasterLayerOverview.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/UI/Map/RasterLayerOverview.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,94 @@ | |||
| <script lang="ts"> | ||||
|   /** | ||||
|    * The RasterLayerOverview shows the available 4 categories of maps with a RasterLayerPicker | ||||
|    */ | ||||
|   import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import type { RasterLayerPolygon } from "../../Models/RasterLayers" | ||||
|   import type { MapProperties } from "../../Models/MapProperties" | ||||
|   import { Map as MlMap } from "maplibre-gl" | ||||
|   import RasterLayerPicker from "./RasterLayerPicker.svelte" | ||||
|   import type { EliCategory } from "../../Models/RasterLayerProperties" | ||||
|   import UserRelatedState from "../../Logic/State/UserRelatedState" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
| 
 | ||||
|   export let availableLayers: Store<RasterLayerPolygon[]> | ||||
|   export let mapproperties: MapProperties | ||||
|   export let userstate: UserRelatedState | ||||
|   export let map: Store<MlMap> | ||||
|   /** | ||||
|    * Used to toggle the background layers on/off | ||||
|    */ | ||||
|   export let visible: UIEventSource<boolean> = undefined | ||||
| 
 | ||||
|   type CategoryType = "photo" | "map" | "other" | "osmbasedmap" | ||||
|   const categories: Record<CategoryType, EliCategory[]> = { | ||||
|     photo: ["photo", "historicphoto"], | ||||
|     map: ["map", "historicmap"], | ||||
|     other: ["other", "elevation"], | ||||
|     osmbasedmap: ["osmbasedmap"], | ||||
|   } | ||||
| 
 | ||||
|   function availableForCategory(type: CategoryType): Store<RasterLayerPolygon[]> { | ||||
|     const keywords = categories[type] | ||||
|     return availableLayers.mapD((available) => | ||||
|       available.filter((layer) => keywords.indexOf(<EliCategory>layer.properties.category) >= 0) | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   const mapLayers = availableForCategory("map") | ||||
|   const osmbasedmapLayers = availableForCategory("osmbasedmap") | ||||
|   const photoLayers = availableForCategory("photo") | ||||
|   const otherLayers = availableForCategory("other") | ||||
| 
 | ||||
|   function onApply() { | ||||
|     visible.setData(false) | ||||
|   } | ||||
| 
 | ||||
|   function getPref(type: CategoryType): undefined | UIEventSource<string> { | ||||
|     return userstate?.osmConnection?.GetPreference("preferred-layer-" + type) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex h-full flex-col"> | ||||
|   <slot name="title"> | ||||
|     <h2> | ||||
|       <Tr t={Translations.t.general.backgroundMap} /> | ||||
|     </h2> | ||||
|   </slot> | ||||
| 
 | ||||
|   <div class="grid h-full w-full grid-cols-1 gap-2 md:grid-cols-2"> | ||||
|     <RasterLayerPicker | ||||
|       availableLayers={photoLayers} | ||||
|       favourite={getPref("photo")} | ||||
|       {map} | ||||
|       {mapproperties} | ||||
|       on:appliedLayer={onApply} | ||||
|       {visible} | ||||
|     /> | ||||
|     <RasterLayerPicker | ||||
|       availableLayers={mapLayers} | ||||
|       favourite={getPref("map")} | ||||
|       {map} | ||||
|       {mapproperties} | ||||
|       on:appliedLayer={onApply} | ||||
|       {visible} | ||||
|     /> | ||||
|     <RasterLayerPicker | ||||
|       availableLayers={osmbasedmapLayers} | ||||
|       favourite={getPref("osmbasedmap")} | ||||
|       {map} | ||||
|       {mapproperties} | ||||
|       on:appliedLayer={onApply} | ||||
|       {visible} | ||||
|     /> | ||||
|     <RasterLayerPicker | ||||
|       availableLayers={otherLayers} | ||||
|       favourite={getPref("other")} | ||||
|       {map} | ||||
|       {mapproperties} | ||||
|       on:appliedLayer={onApply} | ||||
|       {visible} | ||||
|     /> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										92
									
								
								src/UI/Map/RasterLayerPicker.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/UI/Map/RasterLayerPicker.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,92 @@ | |||
| <script lang="ts"> | ||||
|   import type { RasterLayerPolygon } from "../../Models/RasterLayers" | ||||
|   import OverlayMap from "./OverlayMap.svelte" | ||||
|   import type { MapProperties } from "../../Models/MapProperties" | ||||
|   import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import { Map as MlMap } from "maplibre-gl" | ||||
|   import { createEventDispatcher, onDestroy } from "svelte" | ||||
| 
 | ||||
|   /*** | ||||
|    * Chooses a background-layer out of available options | ||||
|    */ | ||||
|   export let availableLayers: Store<RasterLayerPolygon[]> | ||||
|   export let mapproperties: MapProperties | ||||
|   export let map: Store<MlMap> | ||||
| 
 | ||||
|   export let visible: Store<boolean> = undefined | ||||
| 
 | ||||
|   let dispatch = createEventDispatcher<{ appliedLayer }>() | ||||
| 
 | ||||
|   export let favourite: UIEventSource<string> | undefined = undefined | ||||
| 
 | ||||
|   let rasterLayer = new UIEventSource<RasterLayerPolygon>(availableLayers.data?.[0]) | ||||
|   let hasLayers = true | ||||
|   onDestroy( | ||||
|     availableLayers.addCallbackAndRun((layers) => { | ||||
|       if (layers === undefined || layers.length === 0) { | ||||
|         hasLayers = false | ||||
|         return | ||||
|       } | ||||
|       hasLayers = true | ||||
|       rasterLayer.setData(layers[0]) | ||||
|     }) | ||||
|   ) | ||||
| 
 | ||||
|   if (favourite) { | ||||
|     onDestroy( | ||||
|       favourite.addCallbackAndRunD((favourite) => { | ||||
|         const fav = availableLayers.data?.find((l) => l.properties.id === favourite) | ||||
|         if (!fav) { | ||||
|           return | ||||
|         } | ||||
|         rasterLayer.setData(fav) | ||||
|       }) | ||||
|     ) | ||||
| 
 | ||||
|     onDestroy( | ||||
|       rasterLayer.addCallbackAndRunD((selected) => { | ||||
|         favourite?.setData(selected.properties.id) | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   let rasterLayerOnMap = UIEventSource.feedFrom(rasterLayer) | ||||
| 
 | ||||
|   if (visible) { | ||||
|     onDestroy( | ||||
|       visible?.addCallbackAndRunD((visible) => { | ||||
|         if (visible) { | ||||
|           rasterLayerOnMap.setData(rasterLayer.data ?? availableLayers.data[0]) | ||||
|         } else { | ||||
|           rasterLayerOnMap.setData(undefined) | ||||
|         } | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| {#if hasLayers} | ||||
|   <div class="flex h-full w-full flex-col"> | ||||
|     <button | ||||
|       on:click={() => { | ||||
|         mapproperties.rasterLayer.setData(rasterLayer.data) | ||||
|         dispatch("appliedLayer") | ||||
|       }} | ||||
|       class="m-0 h-full w-full p-0" | ||||
|     > | ||||
|       <OverlayMap | ||||
|         rasterLayer={rasterLayerOnMap} | ||||
|         placedOverMap={map} | ||||
|         placedOverMapProperties={mapproperties} | ||||
|         {visible} | ||||
|       /> | ||||
|     </button> | ||||
|     <select bind:value={$rasterLayer} class="w-full"> | ||||
|       {#each $availableLayers as availableLayer} | ||||
|         <option value={availableLayer}> | ||||
|           {availableLayer.properties.name} | ||||
|         </option> | ||||
|       {/each} | ||||
|     </select> | ||||
|   </div> | ||||
| {/if} | ||||
							
								
								
									
										522
									
								
								src/UI/Map/ShowDataLayer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										522
									
								
								src/UI/Map/ShowDataLayer.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,522 @@ | |||
| import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
| import type { Map as MlMap } from "maplibre-gl" | ||||
| import { GeoJSONSource, Marker } from "maplibre-gl" | ||||
| import { ShowDataLayerOptions } from "./ShowDataLayerOptions" | ||||
| import { GeoOperations } from "../../Logic/GeoOperations" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig" | ||||
| import { OsmTags } from "../../Models/OsmFeature" | ||||
| import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource" | ||||
| import { BBox } from "../../Logic/BBox" | ||||
| import { Feature, Point } from "geojson" | ||||
| import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig" | ||||
| import { Utils } from "../../Utils" | ||||
| import * as range_layer from "../../assets/layers/range/range.json" | ||||
| import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" | ||||
| import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" | ||||
| import FilteredLayer from "../../Models/FilteredLayer" | ||||
| import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource" | ||||
| 
 | ||||
| class PointRenderingLayer { | ||||
|     private readonly _config: PointRenderingConfig | ||||
|     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 _dirty = false | ||||
| 
 | ||||
|     constructor( | ||||
|         map: MlMap, | ||||
|         features: FeatureSource, | ||||
|         config: PointRenderingConfig, | ||||
|         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._fetchStore = fetchStore | ||||
|         this._onClick = onClick | ||||
|         this._selectedElement = selectedElement | ||||
|         const self = this | ||||
| 
 | ||||
|         features.features.addCallbackAndRunD((features) => self.updateFeatures(features)) | ||||
|         visibility?.addCallbackAndRunD((visible) => { | ||||
|             if (visible === true && self._dirty) { | ||||
|                 self.updateFeatures(features.features.data) | ||||
|             } | ||||
|             self.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) | ||||
| 
 | ||||
|                 const loc = GeoOperations.featureToCoordinateWithRenderingType( | ||||
|                     <any>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, true) | ||||
|         html.SetClass("marker") | ||||
|         if (this._onClick !== undefined) { | ||||
|             html.SetClass("cursor-pointer") | ||||
|         } | ||||
|         const el = html.ConstructElement() | ||||
| 
 | ||||
|         if (this._onClick) { | ||||
|             const self = this | ||||
|             el.addEventListener("click", function (ev) { | ||||
|                 ev.preventDefault() | ||||
|                 self._onClick(feature) | ||||
|                 console.log("Got click:", feature) | ||||
|                 // Workaround to signal the MapLibreAdaptor to ignore this click
 | ||||
|                 ev["consumed"] = true | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         const marker = new Marker(el).setLngLat(loc).setOffset(iconAnchor).addTo(this._map) | ||||
|         store | ||||
|             .map((tags) => this._config.pitchAlignment.GetRenderValue(tags).Subs(tags).txt) | ||||
|             .addCallbackAndRun((pitchAligment) => marker.setPitchAlignment(pitchAligment)) | ||||
|         store | ||||
|             .map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt) | ||||
|             .addCallbackAndRun((pitchAligment) => marker.setRotationAlignment(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 { | ||||
|     /** | ||||
|      * 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 self = this | ||||
|         features.features.addCallbackAndRunD(() => self.update(features.features)) | ||||
|     } | ||||
| 
 | ||||
|     public destruct(): void { | ||||
|         this._map.removeLayer(this._layername + "_polygon") | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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) { | ||||
|             let 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 update(featureSource: Store<Feature[]>) { | ||||
|         const map = this._map | ||||
|         while (!map.isStyleLoaded()) { | ||||
|             await Utils.waitFor(100) | ||||
|         } | ||||
| 
 | ||||
|         // After waiting 'till the map has loaded, the data might have changed already
 | ||||
|         // As such, we only now read the features from the featureSource and compare with the previously set data
 | ||||
|         const features = featureSource.data | ||||
|         const src = <GeoJSONSource>map.getSource(this._layername) | ||||
|         if (this.currentSourceData === features) { | ||||
|             // Already up to date
 | ||||
|             return | ||||
|         } | ||||
|         if (src === undefined) { | ||||
|             this.currentSourceData = features | ||||
|             map.addSource(this._layername, { | ||||
|                 type: "geojson", | ||||
|                 data: { | ||||
|                     type: "FeatureCollection", | ||||
|                     features, | ||||
|                 }, | ||||
|                 promoteId: "id", | ||||
|             }) | ||||
|             // @ts-ignore
 | ||||
|             const linelayer = this._layername + "_line" | ||||
|             map.addLayer({ | ||||
|                 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", | ||||
|                 }, | ||||
|             }) | ||||
| 
 | ||||
|             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) => { | ||||
|                     console.log("Got polylayer click:", e) | ||||
|                     // polygon-layer-listener
 | ||||
|                     if (e.originalEvent["consumed"]) { | ||||
|                         // This is a polygon beneath a marker, we can ignore it
 | ||||
|                         return | ||||
|                     } | ||||
|                     e.originalEvent["consumed"] = true | ||||
|                     console.log("Got features:", e.features, e) | ||||
|                     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 visiblity of layers ", | ||||
|                         linelayer, | ||||
|                         polylayer, | ||||
|                         e | ||||
|                     ) | ||||
|                 } | ||||
|             }) | ||||
|         } else { | ||||
|             this.currentSourceData = features | ||||
|             src.setData({ | ||||
|                 type: "FeatureCollection", | ||||
|                 features: this.currentSourceData, | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < features.length; i++) { | ||||
|             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 (!map.getSource(this._layername)) { | ||||
|                 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) => { | ||||
|                     map.setFeatureState( | ||||
|                         { source: this._layername, id }, | ||||
|                         this.calculatePropsFor(properties) | ||||
|                     ) | ||||
|                 }) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default class ShowDataLayer { | ||||
|     private static rangeLayer = new LayerConfig( | ||||
|         <LayerConfigJson>range_layer, | ||||
|         "ShowDataLayer.ts:range.json" | ||||
|     ) | ||||
|     private readonly _map: Store<MlMap> | ||||
|     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._map = map | ||||
|         this._options = options | ||||
|         const self = this | ||||
|         this.onDestroy.push(map.addCallbackAndRunD((map) => self.initDrawFeatures(map))) | ||||
|     } | ||||
| 
 | ||||
|     public static showMultipleLayers( | ||||
|         mlmap: UIEventSource<MlMap>, | ||||
|         features: FeatureSource, | ||||
|         layers: LayerConfig[], | ||||
|         options?: Partial<ShowDataLayerOptions> | ||||
|     ) { | ||||
|         const perLayer: PerLayerFeatureSourceSplitter<FeatureSourceForLayer> = | ||||
|             new PerLayerFeatureSourceSplitter( | ||||
|                 layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)), | ||||
|                 features, | ||||
|                 { | ||||
|                     constructStore: (features, layer) => new SimpleFeatureSource(layer, features), | ||||
|                 } | ||||
|             ) | ||||
|         perLayer.forEach((fs) => { | ||||
|             new ShowDataLayer(mlmap, { | ||||
|                 layer: fs.layer.layerDef, | ||||
|                 features: fs, | ||||
|                 ...(options ?? {}), | ||||
|             }) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     public static showRange( | ||||
|         map: Store<MlMap>, | ||||
|         features: FeatureSource, | ||||
|         doShowLayer?: Store<boolean> | ||||
|     ): ShowDataLayer { | ||||
|         return new ShowDataLayer(map, { | ||||
|             layer: ShowDataLayer.rangeLayer, | ||||
|             features, | ||||
|             doShowLayer, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     public destruct() {} | ||||
| 
 | ||||
|     private zoomToCurrentFeatures(map: MlMap) { | ||||
|         if (this._options.zoomToFeatures) { | ||||
|             const features = this._options.features.features.data | ||||
|             const bbox = BBox.bboxAroundAll(features.map(BBox.get)) | ||||
|             map.resize() | ||||
|             map.fitBounds(bbox.toLngLat(), { | ||||
|                 padding: { top: 10, bottom: 10, left: 10, right: 10 }, | ||||
|                 animate: false, | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private initDrawFeatures(map: MlMap) { | ||||
|         let { features, doShowLayer, fetchStore, selectedElement, selectedLayer } = this._options | ||||
|         const onClick = | ||||
|             this._options.onClick ?? | ||||
|             (this._options.layer.title === undefined | ||||
|                 ? undefined | ||||
|                 : (feature: Feature) => { | ||||
|                       selectedElement?.setData(feature) | ||||
|                       selectedLayer?.setData(this._options.layer) | ||||
|                   }) | ||||
|         if (this._options.drawLines !== false) { | ||||
|             for (let i = 0; i < this._options.layer.lineRendering.length; i++) { | ||||
|                 const lineRenderingConfig = this._options.layer.lineRendering[i] | ||||
|                 const l = new LineRenderingLayer( | ||||
|                     map, | ||||
|                     features, | ||||
|                     this._options.layer.id + "_linerendering_" + i, | ||||
|                     lineRenderingConfig, | ||||
|                     doShowLayer, | ||||
|                     fetchStore, | ||||
|                     onClick | ||||
|                 ) | ||||
|                 this.onDestroy.push(l.destruct) | ||||
|             } | ||||
|         } | ||||
|         if (this._options.drawMarkers !== false) { | ||||
|             for (const pointRenderingConfig of this._options.layer.mapRendering) { | ||||
|                 new PointRenderingLayer( | ||||
|                     map, | ||||
|                     features, | ||||
|                     pointRenderingConfig, | ||||
|                     doShowLayer, | ||||
|                     fetchStore, | ||||
|                     onClick, | ||||
|                     selectedElement | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|         features.features.addCallbackAndRunD((_) => this.zoomToCurrentFeatures(map)) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										38
									
								
								src/UI/Map/ShowDataLayerOptions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/UI/Map/ShowDataLayerOptions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" | ||||
| import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| import { Feature } from "geojson" | ||||
| 
 | ||||
| export interface ShowDataLayerOptions { | ||||
|     /** | ||||
|      * Features to show | ||||
|      */ | ||||
|     features: FeatureSource | ||||
|     /** | ||||
|      * Indication of the current selected element; overrides some filters. | ||||
|      * When a feature is tapped, the feature will be put in there | ||||
|      */ | ||||
|     selectedElement?: UIEventSource<Feature> | ||||
| 
 | ||||
|     /** | ||||
|      * When a feature of this layer is tapped, the layer will be marked | ||||
|      */ | ||||
|     selectedLayer?: UIEventSource<LayerConfig> | ||||
| 
 | ||||
|     /** | ||||
|      * If set, zoom to the features when initially loaded and when they are changed | ||||
|      */ | ||||
|     zoomToFeatures?: false | boolean | ||||
|     /** | ||||
|      * Toggles the layer on/off | ||||
|      */ | ||||
|     doShowLayer?: Store<true | boolean> | ||||
| 
 | ||||
|     /** | ||||
|      * Function which fetches the relevant store. | ||||
|      * If given, the map will update when a property is changed | ||||
|      */ | ||||
|     fetchStore?: (id: string) => Store<Record<string, string>> | ||||
| 
 | ||||
|     onClick?: (feature: Feature) => void | ||||
| } | ||||
							
								
								
									
										92
									
								
								src/UI/Map/ShowOverlayRasterLayer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/UI/Map/ShowOverlayRasterLayer.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,92 @@ | |||
| import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
| import { Map as MlMap } from "maplibre-gl" | ||||
| import { Utils } from "../../Utils" | ||||
| import { MapLibreAdaptor } from "./MapLibreAdaptor" | ||||
| import { RasterLayerProperties } from "../../Models/RasterLayerProperties" | ||||
| 
 | ||||
| export default class ShowOverlayRasterLayer { | ||||
|     private readonly _map: UIEventSource<MlMap> | ||||
|     private readonly _layer: RasterLayerProperties | ||||
|     private readonly _mapProperties?: { zoom: Store<number> } | ||||
|     private _mllayer | ||||
|     private readonly _isDisplayed?: Store<boolean> | ||||
| 
 | ||||
|     constructor( | ||||
|         layer: RasterLayerProperties, | ||||
|         map: UIEventSource<MlMap>, | ||||
|         mapProperties?: { zoom: Store<number> }, | ||||
|         options?: { | ||||
|             isDisplayed?: Store<boolean> | ||||
|         } | ||||
|     ) { | ||||
|         this._mapProperties = mapProperties | ||||
|         this._layer = layer | ||||
|         this._map = map | ||||
|         this._isDisplayed = options?.isDisplayed | ||||
|         const self = this | ||||
|         map.addCallbackAndRunD((map) => { | ||||
|             self.addLayer() | ||||
|             map.on("load", () => { | ||||
|                 self.addLayer() | ||||
|             }) | ||||
|         }) | ||||
|         this.addLayer() | ||||
| 
 | ||||
|         options?.isDisplayed?.addCallbackAndRun(() => { | ||||
|             self.setVisibility() | ||||
|         }) | ||||
| 
 | ||||
|         mapProperties?.zoom?.addCallbackAndRun(() => { | ||||
|             self.setVisibility() | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private setVisibility() { | ||||
|         let zoom = this._mapProperties?.zoom?.data | ||||
|         let withinRange = zoom === undefined || zoom > this._layer.min_zoom | ||||
|         let isDisplayed = (this._isDisplayed?.data ?? true) && withinRange | ||||
|         this._map.data?.setLayoutProperty( | ||||
|             this._layer.id, | ||||
|             "visibility", | ||||
|             isDisplayed ? "visible" : "none" | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     private async awaitStyleIsLoaded(): Promise<void> { | ||||
|         const map = this._map.data | ||||
|         if (map === undefined) { | ||||
|             return | ||||
|         } | ||||
|         while (!map?.isStyleLoaded()) { | ||||
|             await Utils.waitFor(250) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async addLayer() { | ||||
|         const map = this._map.data | ||||
|         console.log("Attempting to add ", this._layer.id) | ||||
|         if (map === undefined) { | ||||
|             return | ||||
|         } | ||||
|         await this.awaitStyleIsLoaded() | ||||
|         if (this._mllayer) { | ||||
|             // Already initialized
 | ||||
|             return | ||||
|         } | ||||
|         const background: RasterLayerProperties = this._layer | ||||
| 
 | ||||
|         map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background)) | ||||
|         this._mllayer = map.addLayer({ | ||||
|             id: background.id, | ||||
|             type: "raster", | ||||
|             source: background.id, | ||||
|             paint: {}, | ||||
|         }) | ||||
|         map.setLayoutProperty( | ||||
|             this._layer.id, | ||||
|             "visibility", | ||||
|             this._isDisplayed?.data ?? true ? "visible" : "none" | ||||
|         ) | ||||
|         this.setVisibility() | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue