Feature: smooth switching of raster layers, fix #1765

This commit is contained in:
Pieter Vander Vennet 2024-01-28 00:04:24 +01:00
parent 8612d33724
commit 129f5c3d1f
2 changed files with 222 additions and 164 deletions

View file

@ -9,6 +9,7 @@ import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import * as htmltoimage from "html-to-image"
import RasterLayerHandler from "./RasterLayerHandler"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -41,7 +42,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
readonly minzoom: UIEventSource<number>
readonly maxzoom: UIEventSource<number>
readonly rotation: UIEventSource<number>
readonly animationRunning = new UIEventSource(false)
/**
* Functions that are called when one of those actions has happened
@ -50,11 +50,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private _onKeyNavigation: ((event: KeyNavigationEvent) => void | boolean)[] = []
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
@ -90,6 +85,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.lastClickLocation = lastClickLocation
const self = this
const rasterLayerHandler = new RasterLayerHandler(this._maplibreMap, this.rasterLayer)
function handleClick(e) {
if (e.originalEvent["consumed"]) {
// Workaround, 'ShowPointLayer' sets this flag
@ -111,7 +108,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMinzoom(self.minzoom.data)
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
rasterLayerHandler.setBackground()
this.updateStores(true)
})
self.MoveMapToCurrentLoc(self.location.data)
@ -124,7 +121,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.SetRotation(self.rotation.data)
self.setBackground()
rasterLayerHandler.setBackground()
this.updateStores(true)
map.on("moveend", () => this.updateStores())
map.on("click", (e) => {
@ -170,11 +167,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
})
})
this.rasterLayer.addCallbackAndRun((_) =>
self.setBackground().catch((_) => {
console.error("Could not set background")
})
)
this.rasterLayer.addCallbackAndRun((_) => rasterLayerHandler.setBackground())
this.location.addCallbackAndRunD((loc) => {
self.MoveMapToCurrentLoc(loc)
})
@ -211,48 +204,12 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
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,
// Bit of a hack, but seems to work
scheme: layer.url.includes("{-y}") ? "tms" : "xyz",
}
return RasterLayerHandler.prepareWmsSource(layer)
}
/**
* 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}",
"{-y}": "{y}",
}
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
}
private static async toBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob)))
@ -502,15 +459,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
}
public installCustomKeyboardHandler(viewportStore: UIEventSource<HTMLDivElement>) {
viewportStore.mapD(
(viewport) => {
@ -534,111 +482,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
[this._maplibreMap]
)
}
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(retryAttempts: number = 3): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
return
}
const background: RasterLayerProperties = this.rasterLayer?.data?.properties
if (!background) {
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") {
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.maptilerDefaultLayer.properties
try {
await this.awaitStyleIsLoaded()
if (!map.getSource(maptiler.id)) {
this.removeCurrentLayer(map)
map.addSource(maptiler.id, MapLibreAdaptor.prepareWmsSource(maptiler))
map.setStyle(maptiler.url)
await this.awaitStyleIsLoaded()
}
}catch (e) {
if(retryAttempts > 0){
window.requestAnimationFrame(() => {
console.log("Retrying to set the background ("+retryAttempts+" attempts remaining)... Failed because",e)
this.setBackground(retryAttempts-1)
})
}
}
}
if (!map.getLayer(addLayerBeforeId)) {
addLayerBeforeId = undefined
}
await this.awaitStyleIsLoaded()
if (!map.getSource(background.id)) {
map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background))
}
if (!map.getLayer(background.id)) {
addLayerBeforeId ??= map
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
console.log(
"Adding background layer",
background.id,
"beforeId",
addLayerBeforeId,
"; all layers are",
map.getStyle().layers.map((l) => l.id)
)
map.addLayer(
{
id: background.id,
type: "raster",
source: background.id,
paint: {},
},
addLayerBeforeId
)
}
await this.awaitStyleIsLoaded()
if (this._currentRasterLayer !== background?.id) {
this.removeCurrentLayer(map)
}
this._currentRasterLayer = background?.id
}
private setMaxBounds(bbox: undefined | BBox) {
const map = this._maplibreMap.data

View file

@ -0,0 +1,215 @@
import { Map as MLMap, SourceSpecification } from "maplibre-gl"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import { Utils } from "../../Utils"
class SingleBackgroundHandler {
// Value between 0 and 1.0
public opacity = new UIEventSource<number>(0.0)
private _map: Store<MLMap>
private _background: UIEventSource<RasterLayerPolygon | undefined>
private readonly _targetLayer: RasterLayerPolygon
private _deactivationTime: Date = undefined
/**
* Deactivate a layer after 60 seconds
*/
public static readonly DEACTIVATE_AFTER = 60
private fadeStep = 0.1
constructor(
map: Store<MLMap>,
targetLayer: RasterLayerPolygon,
background: UIEventSource<RasterLayerPolygon | undefined>
) {
this._targetLayer = targetLayer
this._map = map
this._background = background
background.addCallback(async () => {
await this.update()
})
map.addCallbackAndRunD(async (map) => {
map.on("load", async () => {
await this.update()
})
await this.update()
map.on("moveend", () => this.onMove(map))
map.on("zoomend", () => this.onMove(map))
})
}
private onMove(map: MLMap) {
if (!this._deactivationTime) {
return
}
// in seconds
const timeSinceDeactivation =
(new Date().getTime() - this._deactivationTime.getTime()) / 1000
if (timeSinceDeactivation < SingleBackgroundHandler.DEACTIVATE_AFTER) {
return
}
console.debug(
"Removing raster layer",
this._targetLayer.properties.id,
"map moved and not been used for",
SingleBackgroundHandler.DEACTIVATE_AFTER
)
if (map.getLayer(<string>this._targetLayer.properties.id)) {
map.removeLayer(<string>this._targetLayer.properties.id)
}
}
private async update() {
const newTarget: RasterLayerPolygon | undefined = this._background.data
const targetLayer = this._targetLayer
if (newTarget?.properties?.id !== targetLayer.properties.id) {
this._deactivationTime = new Date()
await this.awaitStyleIsLoaded()
this.fadeOut()
} else {
this._deactivationTime = undefined
this.enable()
this.fadeIn()
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._map.data
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
}
private enable() {
const map: MLMap = this._map.data
if (!map) {
return
}
const background = this._targetLayer.properties
console.debug("Enabling", background.id)
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
}
if (!map.getSource(background.id)) {
map.addSource(background.id, RasterLayerHandler.prepareWmsSource(background))
}
if (!map.getLayer(background.id)) {
addLayerBeforeId ??= map
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
map.addLayer(
{
id: background.id,
type: "raster",
source: background.id,
paint: {
"raster-opacity": 0,
},
},
addLayerBeforeId
)
this.opacity.addCallbackAndRun((o) => {
map.setPaintProperty(background.id, "raster-opacity", o)
})
}
}
private fadeOut() {
Stores.Chronic(
8,
() => this.opacity.data > 0 && this._deactivationTime !== undefined
).addCallback((_) => this.opacity.setData(Math.max(0, this.opacity.data - this.fadeStep)))
}
private fadeIn() {
Stores.Chronic(
8,
() => this.opacity.data < 1.0 && this._deactivationTime === undefined
).addCallback((_) => this.opacity.setData(Math.min(1.0, this.opacity.data + this.fadeStep)))
}
}
export default class RasterLayerHandler {
private _map: Store<MLMap>
private _background: UIEventSource<RasterLayerPolygon | undefined>
private _singleLayerHandlers: Record<string, SingleBackgroundHandler> = {}
constructor(map: Store<MLMap>, background: UIEventSource<RasterLayerPolygon | undefined>) {
this._map = map
this._background = background
background.addCallbackAndRunD((l) => {
const key = l.properties.id
if (!this._singleLayerHandlers[key]) {
this._singleLayerHandlers[key] = new SingleBackgroundHandler(map, l, background)
}
})
map.addCallback((map) => {
map.on("load", () => this.setBackground())
this.setBackground()
})
}
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: [RasterLayerHandler.prepareWmsURL(layer.url, layer["tile-size"] ?? 256)],
tileSize: layer["tile-size"] ?? 256,
minzoom: layer["min_zoom"] ?? 1,
maxzoom: layer["max_zoom"] ?? 25,
// Bit of a hack, but seems to work
scheme: layer.url.includes("{-y}") ? "tms" : "xyz",
}
}
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}",
"{-y}": "{y}",
}
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
}
/**
* Performs all necessary updates
*/
public setBackground() {
this.update().catch((e) => console.error(e))
}
private async update() {
const map = this._map.data
if (!map) {
return
}
}
}