MapComplete/src/UI/Map/MapLibreAdaptor.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

620 lines
22 KiB
TypeScript
Raw Normal View History

2023-10-16 14:27:05 +02:00
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"
import { draw } from "svelte/transition"
2023-03-24 19:21:15 +01:00
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
*/
export class MapLibreAdaptor implements MapProperties, ExportableMap {
2023-03-24 19:21:15 +01:00
private static maplibre_control_handlers = [
2023-03-25 02:48:24 +01:00
// "scrollZoom",
// "boxZoom",
// "doubleClickZoom",
2023-03-24 19:21:15 +01:00
"dragRotate",
"dragPan",
"keyboard",
"touchZoomRotate",
]
2023-03-28 05:13:48 +02:00
private static maplibre_zoom_handlers = [
"scrollZoom",
"boxZoom",
"doubleClickZoom",
"touchZoomRotate",
]
readonly location: UIEventSource<{ lon: number; lat: number }>
readonly zoom: UIEventSource<number>
2023-03-28 05:13:48 +02:00
readonly bounds: UIEventSource<BBox>
readonly rasterLayer: UIEventSource<RasterLayerPolygon | undefined>
2023-03-24 19:21:15 +01:00
readonly maxbounds: UIEventSource<BBox | undefined>
readonly allowMoving: UIEventSource<true | boolean | undefined>
2023-07-18 01:26:04 +02:00
readonly allowRotating: UIEventSource<true | boolean | undefined>
2023-03-28 05:13:48 +02:00
readonly allowZooming: UIEventSource<true | boolean | undefined>
readonly lastClickLocation: Store<undefined | { lon: number; lat: number }>
readonly minzoom: UIEventSource<number>
2023-04-21 01:53:24 +02:00
readonly maxzoom: UIEventSource<number>
private readonly _maplibreMap: Store<MLMap>
/**
* Used for internal bookkeeping (to remove a rasterLayer when done loading)
* @private
*/
private _currentRasterLayer: string
2023-03-24 19:21:15 +01:00
2023-03-29 17:21:20 +02:00
constructor(maplibreMap: Store<MLMap>, state?: Partial<MapProperties>) {
this._maplibreMap = maplibreMap
this.location = state?.location ?? new UIEventSource(undefined)
if (this.location.data) {
2023-05-19 10:56:30 +02:00
// 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 })
2023-05-19 10:56:30 +02:00
}
this.zoom = state?.zoom ?? new UIEventSource(1)
this.minzoom = state?.minzoom ?? new UIEventSource(0)
2023-04-21 01:53:24 +02:00
this.maxzoom = state?.maxzoom ?? new UIEventSource(24)
2023-03-24 19:21:15 +01:00
this.zoom.addCallbackAndRunD((z) => {
if (z < this.minzoom.data) {
this.zoom.setData(this.minzoom.data)
2023-03-24 19:21:15 +01:00
}
2023-04-21 01:53:24 +02:00
const max = Math.min(24, this.maxzoom.data ?? 24)
if (z > max) {
this.zoom.setData(max)
2023-03-24 19:21:15 +01:00
}
})
this.maxbounds = state?.maxbounds ?? new UIEventSource(undefined)
this.allowMoving = state?.allowMoving ?? new UIEventSource(true)
2023-07-18 01:26:04 +02:00
this.allowRotating = state?.allowRotating ?? new UIEventSource<boolean>(true)
2023-03-28 05:13:48 +02:00
this.allowZooming = state?.allowZooming ?? new UIEventSource(true)
this.bounds = state?.bounds ?? new UIEventSource(undefined)
this.rasterLayer =
state?.rasterLayer ?? new UIEventSource<RasterLayerPolygon | undefined>(undefined)
2023-03-28 05:13:48 +02:00
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", () => {
map.resize()
2023-03-24 19:21:15 +01:00
self.MoveMapToCurrentLoc(self.location.data)
self.SetZoom(self.zoom.data)
self.setMaxBounds(self.maxbounds.data)
self.setAllowMoving(self.allowMoving.data)
2023-07-18 01:26:04 +02:00
self.setAllowRotating(self.allowRotating.data)
2023-03-28 05:13:48 +02:00
self.setAllowZooming(self.allowZooming.data)
self.setMinzoom(self.minzoom.data)
2023-04-21 01:53:24 +02:00
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
this.updateStores(true)
})
map.resize()
2023-03-24 19:21:15 +01:00
self.MoveMapToCurrentLoc(self.location.data)
self.SetZoom(self.zoom.data)
self.setMaxBounds(self.maxbounds.data)
self.setAllowMoving(self.allowMoving.data)
2023-07-18 01:26:04 +02:00
self.setAllowRotating(self.allowRotating.data)
2023-03-28 05:13:48 +02:00
self.setAllowZooming(self.allowZooming.data)
self.setMinzoom(self.minzoom.data)
2023-04-21 01:53:24 +02:00
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
this.updateStores(true)
map.on("moveend", () => this.updateStores())
2023-03-28 05:13:48 +02:00
map.on("click", (e) => {
handleClick(e)
})
map.on("contextmenu", (e) => {
handleClick(e)
})
map.on("dblclick", (e) => {
handleClick(e)
2023-03-28 05:13:48 +02:00
})
})
this.rasterLayer.addCallbackAndRun((_) =>
2023-03-24 19:21:15 +01:00
self.setBackground().catch((_) => {
console.error("Could not set background")
})
)
this.location.addCallbackAndRunD((loc) => {
self.MoveMapToCurrentLoc(loc)
})
this.zoom.addCallbackAndRunD((z) => self.SetZoom(z))
2023-03-24 19:21:15 +01:00
this.maxbounds.addCallbackAndRun((bbox) => self.setMaxBounds(bbox))
this.allowMoving.addCallbackAndRun((allowMoving) => self.setAllowMoving(allowMoving))
2023-07-18 01:26:04 +02:00
this.allowRotating.addCallbackAndRunD((allowRotating) =>
self.setAllowRotating(allowRotating)
)
2023-03-28 05:13:48 +02:00
this.allowZooming.addCallbackAndRun((allowZooming) => self.setAllowZooming(allowZooming))
this.bounds.addCallbackAndRunD((bounds) => self.setBounds(bounds))
}
2023-03-24 19:21:15 +01:00
/**
* 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),
}
}
2023-04-21 01:53:24 +02:00
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,
2023-09-02 23:23:46 +02:00
// Bit of a hack, but seems to work
scheme: layer.url.includes("{-y}") ? "tms" : "xyz",
2023-04-21 01:53:24 +02:00
}
}
/**
* Prepares an ELI-URL to be compatible with mapbox
*/
2023-04-21 01:53:24 +02:00
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}",
2023-09-02 23:23:46 +02:00
"{-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)))
}
private static async createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.decode = () => resolve(img) as any
img.onload = () => resolve(img)
img.onerror = reject
img.crossOrigin = "anonymous"
img.decoding = "async"
img.src = url
})
}
public async exportAsPng(
rescaleIcons: number = 1,
progress: UIEventSource<{ current: number; total: number }> = undefined
): Promise<Blob> {
const map = this._maplibreMap.data
if (!map) {
return undefined
}
const drawOn = document.createElement("canvas", {})
const ctx = drawOn.getContext("2d")
// The width/height has been set in 'mm' on the parent element and converted to pixels by the browser
const w = map.getContainer().getBoundingClientRect().width
const h = map.getContainer().getBoundingClientRect().height
let dpi = map.getPixelRatio()
console.log("Sizes:", {
dpi,
w,
h,
origSizeW: drawOn.style.width,
origSizeH: drawOn.style.height,
})
// The 'css'-size stays constant...
drawOn.style.width = w + "px"
drawOn.style.height = h + "px"
// ...but the number of pixels is increased
drawOn.width = Math.ceil(w * dpi)
drawOn.height = Math.ceil(h * dpi)
2023-11-13 15:14:41 +01:00
await this.exportBackgroundOnCanvas(ctx)
await this.drawMarkers(ctx, rescaleIcons, progress)
return await MapLibreAdaptor.toBlob(drawOn)
}
/**
* Exports the background map and lines to PNG.
* Markers are _not_ rendered
*/
2023-06-04 22:52:13 +02:00
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()
}
/**
* Draws the markers of the current map on the specified canvas.
* The DPIfactor is used to calculate the correct position, whereas 'rescaleIcons' can be used to make the icons smaller
* @param drawOn
* @param rescaleIcons
* @private
*/
private async drawMarkers(
drawOn: CanvasRenderingContext2D,
rescaleIcons: number = 1,
progress: UIEventSource<{ current: number; total: number }>
): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
console.error("There is no map to export from")
return undefined
}
2023-11-13 15:14:41 +01:00
const container = map.getContainer()
2023-11-13 15:14:41 +01:00
function isDisplayed(el: Element) {
const r1 = el.getBoundingClientRect()
const r2 = container.getBoundingClientRect()
return !(
r2.left > r1.right ||
r2.right < r1.left ||
r2.top > r1.bottom ||
r2.bottom < r1.top
)
}
2023-11-13 15:14:41 +01:00
const markers = Array.from(container.getElementsByClassName("marker"))
for (let i = 0; i < markers.length; i++) {
const marker = <HTMLElement>markers[i]
2023-11-13 15:14:41 +01:00
if (!isDisplayed(marker)) {
continue
}
const pixelRatio = map.getPixelRatio()
let x = marker.getBoundingClientRect().x
let y = marker.getBoundingClientRect().y
const style = marker.style.transform
marker.style.transform = ""
const offset = style.match(/translate\(([-0-9]+)%, ?([-0-9]+)%\)/)
console.log("MARKER", marker)
const w = marker.style.width
// Force a wider view for icon badges
marker.style.width = marker.getBoundingClientRect().width * 4 + "px"
const svgSource = await htmltoimage.toSvg(marker)
const img = await MapLibreAdaptor.createImage(svgSource)
marker.style.width = w
if (offset && rescaleIcons !== 1) {
const [_, relXStr, relYStr] = offset
const relX = Number(relXStr)
const relY = Number(relYStr)
console.log("Moving icon with", relX, relY, img.width, img.height, x, y)
// x += img.width * (relX / 100)
y += img.height * (relY / 100)
}
x *= pixelRatio
y *= pixelRatio
if (progress) {
progress.setData({ current: i, total: markers.length })
}
2023-11-13 15:14:41 +01:00
try {
drawOn.drawImage(img, x, y, img.width * rescaleIcons, img.height * rescaleIcons)
2023-11-13 15:14:41 +01:00
} catch (e) {
console.log("Could not draw image because of", e)
}
marker.style.transform = style
2023-11-13 15:14:41 +01:00
}
}
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)
}
}
2023-05-18 15:44:54 +02:00
private SetZoom(z: number): void {
2023-03-24 19:21:15 +01:00
const map = this._maplibreMap.data
if (!map || z === undefined) {
return
}
if (Math.abs(map.getZoom() - z) > 0.01) {
map.setZoom(z)
}
}
2023-05-18 15:44:54 +02:00
private MoveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
2023-03-24 19:21:15 +01:00
const map = this._maplibreMap.data
if (!map || loc === undefined) {
return
}
const center = map.getCenter()
if (center.lng !== loc.lon || center.lat !== loc.lat) {
2023-07-08 01:21:11 +02:00
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 })
}
2023-03-24 19:21:15 +01:00
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
}
2023-05-18 15:44:54 +02:00
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")
}
}
}
2023-05-18 15:44:54 +02:00
private async setBackground(): 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
}
2023-03-28 05:13:48 +02:00
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") {
2023-06-07 14:34:58 +02:00
// 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
if (!map.getSource(maptiler.id)) {
this.removeCurrentLayer(map)
map.addSource(maptiler.id, MapLibreAdaptor.prepareWmsSource(maptiler))
map.setStyle(maptiler.url)
await this.awaitStyleIsLoaded()
}
2023-06-07 14:34:58 +02:00
}
if (!map.getLayer(addLayerBeforeId)) {
addLayerBeforeId = undefined
}
2023-10-06 02:23:24 +02:00
await this.awaitStyleIsLoaded()
if (!map.getSource(background.id)) {
map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background))
}
if (!map.getLayer(background.id)) {
2023-10-10 01:52:02 +02:00
addLayerBeforeId ??= map
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
2023-10-06 03:34:26 +02:00
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()
2023-09-28 23:50:27 +02:00
if (this._currentRasterLayer !== background?.id) {
this.removeCurrentLayer(map)
}
this._currentRasterLayer = background?.id
}
2023-03-24 19:21:15 +01:00
private setMaxBounds(bbox: undefined | BBox) {
const map = this._maplibreMap.data
if (!map) {
2023-03-24 19:21:15 +01:00
return
}
if (bbox) {
map?.setMaxBounds(bbox.toLngLat())
2023-03-24 19:21:15 +01:00
} else {
map?.setMaxBounds(null)
2023-03-24 19:21:15 +01:00
}
}
2023-07-18 01:26:04 +02:00
private setAllowRotating(allow: true | boolean | undefined) {
const map = this._maplibreMap.data
if (!map) {
return
}
if (allow === false) {
map.rotateTo(0, { duration: 0 })
map.setPitch(0)
map.dragRotate.disable()
2023-09-28 23:50:27 +02:00
map.touchZoomRotate.disableRotation()
2023-07-18 01:26:04 +02:00
} else {
map.dragRotate.enable()
2023-09-28 23:50:27 +02:00
map.touchZoomRotate.enableRotation()
2023-07-18 01:26:04 +02:00
}
}
2023-03-24 19:21:15 +01:00
private setAllowMoving(allow: true | boolean | undefined) {
const map = this._maplibreMap.data
if (!map) {
2023-03-24 19:21:15 +01:00
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()
}
}
2023-07-18 01:26:04 +02:00
this.setAllowRotating(this.allowRotating.data)
2023-03-24 19:21:15 +01:00
}
2023-03-28 05:13:48 +02:00
private setMinzoom(minzoom: number) {
const map = this._maplibreMap.data
if (!map) {
return
}
map.setMinZoom(minzoom)
}
2023-04-21 01:53:24 +02:00
private setMaxzoom(maxzoom: number) {
const map = this._maplibreMap.data
if (!map) {
2023-04-21 01:53:24 +02:00
return
}
map.setMaxZoom(maxzoom)
}
2023-03-28 05:13:48 +02:00
private setAllowZooming(allow: true | boolean | undefined) {
const map = this._maplibreMap.data
if (!map) {
2023-03-28 05:13:48 +02:00
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) {
2023-03-28 05:13:48 +02:00
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())
}
}