forked from MapComplete/MapComplete
Merge branches
This commit is contained in:
commit
7eeac66471
554 changed files with 8193 additions and 7079 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())
|
||||
}
|
||||
}
|
80
src/UI/Map/MaplibreMap.svelte
Normal file
80
src/UI/Map/MaplibreMap.svelte
Normal file
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import * as maplibre from "maplibre-gl"
|
||||
import type {Map} from "maplibre-gl"
|
||||
import type {Readable, Writable} from "svelte/store"
|
||||
import {get, writable} from "svelte/store"
|
||||
import {AvailableRasterLayers} from "../../Models/RasterLayers"
|
||||
import {Utils} from "../../Utils";
|
||||
|
||||
/**
|
||||
* The 'MaplibreMap' maps various event sources onto MapLibre.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* 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<Map>
|
||||
|
||||
let container: HTMLElement
|
||||
|
||||
|
||||
export let attribution = false
|
||||
export let center: {lng: number, lat: number} | Readable<{ lng: number; lat: number }> = writable({lng: 0, lat: 0})
|
||||
console.trace("Center is", center)
|
||||
export let zoom: Readable<number> = writable(1)
|
||||
|
||||
const styleUrl = AvailableRasterLayers.maplibre.properties.url
|
||||
|
||||
let _map: Map
|
||||
onMount(() => {
|
||||
|
||||
let _center: {lng: number, lat: number}
|
||||
if(typeof center["lng"] === "number" && typeof center["lat"] === "number"){
|
||||
_center = <any> center
|
||||
}else{
|
||||
_center = get(<any> center)
|
||||
}
|
||||
|
||||
|
||||
_map = new maplibre.Map({
|
||||
container,
|
||||
style: styleUrl,
|
||||
zoom: get(zoom),
|
||||
center: _center,
|
||||
maxZoom: 24,
|
||||
interactive: true,
|
||||
attributionControl: false,
|
||||
|
||||
});
|
||||
|
||||
_map.on("load", function () {
|
||||
_map.resize()
|
||||
})
|
||||
map.set(_map)
|
||||
|
||||
})
|
||||
onDestroy(async () => {
|
||||
await Utils.waitFor(250);
|
||||
if (_map) _map.remove();
|
||||
map = null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link
|
||||
href="./maplibre-gl.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div bind:this={container} class="map" id="map" style=" position: relative;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;"></div>
|
||||
|
||||
|
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}
|
523
src/UI/Map/ShowDataLayer.ts
Normal file
523
src/UI/Map/ShowDataLayer.ts
Normal file
|
@ -0,0 +1,523 @@
|
|||
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({ element: el}).setLngLat(loc).setOffset(iconAnchor).addTo(this._map)
|
||||
store
|
||||
.map((tags) => this._config.pitchAlignment.GetRenderValue(tags).Subs(tags).txt)
|
||||
.addCallbackAndRun((pitchAligment) => marker.setPitchAlignment(<any> pitchAligment))
|
||||
store
|
||||
.map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt)
|
||||
.addCallbackAndRun((pitchAligment) => marker.setRotationAlignment(<any> 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