diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js index 2c0dd3427b..cd1259244a 100644 --- a/build-scripts/gulp/gather-static.js +++ b/build-scripts/gulp/gather-static.js @@ -81,6 +81,8 @@ async function copyMapPanel(staticDir) { npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"), staticPath("map/") ); + // Controls and popups of the native MapLibre engine + copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/")); } function copyZXingWasm(staticDir) { diff --git a/src/common/map/base-layer.ts b/src/common/map/base-layer.ts index 95735ce176..7fd513637f 100644 --- a/src/common/map/base-layer.ts +++ b/src/common/map/base-layer.ts @@ -12,14 +12,14 @@ import { // Generated by build-scripts/gulp/map-assets.js. The attribution comes from the // TileJSON, deliberately: it follows whoever serves the tiles. -const VECTOR_STYLES = { +export const VECTOR_STYLES = { light: "/static/map/light.json", dark: "/static/map/dark.json", } as const; // Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's // worker, hence a URL rather than an import. -const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js"; +export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js"; // MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer. // OSM serves no @2x variant. @@ -33,8 +33,8 @@ const OSM_ATTRIBUTION = // Browsers keep about 16 live WebGL contexts and drop the oldest, which a // dashboard full of map cards hits. A transient loss is restored, hence a grace. -const CONTEXT_RESTORE_GRACE = 2000; -const RECOVERY_THROTTLE = 30000; +export const CONTEXT_RESTORE_GRACE = 2000; +export const RECOVERY_THROTTLE = 30000; // On the map, not the layer: marker clustering throws without a maximum. The // floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1. @@ -55,7 +55,7 @@ export interface MapBaseLayer { let webGL2Supported: boolean | undefined; // Rules out iOS below 15, older Android tablets and blocklisted drivers. -const supportsWebGL2 = (): boolean => { +export const supportsWebGL2 = (): boolean => { if (webGL2Supported === undefined) { try { const context = document.createElement("canvas").getContext("webgl2"); @@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => { return style; }; -const loadStyle = async (url: string): Promise => { +export const loadStyle = async (url: string): Promise => { const style: StyleSpecification = await (await fetch(url)).json(); if (__DEMO__) { @@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise => { // Global to MapLibre, and it throws when set twice. let rtlTextPluginRequested = false; -const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => { +export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => { if (rtlTextPluginRequested) { return; } @@ -303,9 +303,11 @@ export const createBaseLayer = async ( leaflet: LeafletModuleType, map: LeafletMap, darkMode: boolean, - token: string | undefined + token: string | undefined, + // Skip the vector layer, e.g. after a permanent WebGL context loss + rasterOnly = false ): Promise => { - if (supportsWebGL2()) { + if (!rasterOnly && supportsWebGL2()) { let vectorLayer: MapBaseLayer | undefined; try { const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([ diff --git a/src/common/map/engines/leaflet-map-engine.ts b/src/common/map/engines/leaflet-map-engine.ts new file mode 100644 index 0000000000..ef76fe5934 --- /dev/null +++ b/src/common/map/engines/leaflet-map-engine.ts @@ -0,0 +1,350 @@ +import type { + CircleMarker, + Control, + Map, + MarkerClusterGroup, + Polyline, +} from "leaflet"; +import type { LeafletModuleType } from "../../dom/setup-leaflet-map"; +import type { MapBaseLayer } from "../base-layer"; +import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer"; +import { DecoratedMarker } from "../decorated_marker"; +import { isTouch } from "../../../util/is_touch"; +import type { + MapClusterOptions, + MapCircleOptions, + MapControlPosition, + MapEngine, + MapEngineOptions, + MapFitOptions, + MapItemHandle, + MapLatLng, + MapMarkerHandle, + MapMarkerOptions, + MapPath, +} from "../map-engine"; +import { setMarkerAccessibility } from "../marker-accessibility"; + +/** A leaflet marker that knows the engine handle it was created for */ +interface HandledMarker extends DecoratedMarker { + engineHandle?: LeafletMarkerHandle; +} + +interface LeafletMarkerHandle extends MapMarkerHandle { + marker: HandledMarker; +} + +/** + * The Leaflet implementation of MapEngine. Renders vector tiles through the + * maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles + * otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and + * the engine ha-locations-editor requires for leaflet-draw. + */ +export class LeafletMapEngine implements MapEngine { + /** + * Escape hatch for ha-locations-editor, which manages its own Leaflet + * layers (leaflet-draw). Not for use anywhere else. + */ + public leafletMap?: Map; + + public Leaflet?: LeafletModuleType; + + private _baseLayer?: MapBaseLayer; + + private _clusterable: HandledMarker[] = []; + + private _cluster?: MarkerClusterGroup; + + private _clusterOptions: MapClusterOptions | null = null; + + private _scaleControl?: Control.Scale; + + public async init( + container: HTMLElement, + options: MapEngineOptions + ): Promise { + const root = container.parentNode; + if (!root) { + throw new Error("Cannot set up a Leaflet map on a detached element"); + } + // eslint-disable-next-line + const Leaflet = (await import("leaflet")).default as LeafletModuleType; + Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/"; + await import("leaflet.markercluster"); + + const map = Leaflet.map(container, { + minZoom: MAP_MIN_ZOOM, + maxZoom: MAP_MAX_ZOOM, + }); + map.attributionControl.setPrefix(""); + for (const href of [ + "/static/images/leaflet/leaflet.css", + "/static/images/leaflet/MarkerCluster.css", + ]) { + const style = document.createElement("link"); + style.setAttribute("href", href); + style.setAttribute("rel", "stylesheet"); + root.appendChild(style); + } + map.setView(options.center, options.zoom); + + // The base layer adds itself; a vector layer may still fall back to raster + this._baseLayer = await createBaseLayer( + Leaflet, + map, + options.darkMode, + options.token, + options.rasterOnly ?? false + ); + this.leafletMap = map; + this.Leaflet = Leaflet; + map.zoomControl?.setPosition(options.zoomControlPosition); + + const { events } = options; + if (events.click) { + map.on("click", (ev) => { + events.click!([ev.latlng.lat, ev.latlng.lng]); + }); + } + if (events.zoomStart) { + map.on("zoomstart", () => events.zoomStart!()); + } + if (events.moveStart) { + map.on("movestart", () => events.moveStart!()); + } + } + + public destroy(): void { + this.leafletMap?.remove(); + this.leafletMap = undefined; + this.Leaflet = undefined; + this._baseLayer = undefined; + this._cluster = undefined; + this._clusterable = []; + this._scaleControl = undefined; + } + + public invalidateSize(): void { + this.leafletMap?.invalidateSize({ debounceMoveend: true }); + } + + public hasUsableSize(): boolean { + if (!this.leafletMap) { + return false; + } + const size = this.leafletMap.getSize(); + if (size.x > 0 && size.y > 0) { + return true; + } + const container = this.leafletMap.getContainer(); + if (container.clientWidth > 0 && container.clientHeight > 0) { + // The container was laid out since Leaflet last measured it + this.leafletMap.invalidateSize(false); + return true; + } + return false; + } + + public setDarkMode(darkMode: boolean): void { + this._baseLayer?.setDarkMode(darkMode); + } + + public setZoomControlPosition(position: MapControlPosition): void { + this.leafletMap?.zoomControl?.setPosition(position); + } + + public setScaleRuler(options: { metric: boolean } | null): void { + if (this._scaleControl) { + this.leafletMap?.removeControl(this._scaleControl); + this._scaleControl = undefined; + } + if (!options || !this.leafletMap || !this.Leaflet) { + return; + } + this._scaleControl = this.Leaflet.control.scale({ + position: "bottomleft", + metric: options.metric, + imperial: !options.metric, + }); + this._scaleControl.addTo(this.leafletMap!); + } + + public setView(center: MapLatLng, zoom?: number): void { + this.leafletMap?.setView(center, zoom); + } + + public setZoom(zoom: number): void { + this.leafletMap?.setZoom(zoom); + } + + private _getZoom(): number { + return this.leafletMap?.getZoom() ?? 0; + } + + private _project(location: MapLatLng): { x: number; y: number } { + const point = this.leafletMap!.project(location, this._getZoom()); + return { x: point.x, y: point.y }; + } + + public fitBounds(points: MapLatLng[], options?: MapFitOptions): void { + if (!this.leafletMap || !this.Leaflet || !points.length) { + return; + } + const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5); + this.leafletMap.fitBounds(bounds, { + maxZoom: options?.maxZoom, + animate: options?.animate, + }); + } + + public addMarker( + element: HTMLElement, + location: MapLatLng, + options: MapMarkerOptions + ): MapMarkerHandle { + const decoration = options.decoration + ? this.Leaflet!.circle(location, { + interactive: false, + color: options.decoration.color, + radius: options.decoration.radius, + }) + : undefined; + + // Leaflet's keyboard support focuses its own wrapper, where the element's + // activation handlers never hear a key; the element itself takes focus + const interactive = options.interactive ?? true; + if (interactive) { + element.tabIndex = 0; + } + setMarkerAccessibility(element, options.title, interactive); + const marker: HandledMarker = new DecoratedMarker(location, decoration, { + icon: this.Leaflet!.divIcon({ + html: element, + iconSize: options.size, + iconAnchor: options.anchor, + className: "", + }), + interactive, + keyboard: false, + title: options.title, + }); + + const handle: LeafletMarkerHandle = { + marker, + location, + clusterData: options.clusterData, + remove: () => { + this._cluster?.removeLayer(marker); + marker.remove(); + const index = this._clusterable.indexOf(marker); + if (index !== -1) { + this._clusterable.splice(index, 1); + } + }, + }; + marker.engineHandle = handle; + + if (options.cluster) { + // Placed on the map by the next setClustering call + this._clusterable.push(marker); + } else { + marker.addTo(this.leafletMap!); + } + return handle; + } + + public addCircle( + center: MapLatLng, + options: MapCircleOptions + ): MapItemHandle { + const circle = this.Leaflet!.circle(center, { + interactive: false, + color: options.color, + radius: options.radius, + }).addTo(this.leafletMap!); + return { remove: () => circle.remove() }; + } + + public addPath(path: MapPath): MapItemHandle { + const items: (Polyline | CircleMarker)[] = []; + for (const segment of path.segments) { + items.push( + this.Leaflet!.polyline(segment.points, { + color: path.color, + opacity: segment.opacity, + interactive: false, + }) + ); + } + for (const pathMarker of path.markers) { + items.push( + this.Leaflet!.circleMarker(pathMarker.location, { + radius: isTouch ? 8 : 3, + color: path.color, + opacity: pathMarker.opacity, + fillOpacity: pathMarker.opacity, + interactive: true, + }).bindTooltip(pathMarker.tooltipHtml, { direction: "top" }) + ); + } + items.forEach((item) => item.addTo(this.leafletMap!)); + return { remove: () => items.forEach((item) => item.remove()) }; + } + + public setClustering(options: MapClusterOptions | null): void { + if (this._cluster) { + this._cluster.remove(); + this._cluster = undefined; + } + this._clusterOptions = options; + if (!this.leafletMap || !this.Leaflet) { + return; + } + if (!options) { + this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!)); + return; + } + // markercluster groups by proximity only; groupKey is not supported here + this._cluster = this.Leaflet.markerClusterGroup({ + showCoverageOnHover: false, + removeOutsideVisibleBounds: false, + maxClusterRadius: options.radius, + iconCreateFunction: (cluster) => { + const members = (cluster.getAllChildMarkers() as HandledMarker[]).map( + (marker) => marker.engineHandle! + ); + const latLng = cluster.getLatLng(); + const icon = this._clusterOptions!.iconBuilder(members, [ + latLng.lat, + latLng.lng, + ]); + // The element fills the divIcon wrapper, which gets the size + icon.element.style.width = `${icon.size[0]}px`; + icon.element.style.height = `${icon.size[1]}px`; + // markercluster pins icons to the cluster, so a location override becomes an anchor shift + let anchor = icon.anchor; + if (icon.location) { + const clusterPoint = this._project([latLng.lat, latLng.lng]); + const targetPoint = this._project(icon.location); + const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2]; + anchor = [ + base[0] - (targetPoint.x - clusterPoint.x), + base[1] - (targetPoint.y - clusterPoint.y), + ]; + } + return this.Leaflet!.divIcon({ + html: icon.element, + iconSize: icon.size, + iconAnchor: anchor, + className: "", + }); + }, + }); + this._cluster.addLayers(this._clusterable); + this.leafletMap!.addLayer(this._cluster!); + } + + public refreshClusters(): void { + this._cluster?.refreshClusters(); + } +} diff --git a/src/common/map/engines/maplibre-map-engine.ts b/src/common/map/engines/maplibre-map-engine.ts new file mode 100644 index 0000000000..aa5969fc10 --- /dev/null +++ b/src/common/map/engines/maplibre-map-engine.ts @@ -0,0 +1,1021 @@ +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import type { + IControl, + LayerSpecification, + Map as MapLibreMap, + MapLayerMouseEvent, + MapMouseEvent, + Marker as MapLibreMarker, + StyleSpecification, +} from "maplibre-gl"; +import type maplibregl from "maplibre-gl"; +import { setMarkerAccessibility } from "../marker-accessibility"; +import { + CONTEXT_RESTORE_GRACE, + ensureRTLTextPlugin, + loadStyle, + MAP_MAX_ZOOM, + MAP_MIN_ZOOM, + RECOVERY_THROTTLE, + VECTOR_STYLES, +} from "../base-layer"; +import { + refreshMapTilesToken, + subscribeMapTilesToken, + withMapTilesToken, +} from "../../../data/map_tiles"; +import { isTouch } from "../../../util/is_touch"; +import type { + MapCircleOptions, + MapClusterOptions, + MapControlPosition, + MapEngine, + MapEngineEvents, + MapEngineOptions, + MapFitOptions, + MapItemHandle, + MapLatLng, + MapMarkerHandle, + MapMarkerOptions, + MapPath, +} from "../map-engine"; + +type MapLibreModule = typeof maplibregl; + +// MapLibre zoom is one level below Leaflet's, which the interface uses +const ZOOM_OFFSET = 1; + +// Marks the sources and layers this engine owns, to carry them over style swaps +const CUSTOM_PREFIX = "ha-map-"; + +const POSITIONS: Record< + MapControlPosition, + "top-left" | "top-right" | "bottom-left" | "bottom-right" +> = { + topleft: "top-left", + topright: "top-right", + bottomleft: "bottom-left", + bottomright: "bottom-right", +}; + +const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css"; + +// Roughly one zoom level per two wheel notches (MapLibre's default is 1/450) +const WHEEL_ZOOM_RATE = 1 / 200; + +// Regroup clusters once continuous zooming settles, not on every wheel notch +const CLUSTER_REBUILD_DELAY = 120; + +type GeoJSONSourceSpecification = Extract< + Parameters[1], + { type: "geojson" } +>; + +// A marker element keeps MapLibre's positioning once it leaves the map +const resetMarkerElement = (element: HTMLElement): void => { + Array.from(element.classList) + .filter((name) => name.startsWith("maplibregl-marker")) + .forEach((name) => element.classList.remove(name)); + element.style.transform = ""; + element.style.opacity = ""; + element.style.pointerEvents = ""; +}; + +// A meter-radius circle as a polygon (spherical approximation) +const circlePolygon = ( + center: MapLatLng, + radiusMeters: number +): Feature => { + const steps = 64; + const latOffset = radiusMeters / 111320; + const lngOffset = + latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01); + const ring: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const theta = (2 * Math.PI * i) / steps; + ring.push([ + center[1] + lngOffset * Math.sin(theta), + center[0] + latOffset * Math.cos(theta), + ]); + } + return { + type: "Feature", + properties: {}, + geometry: { type: "Polygon", coordinates: [ring] }, + }; +}; + +interface ManagedMarker { + element: HTMLElement; + location: MapLatLng; + options: MapMarkerOptions; + handle: MapMarkerHandle; + mlMarker?: MapLibreMarker; + decoration?: MapItemHandle; + removed?: boolean; +} + +interface ClusterGroup { + /** Members are shown in a bubble at their spot instead of an icon */ + open?: boolean; + members: ManagedMarker[]; + center: MapLatLng; + iconMarker?: MapLibreMarker; +} + +/** + * The MapLibre GL engine: native vector rendering, requires WebGL2. The host + * falls back to Leaflet without it or after a fatal context loss. + */ +export class MapLibreMapEngine implements MapEngine { + private _maplibre?: MapLibreModule; + + private _map?: MapLibreMap; + + private _events: Partial = {}; + + private _zoomControl?: IControl; + + private _scaleControl?: IControl; + + private _markers: ManagedMarker[] = []; + + private _clusterOptions: MapClusterOptions | null = null; + + private _clusterGroups: ClusterGroup[] = []; + + private _clusterRebuildTimeout?: number; + + private _idCounter = 0; + + // Carried over style swaps, which replace all sources and layers + // Kept by spec, not id: a style swap arriving while the previous swap is + // still loading has no previous style to carry them over from + private _customSources = new Map(); + + private _customLayers = new Map(); + + // The only custom layers that take clicks (hover tooltips) + private _pathPointLayers = new Set(); + + // A style swap the differ cannot apply rebuilds the style, which is unloaded + // until the next frame and throws on mutation; layer work queues until then + private _pendingStyleOps: (() => void)[] = []; + + // A failed style request rolls back to the applied mode, not the requested one + private _appliedDarkMode = false; + + private _requestedDarkMode = false; + + private _latestStyleRequest = 0; + + private _contextLost = false; + + private _fallbackTimeout?: number; + + private _destroyed = false; + + private _refused = false; + + private _unsubscribeToken?: () => void; + + private _resizing = false; + + private _settleInit?: () => void; + + public async init( + container: HTMLElement, + options: MapEngineOptions + ): Promise { + if (options.rasterOnly) { + throw new Error("The MapLibre engine cannot render without WebGL"); + } + const maplibre = (await import("maplibre-gl")).default; + this._maplibre = maplibre; + ensureRTLTextPlugin(maplibre.setRTLTextPlugin); + + // MapLibre's stylesheet for controls and popups; one link per root + const root = container.parentNode; + if (root && !root.querySelector(`link[href="${MAPLIBRE_CSS_URL}"]`)) { + const style = document.createElement("link"); + style.setAttribute("href", MAPLIBRE_CSS_URL); + style.setAttribute("rel", "stylesheet"); + root.appendChild(style); + } + + this._appliedDarkMode = options.darkMode; + this._requestedDarkMode = options.darkMode; + this._events = options.events; + + const style = await loadStyle( + VECTOR_STYLES[options.darkMode ? "dark" : "light"] + ); + if (this._destroyed) { + return; + } + + const map = new maplibre.Map({ + container, + style, + center: [options.center[1], options.center[0]], + zoom: options.zoom - ZOOM_OFFSET, + minZoom: MAP_MIN_ZOOM - ZOOM_OFFSET, + maxZoom: MAP_MAX_ZOOM - ZOOM_OFFSET, + // Dashboards are north-up; no rotate or pitch gestures + dragRotate: false, + pitchWithRotate: false, + touchPitch: false, + // Rendered with a device font, so these glyphs are never requested + localIdeographFontFamily: "sans-serif", + // Inline on wide maps, collapsible (open by default) on narrow ones + attributionControl: {}, + // Proxied by core behind a token; absolute so the worker can resolve them + transformRequest: (url) => ({ + url: withMapTilesToken(url), + referrerPolicy: __DEMO__ ? "origin" : undefined, + }), + }); + map.touchZoomRotate.disableRotation(); + map.keyboard.disableRotation(); + map.scrollZoom.setWheelZoomRate(WHEEL_ZOOM_RATE); + this._map = map; + + // A refused TileJSON is never retried, so the style is applied again with + // a new token. 403: stale token, 404: proxy not registered yet during a + // restart, no status: network. Throttled so another refusal cannot loop. + let lastRecovery = 0; + map.on("error", (event) => { + const status = (event.error as { status?: number } | undefined)?.status; + if (status !== undefined && status !== 403 && status !== 404) { + return; + } + if (Date.now() - lastRecovery < RECOVERY_THROTTLE) { + return; + } + lastRecovery = Date.now(); + this._refused = true; + refreshMapTilesToken(); + }); + // Only a new token clears a refusal; a theme change in between is refused too + this._unsubscribeToken = subscribeMapTilesToken(() => { + if (this._refused) { + this._refused = false; + this._applyStyle(this._requestedDarkMode); + } + }); + + this._zoomControl = new maplibre.NavigationControl({ showCompass: false }); + map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]); + + map.on("click", (ev) => { + // Clicks on path points (they have tooltips) are not map clicks + const pathHit = map + .queryRenderedFeatures(ev.point) + .some((feature) => this._pathPointLayers.has(feature.layer.id)); + if (!pathHit) { + this._events.click?.([ev.lngLat.lat, ev.lngLat.lng]); + } + }); + map.on("zoomstart", () => this._events.zoomStart?.()); + map.on("movestart", () => { + // resize() fires movestart even when nothing changed (see _resize) + if (!this._resizing) { + this._events.moveStart?.(); + } + }); + // Grouping depends on screen distances; regroup when the camera settles + map.on("moveend", () => { + if (this._clusterOptions) { + clearTimeout(this._clusterRebuildTimeout); + this._clusterRebuildTimeout = window.setTimeout(() => { + this._rebuildClusters(); + }, CLUSTER_REBUILD_DELAY); + } + }); + + // A lost WebGL context gets a grace period to come back before falling back + map.on("webglcontextlost", () => { + this._contextLost = true; + this._scheduleFatal(); + }); + map.on("webglcontextrestored", () => { + this._contextLost = false; + clearTimeout(this._fallbackTimeout); + }); + document.addEventListener("visibilitychange", this._handleVisibility); + + // Sources and layers can be added once the style has loaded (see + // _whenStyleLoaded); it was fetched above, so this cannot fail + await new Promise((resolve) => { + if (map.getStyle()) { + resolve(); + return; + } + // destroy() settles a pending init, so a host torn down mid-setup + // gets to finish + this._settleInit = resolve; + map.once("style.load", () => resolve()); + }); + this._settleInit = undefined; + } + + private _handleVisibility = () => { + if (this._contextLost) { + this._scheduleFatal(); + } + }; + + private _scheduleFatal(): void { + clearTimeout(this._fallbackTimeout); + // Backgrounding drops the context too, and there it comes back on return + if (this._destroyed || document.hidden) { + return; + } + this._fallbackTimeout = window.setTimeout(() => { + this._events.fatal?.(); + }, CONTEXT_RESTORE_GRACE); + } + + public destroy(): void { + this._destroyed = true; + this._settleInit?.(); + this._settleInit = undefined; + this._unsubscribeToken?.(); + clearTimeout(this._fallbackTimeout); + clearTimeout(this._clusterRebuildTimeout); + document.removeEventListener("visibilitychange", this._handleVisibility); + this._clusterGroups.forEach((group) => group.iconMarker?.remove()); + this._clusterGroups = []; + this._markers = []; + this._pendingStyleOps = []; + this._map?.remove(); + this._map = undefined; + } + + public invalidateSize(): void { + this._resize(); + } + + public hasUsableSize(): boolean { + if (!this._map) { + return false; + } + const container = this._map.getContainer(); + if (container.clientWidth > 0 && container.clientHeight > 0) { + this._resize(); + return true; + } + return false; + } + + // MapLibre's resize fires movestart/move/moveend synchronously even when the + // size did not change; hosts must not read them as camera movement + private _resize(): void { + if (!this._map) { + return; + } + this._resizing = true; + try { + this._map.resize(); + } finally { + this._resizing = false; + } + } + + public setDarkMode(darkMode: boolean): void { + if (!this._map || darkMode === this._requestedDarkMode) { + return; + } + this._requestedDarkMode = darkMode; + this._applyStyle(darkMode); + } + + private _applyStyle(darkMode: boolean): void { + const request = ++this._latestStyleRequest; + + loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]) + .then((style) => { + if (request === this._latestStyleRequest && this._map) { + this._map.setStyle(style, { + transformStyle: (previous, next) => + this._carryCustomLayers(previous, next), + }); + this._appliedDarkMode = darkMode; + } + }) + .catch(() => { + if (request === this._latestStyleRequest) { + this._requestedDarkMode = this._appliedDarkMode; + } + }); + } + + private _carryCustomLayers( + previous: StyleSpecification | undefined, + next: StyleSpecification + ): StyleSpecification { + const sources = { ...next.sources }; + for (const [id, source] of this._customSources) { + sources[id] = previous?.sources?.[id] ?? source; + } + const nextIds = new Set(next.layers.map((layer) => layer.id)); + const customLayers = [...this._customLayers.values()].filter( + (layer) => !nextIds.has(layer.id) + ); + const layers = [...next.layers]; + const symbolIndex = layers.findIndex((layer) => layer.type === "symbol"); + layers.splice( + symbolIndex === -1 ? layers.length : symbolIndex, + 0, + ...customLayers + ); + return { ...next, sources, layers }; + } + + public setZoomControlPosition(position: MapControlPosition): void { + if (!this._map || !this._zoomControl) { + return; + } + this._map.removeControl(this._zoomControl); + this._map.addControl(this._zoomControl, POSITIONS[position]); + } + + public setScaleRuler(options: { metric: boolean } | null): void { + if (this._scaleControl) { + this._map?.removeControl(this._scaleControl); + this._scaleControl = undefined; + } + if (!options || !this._map || !this._maplibre) { + return; + } + this._scaleControl = new this._maplibre.ScaleControl({ + unit: options.metric ? "metric" : "imperial", + }); + this._map.addControl(this._scaleControl, "bottom-left"); + } + + public setView(center: MapLatLng, zoom?: number): void { + this._map?.jumpTo({ + center: [center[1], center[0]], + zoom: zoom !== undefined ? zoom - ZOOM_OFFSET : undefined, + }); + } + + public setZoom(zoom: number): void { + this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET }); + } + + private _getMaxZoom(): number { + return (this._map?.getMaxZoom() ?? 0) + ZOOM_OFFSET; + } + + private _project(location: MapLatLng): { x: number; y: number } { + const point = this._map!.project([location[1], location[0]]); + return { x: point.x, y: point.y }; + } + + public fitBounds(points: MapLatLng[], options?: MapFitOptions): void { + if (!this._map || !this._maplibre || !points.length) { + return; + } + let minLat = points[0][0]; + let maxLat = points[0][0]; + let minLng = points[0][1]; + let maxLng = points[0][1]; + for (const [lat, lng] of points) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + } + const maxZoom = + options?.maxZoom !== undefined + ? options.maxZoom - ZOOM_OFFSET + : undefined; + if (minLat === maxLat && minLng === maxLng) { + // Zero-area bounds: center on the point + this._map.easeTo({ + center: [minLng, minLat], + zoom: maxZoom ?? this._map.getZoom(), + animate: options?.animate, + }); + return; + } + const pad = options?.pad ?? 0.5; + const latPad = (maxLat - minLat) * pad; + const lngPad = (maxLng - minLng) * pad; + this._map.fitBounds( + [ + [minLng - lngPad, minLat - latPad], + [maxLng + lngPad, maxLat + latPad], + ], + { maxZoom, animate: options?.animate } + ); + } + + public addMarker( + element: HTMLElement, + location: MapLatLng, + options: MapMarkerOptions + ): MapMarkerHandle { + element.style.width = `${options.size[0]}px`; + element.style.height = `${options.size[1]}px`; + if (options.title) { + element.title = options.title; + } + if (options.interactive ?? true) { + element.tabIndex = 0; + } else { + // Leaflet lets input through non-interactive markers; MapLibre does not + element.style.pointerEvents = "none"; + } + setMarkerAccessibility(element, options.title, options.interactive ?? true); + + const managed: ManagedMarker = { + element, + location, + options, + handle: undefined as unknown as MapMarkerHandle, + }; + managed.handle = { + location, + clusterData: options.clusterData, + remove: () => { + managed.removed = true; + this._hideMarker(managed); + const index = this._markers.indexOf(managed); + if (index !== -1) { + this._markers.splice(index, 1); + } + }, + }; + this._markers.push(managed); + + if (!options.cluster) { + // Clusterable markers appear on the next setClustering call + this._showMarker(managed); + } + return managed.handle; + } + + private _showMarker(managed: ManagedMarker): void { + if (!this._map || !this._maplibre || managed.removed) { + return; + } + if (!managed.mlMarker) { + const { options } = managed; + managed.mlMarker = new this._maplibre.Marker({ + element: managed.element, + ...(options.anchor + ? { + anchor: "top-left" as const, + offset: [-options.anchor[0], -options.anchor[1]] as [ + number, + number, + ], + } + : {}), + }) + .setLngLat([managed.location[1], managed.location[0]]) + .addTo(this._map); + } + if (managed.options.decoration && !managed.decoration) { + managed.decoration = this.addCircle( + managed.location, + managed.options.decoration + ); + } + } + + private _hideMarker(managed: ManagedMarker): void { + managed.mlMarker?.remove(); + managed.mlMarker = undefined; + managed.decoration?.remove(); + managed.decoration = undefined; + } + + public addCircle( + center: MapLatLng, + options: MapCircleOptions + ): MapItemHandle { + if (!this._map) { + return { remove: () => undefined }; + } + const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`; + this._addCustomSource(id, circlePolygon(center, options.radius)); + this._addCustomLayer({ + id: `${id}-fill`, + type: "fill", + source: id, + paint: { "fill-color": options.color, "fill-opacity": 0.2 }, + }); + this._addCustomLayer({ + id: `${id}-line`, + type: "line", + source: id, + paint: { "line-color": options.color, "line-width": 3 }, + }); + return { + remove: () => { + this._removeCustomLayer(`${id}-fill`); + this._removeCustomLayer(`${id}-line`); + this._removeCustomSource(id); + }, + }; + } + + public addPath(path: MapPath): MapItemHandle { + if (!this._map || !this._maplibre) { + return { remove: () => undefined }; + } + const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`; + + const lines: FeatureCollection = { + type: "FeatureCollection", + features: path.segments.map((segment) => ({ + type: "Feature", + properties: { opacity: segment.opacity ?? 1 }, + geometry: { + type: "LineString", + coordinates: segment.points.map((point) => [point[1], point[0]]), + }, + })), + }; + const points: FeatureCollection = { + type: "FeatureCollection", + features: path.markers.map((pathMarker) => ({ + type: "Feature", + properties: { + opacity: pathMarker.opacity ?? 1, + tooltip: pathMarker.tooltipHtml, + }, + geometry: { + type: "Point", + coordinates: [pathMarker.location[1], pathMarker.location[0]], + }, + })), + }; + + this._addCustomSource(`${id}-lines`, lines); + this._addCustomSource(`${id}-points`, points); + this._addCustomLayer({ + id: `${id}-lines`, + type: "line", + source: `${id}-lines`, + paint: { + "line-color": path.color, + "line-width": 3, + "line-opacity": ["get", "opacity"], + }, + }); + this._addCustomLayer({ + id: `${id}-points`, + type: "circle", + source: `${id}-points`, + paint: { + "circle-radius": isTouch ? 8 : 3, + "circle-color": path.color, + "circle-opacity": ["get", "opacity"], + "circle-stroke-width": 2, + "circle-stroke-color": path.color, + "circle-stroke-opacity": ["get", "opacity"], + }, + }); + + const map = this._map; + const popup = new this._maplibre.Popup({ + closeButton: false, + closeOnClick: false, + offset: 10, + }); + const layerId = `${id}-points`; + const showPopup = (ev: MapLayerMouseEvent) => { + const feature = ev.features?.[0]; + if (!feature || feature.geometry.type !== "Point") { + return; + } + popup + .setLngLat(feature.geometry.coordinates as [number, number]) + .setHTML(feature.properties?.tooltip ?? "") + .addTo(map); + }; + const onEnter = (ev: MapLayerMouseEvent) => { + map.getCanvas().style.cursor = "pointer"; + showPopup(ev); + }; + const onLeave = () => { + map.getCanvas().style.cursor = ""; + popup.remove(); + }; + // Touch has no hover: a tap shows the popup, a tap elsewhere dismisses it + const onMapClick = (ev: MapMouseEvent) => { + if (!map.queryRenderedFeatures(ev.point, { layers: [layerId] }).length) { + popup.remove(); + } + }; + map.on("mouseenter", layerId, onEnter); + map.on("mouseleave", layerId, onLeave); + map.on("click", layerId, showPopup); + map.on("click", onMapClick); + this._pathPointLayers.add(layerId); + + return { + remove: () => { + map.off("mouseenter", layerId, onEnter); + map.off("mouseleave", layerId, onLeave); + map.off("click", layerId, showPopup); + map.off("click", onMapClick); + this._pathPointLayers.delete(layerId); + popup.remove(); + this._removeCustomLayer(`${id}-lines`); + this._removeCustomLayer(`${id}-points`); + this._removeCustomSource(`${id}-lines`); + this._removeCustomSource(`${id}-points`); + }, + }; + } + + // Runs now, or once the style has loaded. MapLibre serializes nothing + // until then, so getStyle() is undefined exactly while the style is unloaded + private _whenStyleLoaded(operation: () => void): void { + const map = this._map; + if (!map) { + return; + } + if (map.getStyle()) { + operation(); + return; + } + if (!this._pendingStyleOps.length) { + map.once("style.load", () => { + const operations = this._pendingStyleOps; + this._pendingStyleOps = []; + operations.forEach((pending) => pending()); + }); + } + this._pendingStyleOps.push(operation); + } + + private _addCustomSource( + id: string, + data: Feature | FeatureCollection + ): void { + this._whenStyleLoaded(() => { + const source: GeoJSONSourceSpecification = { type: "geojson", data }; + this._map!.addSource(id, source); + this._customSources.set(id, source); + }); + } + + private _addCustomLayer(layer: LayerSpecification): void { + this._whenStyleLoaded(() => { + // Under the labels, over the base cartography + const symbolLayer = this._map!.getStyle().layers.find( + (styleLayer) => + styleLayer.type === "symbol" && !this._customLayers.has(styleLayer.id) + ); + this._map!.addLayer(layer, symbolLayer?.id); + this._customLayers.set(layer.id, layer); + }); + } + + private _removeCustomLayer(id: string): void { + this._whenStyleLoaded(() => { + if (this._map!.getLayer(id)) { + this._map!.removeLayer(id); + } + this._customLayers.delete(id); + }); + } + + private _removeCustomSource(id: string): void { + this._whenStyleLoaded(() => { + if (this._map!.getSource(id)) { + this._map!.removeSource(id); + } + this._customSources.delete(id); + }); + } + + public setClustering(options: MapClusterOptions | null): void { + this._clusterOptions = options; + this._rebuildClusters(); + } + + public refreshClusters(): void { + this._rebuildClusters(false); + } + + // Zooming separates members unless they share a spot or the map is already + // at its maximum zoom + private _canSeparate(members: ManagedMarker[]): boolean { + const map = this._map!; + if (map.getZoom() >= map.getMaxZoom() - 0.01) { + return false; + } + const [first] = members; + return members.some( + (managed) => + managed.location[0] !== first.location[0] || + managed.location[1] !== first.location[1] + ); + } + + // Shows the members themselves in a bubble whose tail points at their + // spot, each reachable on its own; the next regroup closes it + private _openGroup(group: ClusterGroup): void { + const members = document.createElement("div"); + members.className = "cluster-open-members"; + for (const managed of group.members) { + this._hideMarker(managed); + resetMarkerElement(managed.element); + members.appendChild(managed.element); + } + const tail = document.createElement("div"); + tail.className = "cluster-open-tail"; + const root = document.createElement("div"); + root.className = "cluster-open"; + root.append(members, tail); + group.iconMarker = new this._maplibre!.Marker({ + element: root, + anchor: "bottom", + }) + .setLngLat([group.center[1], group.center[0]]) + .addTo(this._map!); + } + + // Groups clusterable markers by screen distance + private _rebuildClusters(regroup = true): void { + if (!this._map) { + return; + } + const clusterable = this._markers.filter( + (managed) => managed.options.cluster && !managed.removed + ); + // A member that had focus hands it to the icon replacing it; read before + // the open bubble holding it is removed + const active = ( + this._map.getContainer().getRootNode() as Document | ShadowRoot + ).activeElement; + const focusedMember = active + ? clusterable.find((managed) => managed.element.contains(active)) + : undefined; + this._clusterGroups.forEach((group) => group.iconMarker?.remove()); + + if (!this._clusterOptions) { + this._clusterGroups = []; + clusterable.forEach((managed) => this._showMarker(managed)); + return; + } + + if (!regroup) { + // Markers removed since the last grouping leave their groups + this._clusterGroups = this._clusterGroups + .map((group) => ({ + ...group, + members: group.members.filter((managed) => !managed.removed), + })) + .filter((group) => group.members.length); + } + + if (regroup || !this._clusterGroups.length) { + const { radius, groupKey, groupRadius } = this._clusterOptions; + const groups: { + seed: { x: number; y: number }; + members: ManagedMarker[]; + }[] = []; + + // Keyed groups first; one spread too wide falls through to proximity + const ungrouped: ManagedMarker[] = []; + if (groupKey) { + const byKey: Record = {}; + for (const managed of clusterable) { + const key = groupKey(managed.handle); + if (key === undefined) { + ungrouped.push(managed); + } else { + (byKey[key] ??= []).push(managed); + } + } + for (const members of Object.values(byKey)) { + const points = members.map((m) => this._project(m.location)); + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const spread = Math.hypot( + Math.max(...xs) - Math.min(...xs), + Math.max(...ys) - Math.min(...ys) + ); + if (members.length > 1 && spread <= (groupRadius ?? radius)) { + groups.push({ seed: points[0], members }); + } else { + ungrouped.push(...members); + } + } + } else { + ungrouped.push(...clusterable); + } + + for (const managed of ungrouped) { + const point = this._project(managed.location); + const group = groups.find( + (candidate) => + Math.hypot( + candidate.seed.x - point.x, + candidate.seed.y - point.y + ) <= radius + ); + if (group) { + group.members.push(managed); + } else { + groups.push({ seed: point, members: [managed] }); + } + } + this._clusterGroups = groups.map((group) => ({ + members: group.members, + center: [ + group.members.reduce((sum, m) => sum + m.location[0], 0) / + group.members.length, + group.members.reduce((sum, m) => sum + m.location[1], 0) / + group.members.length, + ] as MapLatLng, + })); + } + + for (const group of this._clusterGroups) { + group.iconMarker = undefined; + if (group.members.length === 1) { + this._showMarker(group.members[0]); + continue; + } + if (group.open) { + this._openGroup(group); + continue; + } + group.members.forEach((managed) => this._hideMarker(managed)); + + const icon = this._clusterOptions.iconBuilder( + group.members.map((managed) => managed.handle), + group.center + ); + icon.element.style.width = `${icon.size[0]}px`; + icon.element.style.height = `${icon.size[1]}px`; + // Clicking a bubble zooms in on its members; when zooming cannot + // separate them, they open in a bubble pointing at their spot instead + const zoomToMembers = () => { + if (this._canSeparate(group.members)) { + this.fitBounds( + group.members.map((managed) => managed.location), + { pad: 0.3, maxZoom: this._getMaxZoom() } + ); + return; + } + group.open = true; + group.iconMarker?.remove(); + this._openGroup(group); + }; + icon.element.tabIndex = 0; + setMarkerAccessibility( + icon.element, + group.members + .map((managed) => managed.options.title) + .filter(Boolean) + .join(", ") || undefined, + true + ); + icon.element.addEventListener("click", (ev) => { + ev.stopPropagation(); + zoomToMembers(); + }); + icon.element.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + zoomToMembers(); + // Keyboard focus follows into the opened bubble + if (group.open) { + group.members[0]?.element.focus(); + } + } + }); + const location = icon.location ?? group.center; + group.iconMarker = new this._maplibre!.Marker({ + element: icon.element, + ...(icon.anchor + ? { + anchor: "top-left" as const, + offset: [-icon.anchor[0], -icon.anchor[1]] as [number, number], + } + : {}), + }) + .setLngLat([location[1], location[0]]) + .addTo(this._map); + } + if (focusedMember) { + const group = this._clusterGroups.find((candidate) => + candidate.members.includes(focusedMember) + ); + if (group?.iconMarker && !group.open) { + group.iconMarker.getElement().focus(); + } + } + } +} diff --git a/src/common/map/map-engine.ts b/src/common/map/map-engine.ts new file mode 100644 index 0000000000..4e017eb606 --- /dev/null +++ b/src/common/map/map-engine.ts @@ -0,0 +1,207 @@ +/** + * Engine abstraction for ha-map. + * + * ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble + * DOM, history path math, fit policy) and delegates the primitive map + * operations to a MapEngine, so the engine can be selected at runtime: + * MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always + * for ha-locations-editor, which edits with leaflet-draw). + * + * The interface exposes no engine types: positions are [latitude, longitude] + * tuples and marker content is caller-owned HTML elements. + * + * Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical + * convention across Home Assistant map configs. The MapLibre engine converts + * internally (MapLibre zoom = Leaflet zoom - 1). + */ + +export type MapLatLng = [latitude: number, longitude: number]; + +export interface MapPoint { + x: number; + y: number; +} + +export type MapControlPosition = + "topleft" | "topright" | "bottomleft" | "bottomright"; + +export interface MapEngineEvents { + /** Click on the map surface, not on a marker */ + click(location: MapLatLng): void; + /** Zoom is starting, programmatic or not */ + zoomStart(): void; + /** The map starts moving, programmatic or not */ + moveStart(): void; + /** The engine can no longer render; the host switches to the fallback */ + fatal(): void; +} + +export interface MapEngineOptions { + center: MapLatLng; + zoom: number; + darkMode: boolean; + /** Token for core's tile proxy */ + token?: string; + zoomControlPosition: MapControlPosition; + /** Render without WebGL after a permanent context loss; WebGL engines reject init */ + rasterOnly?: boolean; + events: Partial; +} + +export interface MapFitOptions { + /** Do not zoom in beyond this level even if the bounds would allow it */ + maxZoom?: number; + /** Relative padding around the bounds, e.g. 0.5 grows them by 50% */ + pad?: number; + /** Ease the camera to the bounds instead of jumping; defaults to true */ + animate?: boolean; +} + +export interface MapMarkerOptions { + /** Rendered size of the element in pixels */ + size: [width: number, height: number]; + /** Point of the element placed on the coordinate, from its top left; defaults to the center */ + anchor?: [x: number, y: number]; + /** Takes pointer input and keyboard focus; defaults to true */ + interactive?: boolean; + /** Accessible name */ + title?: string; + /** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */ + decoration?: MapCircleOptions; + /** Cluster this marker; it appears once setClustering is called */ + cluster?: boolean; + /** Caller data handed back to the cluster icon builder */ + clusterData?: unknown; +} + +export interface MapCircleOptions { + /** Radius in meters */ + radius: number; + /** Stroke color; the fill is derived from it, translucent */ + color: string; +} + +export interface MapPathSegment { + points: MapLatLng[]; + opacity?: number; +} + +export interface MapPathMarker { + location: MapLatLng; + opacity?: number; + /** Tooltip/popup HTML shown on hover; caller is responsible for escaping */ + tooltipHtml: string; +} + +export interface MapPath { + color: string; + segments: MapPathSegment[]; + markers: MapPathMarker[]; +} + +/** Handle to anything placed on the map; remove() must be idempotent */ +export interface MapItemHandle { + remove(): void; +} + +export interface MapMarkerHandle extends MapItemHandle { + readonly location: MapLatLng; + readonly clusterData?: unknown; +} + +export interface MapClusterIcon { + element: HTMLElement; + size: [width: number, height: number]; + /** Like MapMarkerOptions.anchor; defaults to the element's center */ + anchor?: [x: number, y: number]; + /** Show the icon here instead of at the cluster, e.g. attached to a zone */ + location?: MapLatLng; +} + +export interface MapClusterOptions { + /** Cluster markers closer than this many screen pixels */ + radius: number; + /** + * Markers sharing a key (e.g. their zone) form one group while they span + * at most groupRadius pixels; beyond that, and without a key, they cluster + * by proximity. + */ + groupKey?(marker: MapMarkerHandle): string | undefined; + groupRadius?: number; + /** Builds a cluster's element; called when its members change and on refreshClusters() */ + iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon; +} + +export interface MapEngine { + /** Create the map in the container; call once */ + init(container: HTMLElement, options: MapEngineOptions): Promise; + + /** Tear down the map and release its resources (DOM, workers, WebGL) */ + destroy(): void; + + /** Re-measure the container after a size change */ + invalidateSize(): void; + + /** Whether the map has a non-zero size, re-measuring if needed */ + hasUsableSize(): boolean; + + setDarkMode(darkMode: boolean): void; + + setZoomControlPosition(position: MapControlPosition): void; + + /** Show a scale ruler (bottom start); null hides it */ + setScaleRuler(options: { metric: boolean } | null): void; + + setView(center: MapLatLng, zoom?: number): void; + + setZoom(zoom: number): void; + + /** Fit the given points into view; a single point centers on it */ + fitBounds(points: MapLatLng[], options?: MapFitOptions): void; + + // Content ------------------------------------------------------------ + + /** + * Place an HTML element on the map. The element is owned by the caller; + * the engine positions it and, for interactive markers, makes it + * focusable. + */ + addMarker( + element: HTMLElement, + location: MapLatLng, + options: MapMarkerOptions + ): MapMarkerHandle; + + /** Draw a meter-radius circle (zone radius) */ + addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle; + + /** Draw one history trail (points with tooltips, connecting segments) */ + addPath(path: MapPath): MapItemHandle; + + /** + * Enable or disable clustering of the markers added with cluster: true. + * Must be called after each batch of addMarker calls to place clusterable + * markers on the map; null places them unclustered. + */ + setClustering(options: MapClusterOptions | null): void; + + /** Rebuild cluster icons without regrouping (e.g. after a style change) */ + refreshClusters(): void; +} + +/** + * Bounding box corners of a circle, for fitting a radius into view without + * engine-specific circle bounds. + */ +export const circleBoundsPoints = ( + center: MapLatLng, + radiusMeters: number +): MapLatLng[] => { + const latOffset = radiusMeters / 111320; + const lngOffset = + latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01); + return [ + [center[0] - latOffset, center[1] - lngOffset], + [center[0] + latOffset, center[1] + lngOffset], + ]; +}; diff --git a/src/common/map/marker-accessibility.ts b/src/common/map/marker-accessibility.ts new file mode 100644 index 0000000000..1f94970ebf --- /dev/null +++ b/src/common/map/marker-accessibility.ts @@ -0,0 +1,23 @@ +/** + * Marker elements are focusable buttons on both engines, so the caller's + * element is the keyboard target and needs a name and a role. MapLibre would + * otherwise label it "Map marker". + */ +export const setMarkerAccessibility = ( + element: HTMLElement, + title: string | undefined, + interactive: boolean +): void => { + if (title && !element.hasAttribute("aria-label")) { + element.setAttribute("aria-label", title); + } + if (!element.hasAttribute("role")) { + if (interactive) { + element.setAttribute("role", "button"); + } else if (title) { + element.setAttribute("role", "img"); + } else { + element.setAttribute("aria-hidden", "true"); + } + } +}; diff --git a/src/components/map/ha-entity-marker.ts b/src/components/map/ha-entity-marker.ts index 5bc96c4a6e..1836016f8e 100644 --- a/src/components/map/ha-entity-marker.ts +++ b/src/components/map/ha-entity-marker.ts @@ -58,6 +58,24 @@ class HaEntityMarker extends LitElement { `; } + public connectedCallback() { + super.connectedCallback(); + this.addEventListener("keydown", this._handleKeydown); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("keydown", this._handleKeydown); + } + + // The map engines make the marker a focusable button + private _handleKeydown = (ev: KeyboardEvent) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._badgeTap(ev); + } + }; + private _badgeTap(ev: Event) { ev.stopPropagation(); if (this.entityId) { diff --git a/src/components/map/ha-locations-editor.ts b/src/components/map/ha-locations-editor.ts index 38378c9042..971cf25aa0 100644 --- a/src/components/map/ha-locations-editor.ts +++ b/src/components/map/ha-locations-editor.ts @@ -3,7 +3,6 @@ import type { DivIcon, DragEndEvent, LatLng, - LatLngExpression, Marker, MarkerOptions, } from "leaflet"; @@ -13,6 +12,7 @@ import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map"; +import type { MapLatLng } from "../../common/map/map-engine"; import type { ThemeMode } from "../../types"; import "../ha-input-helper-text"; import "./ha-map"; @@ -87,7 +87,7 @@ export class HaLocationsEditor extends LitElement { } public fitBounds( - boundingbox: LatLngExpression[], + boundingbox: MapLatLng[], options?: { zoom?: number; pad?: number } ) { this.map.fitBounds(boundingbox, options); @@ -126,6 +126,7 @@ export class HaLocationsEditor extends LitElement { protected render(): TemplateResult { return html` typeof entity === "string" ? entity : entity.entity_id; export interface HaMapPathPoint { - point: LatLngTuple; + point: MapLatLng; timestamp: Date; } export interface HaMapPaths { @@ -96,6 +92,8 @@ export interface HaMapEntity { focus?: boolean; } +const CLUSTER_RADIUS = 40; + @customElement("ha-map") export class HaMap extends ReactiveElement { @state() @@ -129,8 +127,18 @@ export class HaMap extends ReactiveElement { @property({ attribute: false }) public paths?: HaMapPaths[]; + /** + * Raw Leaflet layers, for ha-locations-editor only. Requires + * engine="leaflet". + */ @property({ attribute: false }) public layers?: Layer[]; + /** + * Which map engine to use. "leaflet" is required by embedders that manage + * raw Leaflet layers (ha-locations-editor); "auto" picks the best engine. + */ + @property() public engine: "auto" | "leaflet" = "auto"; + @property({ type: Boolean }) public clickable = false; @property({ attribute: "auto-fit", type: Boolean }) public autoFit = false; @@ -158,27 +166,25 @@ export class HaMap extends ReactiveElement { @query("#map") private _mapElement?: HTMLElement; - public leafletMap?: Map; + private _engine?: MapEngine; - private Leaflet?: LeafletModuleType; - - private _baseLayer?: MapBaseLayer; + /** Escape hatch for ha-locations-editor; only set with engine="leaflet" */ + public get leafletMap() { + // Only the Leaflet engine has this property + return (this._engine as Partial | undefined)?.leafletMap; + } private _resizeObserver?: ResizeObserver; - private _mapItems: (Marker | Circle)[] = []; + private _entityHandles: MapMarkerHandle[] = []; - private _mapFocusItems: (Marker | Circle)[] = []; + private _zoneHandles: MapItemHandle[] = []; - private _mapZones: DecoratedMarker[] = []; + private _pathHandles: MapItemHandle[] = []; - private _mapFocusZones: (Marker | Circle)[] = []; + private _focusPoints: MapLatLng[] = []; - private _mapCluster: MarkerClusterGroup | undefined; - - private _scaleRulerControl?: Control.Scale; - - private _mapPaths: (Polyline | CircleMarker)[] = []; + private _focusZonePoints: MapLatLng[] = []; private _clickCount = 0; @@ -211,16 +217,21 @@ export class HaMap extends ReactiveElement { "visibilitychange", this._handleVisibilityChange ); - if (this.leafletMap) { - this.leafletMap.remove(); - this.leafletMap = undefined; - this.Leaflet = undefined; - this._baseLayer = undefined; - } + this._engine?.destroy(); + this._engine = undefined; + // An engine still setting up goes too; its setup notices and stops + this._setupAttempt++; + this._startingEngine?.destroy(); + this._startingEngine = undefined; + this._loading = false; + this._entityHandles = []; + this._zoneHandles = []; + this._pathHandles = []; + this._focusPoints = []; + this._focusZonePoints = []; - // the control went away with the map, so don't hold on to it - this._scaleRulerControl = undefined; this._pendingFit = undefined; + this._hasFitted = false; this._loaded = false; if (this._resizeObserver) { @@ -281,11 +292,9 @@ export class HaMap extends ReactiveElement { } if (changedProps.has("zoom")) { - this._isProgrammaticFit = true; - this.leafletMap!.setZoom(this.zoom); - setTimeout(() => { - this._isProgrammaticFit = false; - }, PROGRAMMITIC_FIT_DELAY); + this._withProgrammaticFit(() => { + this._engine!.setZoom(this.zoom); + }); } const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined; @@ -313,76 +322,167 @@ export class HaMap extends ReactiveElement { map.classList.toggle("dark", this._darkMode); map.classList.toggle("forced-dark", this.themeMode === "dark"); map.classList.toggle("forced-light", this.themeMode === "light"); - this._baseLayer?.setDarkMode(this._darkMode); + this._engine?.setDarkMode(this._darkMode); } private _loading = false; - private async _loadMap(): Promise { - if (this._loading) return; - let map = this.shadowRoot!.getElementById("map"); - if (!map) { - map = document.createElement("div"); - map.id = "map"; - this.shadowRoot!.append(map); + private _forceLeaflet = false; + + // The engine being set up, so a disconnect can tear it down mid-init + private _startingEngine?: MapEngine; + + private _setupAttempt = 0; + + // Each engine is its own chunk; a map only downloads the one it uses + private async _createEngine(): Promise { + if (this.engine === "leaflet" || this._forceLeaflet || !supportsWebGL2()) { + const leaflet = + await import("../../common/map/engines/leaflet-map-engine"); + return new leaflet.LeafletMapEngine(); } - this._loading = true; + const maplibre = + await import("../../common/map/engines/maplibre-map-engine"); + return new maplibre.MapLibreMapEngine(); + } + + // An engine that cannot start hands over to the Leaflet fallback + private async _loadMap(): Promise { + const onFallback = this._forceLeaflet || !supportsWebGL2(); try { - // The tiles are proxied by core behind a token, so nothing loads without - // one. A host that provides no connection, or a backend without the - // proxy, leaves the map without tiles rather than failing to set up. + await this._setUpEngine(); + } catch (err) { + if (!this.isConnected) { + return; + } + if (onFallback) { + // Already on the fallback; nothing left to try + throw err; + } + this._forceLeaflet = true; + await this._loadMap(); + } + } + + private async _setUpEngine(): Promise { + if (this._loading) return; + // A fresh container per engine; engines leave state on the element they used + this.shadowRoot!.getElementById("map")?.remove(); + const map = document.createElement("div"); + map.id = "map"; + this.shadowRoot!.append(map); + this._loading = true; + const attempt = ++this._setupAttempt; + let engine: MapEngine | undefined; + try { + // Without a connection or the tile proxy the map sets up without tiles const token = this._connection ? await ensureMapTilesToken(this._connection.connection) : undefined; - const setup = await setupLeafletMap(map, { - latitude: this._config?.latitude ?? 52.3731339, - longitude: this._config?.longitude ?? 4.8903147, + const rasterOnly = this._forceLeaflet; + engine = await this._createEngine(); + if (attempt !== this._setupAttempt) { + return; + } + this._startingEngine = engine; + await engine.init(map, { + center: [ + this._config?.latitude ?? 52.3731339, + this._config?.longitude ?? 4.8903147, + ], zoom: this.zoom, darkMode: this._darkMode, token, + rasterOnly: this._forceLeaflet, + zoomControlPosition: "topleft", + events: { + click: (location) => this._handleEngineClick(location), + zoomStart: () => { + if (!this._isProgrammaticFit) { + this._pauseAutoFit = true; + } + }, + moveStart: () => { + if (!this._isProgrammaticFit) { + this._pauseAutoFit = true; + } + }, + fatal: () => this._handleEngineFatal(), + }, }); - // Setting up fetches a style, so the element can be gone by now. - // `disconnectedCallback` had no map to tear down, and keeping this one - // would leave a live map - and its WebGL context - on a detached host, - // and its container too initialized to set up again on reconnect. - if (!this.isConnected) { - setup.map.remove(); + // Disconnected while the style was loading, or superseded by a newer setup + if (!this.isConnected || attempt !== this._setupAttempt) { return; } - this.leafletMap = setup.map; - this.Leaflet = setup.leaflet; - this._baseLayer = setup.baseLayer; + // A fatal event during setup asked for the fallback; _loadMap retries on it + if (this._forceLeaflet && !rasterOnly) { + throw new Error("Map engine failed during setup"); + } + this._engine = engine; this._updateMapStyle(); - this.leafletMap.on("click", (ev) => { - if (this._clickCount === 0) { - setTimeout(() => { - if (this._clickCount === 1) { - fireEvent(this, "map-clicked", { - location: [ev.latlng.lat, ev.latlng.lng], - }); - } - this._clickCount = 0; - }, 250); - } - this._clickCount++; - }); - this.leafletMap.on("zoomstart", () => { - if (!this._isProgrammaticFit) { - this._pauseAutoFit = true; - } - }); - this.leafletMap.on("movestart", () => { - if (!this._isProgrammaticFit) { - this._pauseAutoFit = true; - } - }); this._loaded = true; } finally { - this._loading = false; + if (attempt === this._setupAttempt) { + this._loading = false; + this._startingEngine = undefined; + } + // An engine that did not make it may already hold a map and a WebGL context + if (engine && engine !== this._engine) { + engine.destroy(); + } } } + // Rebuild on the Leaflet fallback after a fatal engine failure + private _handleEngineFatal(): void { + if (this._forceLeaflet) { + return; + } + this._forceLeaflet = true; + if (this._loading) { + // Setup in flight: tearing its engine down settles a pending init, and + // the setup then hands over to the fallback + this._startingEngine?.destroy(); + return; + } + this._engine?.destroy(); + this._engine = undefined; + this._entityHandles = []; + this._zoneHandles = []; + this._pathHandles = []; + this._focusPoints = []; + this._focusZonePoints = []; + this._pendingFit = undefined; + this._hasFitted = false; + this._loaded = false; + this._loadMap(); + } + + private _handleEngineClick(location: MapLatLng): void { + // Fire only for single clicks, not for the two of a double-click zoom + if (this._clickCount === 0) { + setTimeout(() => { + if (this._clickCount === 1) { + fireEvent(this, "map-clicked", { location }); + } + this._clickCount = 0; + }, 250); + } + this._clickCount++; + } + + // The first fit after load jumps to the content; later fits animate + private _hasFitted = false; + + private _withProgrammaticFit(fit: () => void): void { + this._isProgrammaticFit = true; + fit(); + setTimeout(() => { + this._isProgrammaticFit = false; + }, PROGRAMMITIC_FIT_DELAY); + } + public fitMap(options?: { zoom?: number; pad?: number; @@ -391,7 +491,7 @@ export class HaMap extends ReactiveElement { if (options?.unpause_autofit) { this._pauseAutoFit = false; } - if (!this.leafletMap || !this.Leaflet || !this._config) { + if (!this._engine || !this._config) { return; } @@ -400,60 +500,53 @@ export class HaMap extends ReactiveElement { } if ( - !this._mapFocusItems.length && - !this._mapFocusZones.length && + !this._focusPoints.length && + !this._focusZonePoints.length && !this.layers?.length ) { - this._isProgrammaticFit = true; - this.leafletMap.setView( - new this.Leaflet.LatLng(this._config.latitude, this._config.longitude), - options?.zoom || this.zoom - ); - setTimeout(() => { - this._isProgrammaticFit = false; - }, PROGRAMMITIC_FIT_DELAY); + this._withProgrammaticFit(() => { + this._engine!.setView( + [this._config.latitude, this._config.longitude], + options?.zoom || this.zoom + ); + }); + this._hasFitted = true; return; } - let bounds = this.Leaflet.latLngBounds( - this._mapFocusItems - ? this._mapFocusItems.map((item) => item.getLatLng()) - : [] - ); + const points = [...this._focusPoints, ...this._focusZonePoints]; - this._mapFocusZones?.forEach((zone) => { - bounds.extend("getBounds" in zone ? zone.getBounds() : zone.getLatLng()); + // Raw Leaflet layers (ha-locations-editor) contribute their bounds + const leafletMap = this.leafletMap; + if (this.layers?.length && leafletMap) { + this.layers.forEach((layer: any) => { + if ("getBounds" in layer) { + const layerBounds = layer.getBounds(); + points.push( + [layerBounds.getSouth(), layerBounds.getWest()], + [layerBounds.getNorth(), layerBounds.getEast()] + ); + } else if ("getLatLng" in layer) { + const latLng = layer.getLatLng(); + points.push([latLng.lat, latLng.lng]); + } + }); + } + + this._withProgrammaticFit(() => { + this._engine!.fitBounds(points, { + maxZoom: options?.zoom || this.zoom, + pad: options?.pad ?? 0.5, + animate: this._hasFitted, + }); }); - - this.layers?.forEach((layer: any) => { - bounds.extend( - "getBounds" in layer ? layer.getBounds() : layer.getLatLng() - ); - }); - - bounds = bounds.pad(options?.pad ?? 0.5); - this._isProgrammaticFit = true; - this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom }); - setTimeout(() => { - this._isProgrammaticFit = false; - }, PROGRAMMITIC_FIT_DELAY); + this._hasFitted = true; } - // Leaflet derives the zoom level that fits given bounds from the current - // size of the map container. When the container has not been laid out yet, - // that size is 0x0 and the computed zoom collapses to the minimum, leaving - // the map zoomed out to the world even after the container gets its size. - // Defer fitting until the resize observer reports a usable size. + // Fitting uses the container size; before layout it is 0x0 and the zoom + // collapses to the minimum, so defer until the resize observer reports one private _deferIfUnsized(fit: () => void): boolean { - const size = this.leafletMap!.getSize(); - if (size.x > 0 && size.y > 0) { - this._pendingFit = undefined; - return false; - } - const container = this.leafletMap!.getContainer(); - if (container.clientWidth > 0 && container.clientHeight > 0) { - // The container was laid out since Leaflet last measured it. - this.leafletMap!.invalidateSize(false); + if (this._engine!.hasUsableSize()) { this._pendingFit = undefined; return false; } @@ -462,11 +555,10 @@ export class HaMap extends ReactiveElement { } private _runPendingFit(): void { - if (!this._pendingFit || !this.leafletMap) { + if (!this._pendingFit || !this._engine) { return; } - const size = this.leafletMap.getSize(); - if (size.x > 0 && size.y > 0) { + if (this._engine.hasUsableSize()) { const pendingFit = this._pendingFit; this._pendingFit = undefined; pendingFit(); @@ -474,23 +566,23 @@ export class HaMap extends ReactiveElement { } public fitBounds( - boundingbox: LatLngExpression[], + boundingbox: MapLatLng[], options?: { zoom?: number; pad?: number } ) { - if (!this.leafletMap || !this.Leaflet) { + if (!this._engine) { return; } if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) { return; } - const bounds = this.Leaflet.latLngBounds(boundingbox).pad( - options?.pad ?? 0.5 - ); - this._isProgrammaticFit = true; - this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom }); - setTimeout(() => { - this._isProgrammaticFit = false; - }, PROGRAMMITIC_FIT_DELAY); + this._withProgrammaticFit(() => { + this._engine!.fitBounds(boundingbox, { + maxZoom: options?.zoom || this.zoom, + pad: options?.pad ?? 0.5, + animate: this._hasFitted, + }); + }); + this._hasFitted = true; } private _drawLayers(prevLayers: Layer[] | undefined): void { @@ -500,9 +592,12 @@ export class HaMap extends ReactiveElement { if (!this.layers) { return; } - const map = this.leafletMap!; + const leafletMap = this.leafletMap; + if (!leafletMap) { + return; + } this.layers.forEach((layer) => { - map.addLayer(layer); + leafletMap.addLayer(layer); }); } @@ -531,16 +626,12 @@ export class HaMap extends ReactiveElement { } private _drawPaths(): void { - const map = this.leafletMap; - // eslint-disable-next-line @typescript-eslint/naming-convention - const Leaflet = this.Leaflet; - - if (!this._i18n || !this._config || !map || !Leaflet) { + if (!this._i18n || !this._config || !this._engine) { return; } - if (this._mapPaths.length) { - this._mapPaths.forEach((marker) => marker.remove()); - this._mapPaths = []; + if (this._pathHandles.length) { + this._pathHandles.forEach((handle) => handle.remove()); + this._pathHandles = []; } if (!this.paths) { return; @@ -558,6 +649,9 @@ export class HaMap extends ReactiveElement { baseOpacity = 1 - path.gradualOpacity; } + const segments: MapPathSegment[] = []; + const markers: MapPathMarker[] = []; + for ( let pointIndex = 0; pointIndex < path.points.length - 1; @@ -570,30 +664,19 @@ export class HaMap extends ReactiveElement { const thisPoint = path.points[pointIndex]; const nextPoint = path.points[pointIndex + 1]; - // DRAW point - this._mapPaths.push( - Leaflet.circleMarker(thisPoint.point, { - radius: isTouch ? 8 : 3, - color: path.color || darkPrimaryColor, - opacity, - fillOpacity: opacity, - interactive: true, - }).bindTooltip(this._computePathTooltip(path, thisPoint), { - direction: "top", - }) - ); + markers.push({ + location: thisPoint.point, + opacity, + tooltipHtml: this._computePathTooltip(path, thisPoint), + }); - // DRAW line between this and next point if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) { // if the path does not cross the antimeridian, draw a simple line // between the two points - this._mapPaths.push( - Leaflet.polyline([thisPoint.point, nextPoint.point], { - color: path.color || darkPrimaryColor, - opacity, - interactive: false, - }) - ); + segments.push({ + points: [thisPoint.point, nextPoint.point], + opacity, + }); } else { // if the path crosses the antimeridian, split the line into two, to // avoid it being drawn across the entire map @@ -614,29 +697,23 @@ export class HaMap extends ReactiveElement { longitudeDifference; } - const intersectionPoint1: LatLngTuple = [ + const intersectionPoint1: MapLatLng = [ intersectionLatitude, thisPoint.point[1] > 0 ? 180 : -180, ]; - const intersectionPoint2: LatLngTuple = [ + const intersectionPoint2: MapLatLng = [ intersectionLatitude, nextPoint.point[1] > 0 ? 180 : -180, ]; - this._mapPaths.push( - Leaflet.polyline([thisPoint.point, intersectionPoint1], { - color: path.color || darkPrimaryColor, - opacity, - interactive: false, - }) - ); - this._mapPaths.push( - Leaflet.polyline([intersectionPoint2, nextPoint.point], { - color: path.color || darkPrimaryColor, - opacity, - interactive: false, - }) - ); + segments.push({ + points: [thisPoint.point, intersectionPoint1], + opacity, + }); + segments.push({ + points: [intersectionPoint2, nextPoint.point], + opacity, + }); } } const pointIndex = path.points.length - 1; @@ -644,52 +721,40 @@ export class HaMap extends ReactiveElement { const opacity = path.gradualOpacity ? baseOpacity! + pointIndex * opacityStep! : undefined; - // DRAW end path point - this._mapPaths.push( - Leaflet.circleMarker(path.points[pointIndex].point, { - radius: isTouch ? 8 : 3, - color: path.color || darkPrimaryColor, - opacity, - fillOpacity: opacity, - interactive: true, - }).bindTooltip( - this._computePathTooltip(path, path.points[pointIndex]), - { direction: "top" } - ) - ); + markers.push({ + location: path.points[pointIndex].point, + opacity, + tooltipHtml: this._computePathTooltip(path, path.points[pointIndex]), + }); } - this._mapPaths.forEach((marker) => map.addLayer(marker)); + + const enginePath: MapPath = { + color: path.color || darkPrimaryColor, + segments, + markers, + }; + this._pathHandles.push(this._engine!.addPath(enginePath)); }); } private _drawEntities(): void { const states = this._states; - const map = this.leafletMap; - // eslint-disable-next-line @typescript-eslint/naming-convention - const Leaflet = this.Leaflet; + const engine = this._engine; - if (!states || !map || !Leaflet) { + if (!states || !engine) { return; } - if (this._mapItems.length) { - this._mapItems.forEach((marker) => marker.remove()); - this._mapItems = []; - this._mapFocusItems = []; - } + this._entityHandles.forEach((handle) => handle.remove()); + this._entityHandles = []; + this._focusPoints = []; - if (this._mapZones.length) { - this._mapZones.forEach((marker) => marker.remove()); - this._mapZones = []; - this._mapFocusZones = []; - } - - if (this._mapCluster) { - this._mapCluster.remove(); - this._mapCluster = undefined; - } + this._zoneHandles.forEach((handle) => handle.remove()); + this._zoneHandles = []; + this._focusZonePoints = []; if (!this.entities) { + engine.setClustering(null); return; } @@ -703,8 +768,6 @@ export class HaMap extends ReactiveElement { "--dark-primary-color" ); - const className = this._darkMode ? "dark" : "light"; - for (const entity of this.entities) { const stateObj = states[getEntityId(entity)]; if (!stateObj) { @@ -724,6 +787,7 @@ export class HaMap extends ReactiveElement { continue; } const { latitude, longitude, gpsAccuracy } = location; + const position: MapLatLng = [latitude, longitude]; if (computeStateDomain(stateObj) === "zone") { // DRAW ZONE @@ -731,42 +795,61 @@ export class HaMap extends ReactiveElement { continue; } + const zoneMarkerColor = passive ? passiveZoneColor : zoneColor; + + if (radius) { + this._zoneHandles.push( + engine.addCircle(position, { radius, color: zoneMarkerColor }) + ); + } + // create icon - let iconHTML: string; + const iconEl = document.createElement("div"); + iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`; if (icon) { const el = document.createElement("ha-icon"); el.setAttribute("icon", icon); - iconHTML = el.outerHTML; + iconEl.appendChild(el); } else { const el = document.createElement("span"); el.textContent = title; - iconHTML = el.outerHTML; + iconEl.appendChild(el); } - // create circle around it - const circle = Leaflet.circle([latitude, longitude], { - interactive: false, - color: passive ? passiveZoneColor : zoneColor, - radius, - }); + if (this.interactiveZones) { + const openMoreInfo = (ev: Event) => { + ev.stopPropagation(); + fireEvent(this, "hass-more-info", { + entityId: stateObj.entity_id, + }); + }; + iconEl.addEventListener("click", openMoreInfo); + iconEl.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + openMoreInfo(ev); + } + }); + } - const markerIconSize = this._getMarkerSize(computedStyles) / 2; - const marker = new DecoratedMarker([latitude, longitude], circle, { - icon: Leaflet.divIcon({ - html: iconHTML, - iconSize: [markerIconSize, markerIconSize], - className, - }), - interactive: this.interactiveZones, - title, - }); + const zoneIconSize = this._getMarkerSize(computedStyles) / 2; + this._zoneHandles.push( + engine.addMarker(iconEl, position, { + size: [zoneIconSize, zoneIconSize], + interactive: this.interactiveZones, + title, + }) + ); - this._mapZones.push(marker); if ( this.fitZones && (typeof entity === "string" || entity.focus !== false) ) { - this._mapFocusZones.push(circle); + if (radius) { + this._focusZonePoints.push(...circleBoundsPoints(position, radius)); + } else { + this._focusZonePoints.push(position); + } } continue; @@ -810,64 +893,50 @@ export class HaMap extends ReactiveElement { entityMarker.entityColor = entity.color; } - // create marker with the icon const markerSize = this._getMarkerSize(computedStyles); - const marker = new DecoratedMarker([latitude, longitude], undefined, { - icon: Leaflet.divIcon({ - html: entityMarker, - iconSize: [markerSize, markerSize], - className: "", - }), - title: title, - }); + this._entityHandles.push( + engine.addMarker(entityMarker, position, { + size: [markerSize, markerSize], + title, + cluster: true, + // create circle around if entity has accuracy + decoration: gpsAccuracy + ? { radius: gpsAccuracy, color: darkPrimaryColor } + : undefined, + }) + ); + if (typeof entity === "string" || entity.focus !== false) { - this._mapFocusItems.push(marker); + this._focusPoints.push(position); } - - // create circle around if entity has accuracy - if (gpsAccuracy) { - marker.decorationLayer = Leaflet.circle([latitude, longitude], { - interactive: false, - color: darkPrimaryColor, - radius: gpsAccuracy, - }); - } - - this._mapItems.push(marker); } - if (this.clusterMarkers) { - this._mapCluster = Leaflet.markerClusterGroup({ - showCoverageOnHover: false, - removeOutsideVisibleBounds: false, - maxClusterRadius: 40, - }); - this._mapCluster.addLayers(this._mapItems); - map.addLayer(this._mapCluster); - } else { - this._mapItems.forEach((marker) => map.addLayer(marker)); - } - - this._mapZones.forEach((marker) => map.addLayer(marker)); + engine.setClustering( + this.clusterMarkers + ? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon } + : null + ); } + // Renders a marker cluster as a circle with the member count + private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => { + const size = Math.round( + this._getMarkerSize(getComputedStyle(this)) * (2 / 3) + ); + const element = document.createElement("div"); + element.className = "marker-cluster"; + const count = document.createElement("span"); + count.textContent = String(members.length); + element.appendChild(count); + return { element, size: [size, size] }; + }; + private _drawScaleRuler(): void { - if (this._scaleRulerControl) { - this.leafletMap?.removeControl(this._scaleRulerControl); - this._scaleRulerControl = undefined; - } - - if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) { - return; - } - - const metric = this._config?.unit_system?.length === UNIT_KM; - this._scaleRulerControl = this.Leaflet.control.scale({ - position: "bottomleft", - metric, - imperial: !metric, - }); - this._scaleRulerControl.addTo(this.leafletMap); + this._engine?.setScaleRuler( + this.scaleRuler + ? { metric: this._config?.unit_system?.length === UNIT_KM } + : null + ); } private _getMarkerSize(computedStyles: CSSStyleDeclaration): number { @@ -880,7 +949,7 @@ export class HaMap extends ReactiveElement { private async _attachObserver(): Promise { if (!this._resizeObserver) { this._resizeObserver = new ResizeObserver(() => { - this.leafletMap?.invalidateSize({ debounceMoveend: true }); + this._engine?.invalidateSize(); this._runPendingFit(); }); } @@ -915,13 +984,39 @@ export class HaMap extends ReactiveElement { #map:active { cursor: grabbing; } + /* A cluster opened at its spot: the members in a bubble with a tail */ + .cluster-open { + display: flex; + flex-direction: column; + align-items: center; + } + .cluster-open-members { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; + padding: 6px; + /* Six markers per row */ + max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px); + background: var(--card-background-color, #fff); + border-radius: 14px; + box-shadow: var(--ha-box-shadow-s); + } + .cluster-open-tail { + width: 10px; + height: 10px; + margin-top: -5px; + border-radius: 2px; + background: var(--card-background-color, #fff); + transform: rotate(45deg); + } /* Only the raster fallback is inverted for dark mode, the vector style ships its own dark cartography. */ .leaflet-tile-pane .leaflet-tile { filter: var(--map-filter); } - /* The only two rules the MapLibre canvas needs from its stylesheet, the - rest of it styles controls and popups we do not render. */ + /* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre + stylesheet; these are the only two rules its canvas needs. */ .maplibregl-map { position: relative; overflow: hidden; @@ -931,6 +1026,32 @@ export class HaMap extends ReactiveElement { top: 0; left: 0; } + .dark .maplibregl-ctrl.maplibregl-ctrl-group { + background-color: #1c1c1c; + } + .dark .maplibregl-ctrl-group button + button { + border-top-color: #313131; + } + .dark .maplibregl-ctrl button .maplibregl-ctrl-icon { + filter: invert(1); + } + /* MapLibre's stylesheet, linked into this root, wins on equal specificity */ + .maplibregl-popup-content { + padding: 8px !important; + font-size: var(--ha-font-size-s); + font-family: var(--ha-font-family-body); + background: rgba(80, 80, 80, 0.9) !important; + color: white !important; + border-radius: var(--ha-border-radius-sm) !important; + box-shadow: none !important; + text-align: center; + } + .maplibregl-popup-anchor-bottom .maplibregl-popup-tip { + border-top-color: rgba(80, 80, 80, 0.9) !important; + } + .maplibregl-popup-anchor-top .maplibregl-popup-tip { + border-bottom-color: rgba(80, 80, 80, 0.9) !important; + } .dark .leaflet-bar a { background-color: #1c1c1c; color: #ffffff; @@ -956,6 +1077,17 @@ export class HaMap extends ReactiveElement { .leaflet-pane { z-index: 0 !important; } + /* Zone icons, sized like the Leaflet divIcon they replaced */ + .zone-icon { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: var(--primary-text-color); + } + .zone-icon.dark { + color: #ffffff; + } .leaflet-control, .leaflet-top, .leaflet-bottom { @@ -1006,22 +1138,18 @@ export class HaMap extends ReactiveElement { --mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2); } - .marker-cluster div { + .marker-cluster { + box-sizing: border-box; background-clip: padding-box; background-color: var(--primary-color); border: 3px solid rgba(var(--rgb-primary-color), 0.2); - width: calc(var(--ha-marker-size, 48px) * 0.667); - height: calc(var(--ha-marker-size, 48px) * 0.667); border-radius: 50%; - text-align: center; - align-content: center; + display: flex; + align-items: center; + justify-content: center; color: var(--text-primary-color); font-size: var(--ha-font-size-m); } - - .marker-cluster span { - line-height: var(--ha-line-height-expanded); - } `; } diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index cb17758999..a3a25fc38f 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -4,7 +4,6 @@ import { mdiImageFilterCenterFocus, } from "@mdi/js"; import type { HassEntities } from "home-assistant-js-websocket"; -import type { LatLngTuple } from "leaflet"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; @@ -29,6 +28,7 @@ import type { HaMapPaths, MapCardMarkerLabelMode, } from "../../../components/map/ha-map"; +import type { MapLatLng } from "../../../common/map/map-engine"; import type { HistoryStates } from "../../../data/history"; import { subscribeHistoryStatesTimeWindow } from "../../../data/history"; import type { HomeAssistant } from "../../../types"; @@ -528,7 +528,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { continue; } const p = {} as HaMapPathPoint; - p.point = [latitude, longitude] as LatLngTuple; + p.point = [latitude, longitude] as MapLatLng; p.timestamp = new Date(entityState.lu * 1000); points.push(p); } diff --git a/test/common/map/base-layer.test.ts b/test/common/map/base-layer.test.ts index 7acc17f5ca..44a291b734 100644 --- a/test/common/map/base-layer.test.ts +++ b/test/common/map/base-layer.test.ts @@ -167,6 +167,15 @@ describe("createBaseLayer", () => { expect(leaflet.tileLayer).not.toHaveBeenCalled(); }); + it("renders raster tiles when asked to, even with WebGL2", async () => { + const createBaseLayer = await setWebGL2(true); + + await createBaseLayer(leaflet, map, false, TOKEN, true); + + expect(isRaster()).toBe(true); + expect(maplibreGL).not.toHaveBeenCalled(); + }); + it("falls back to raster tiles when the style cannot be fetched", async () => { const createBaseLayer = await setWebGL2(true); vi.stubGlobal( diff --git a/test/common/map/maplibre-map-engine.test.ts b/test/common/map/maplibre-map-engine.test.ts new file mode 100644 index 0000000000..6d2034666a --- /dev/null +++ b/test/common/map/maplibre-map-engine.test.ts @@ -0,0 +1,789 @@ +import type { LayerSpecification, StyleSpecification } from "maplibre-gl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CONTEXT_RESTORE_GRACE } from "../../../src/common/map/base-layer"; +import { MapLibreMapEngine } from "../../../src/common/map/engines/maplibre-map-engine"; +import type { + MapEngineEvents, + MapLatLng, + MapMarkerHandle, +} from "../../../src/common/map/map-engine"; + +// The MapLibre engine cannot run in jsdom (no WebGL2), so maplibre-gl is +// replaced by a fake map that models the parts the engine depends on: the +// style lifecycle (a swap leaves the style unloaded until it loads), sources +// and layers, screen projection for clustering, and WebGL context events. + +type Listener = (ev?: any) => void; + +const fakes = vi.hoisted(() => { + const baseStyle = (): StyleSpecification => ({ + version: 8, + sources: {}, + layers: [ + { id: "land", type: "background" }, + { id: "labels", type: "symbol", source: "osm", "source-layer": "place" }, + ], + }); + + class FakeMarker { + static all: FakeMarker[] = []; + + lngLat?: [number, number]; + + options: any; + + onMap = false; + + constructor(options: any) { + this.options = options; + } + + setLngLat(lngLat: [number, number]) { + this.lngLat = lngLat; + return this; + } + + getLngLat() { + return { lng: this.lngLat![0], lat: this.lngLat![1] }; + } + + addTo() { + this.onMap = true; + FakeMarker.all.push(this); + document.body.appendChild(this.options.element); + return this; + } + + remove() { + this.onMap = false; + const index = FakeMarker.all.indexOf(this); + if (index !== -1) { + FakeMarker.all.splice(index, 1); + } + (this.options.element as HTMLElement).remove(); + return this; + } + + getElement() { + return this.options.element as HTMLElement; + } + + on() { + return this; + } + } + + class FakePopup { + setLngLat() { + return this; + } + + setHTML() { + return this; + } + + addTo() { + return this; + } + + remove() { + return this; + } + } + + class FakeMap { + static instances: FakeMap[] = []; + + /** Whether new maps start with their style loaded */ + static startLoaded = true; + + /** Makes the next setStyle call throw, like an invalid style would */ + static failNextSetStyle = false; + + style: StyleSpecification; + + styleLoaded: boolean; + + listeners: Record = {}; + + fitBounds = vi.fn(); + + easeTo = vi.fn(); + + jumpTo = vi.fn(); + + resize = vi.fn(() => { + this.fire("movestart"); + this.fire("move"); + this.fire("moveend"); + }); + + remove = vi.fn(); + + addControl = vi.fn(); + + removeControl = vi.fn(); + + setStyle = vi.fn( + ( + style: StyleSpecification, + options?: { + transformStyle?: ( + previous: StyleSpecification | undefined, + next: StyleSpecification + ) => StyleSpecification; + } + ) => { + if (FakeMap.failNextSetStyle) { + FakeMap.failNextSetStyle = false; + throw new Error("Invalid style"); + } + // MapLibre hands over the current style only once it has loaded + this.style = options?.transformStyle + ? options.transformStyle( + this.styleLoaded ? this.style : undefined, + style + ) + : style; + // A rebuilt style is unloaded until the next frame + this.styleLoaded = false; + } + ); + + touchZoomRotate = { disableRotation: vi.fn() }; + + keyboard = { disableRotation: vi.fn() }; + + scrollZoom = { setWheelZoomRate: vi.fn() }; + + private _container: HTMLElement; + + constructor(options: { + container: HTMLElement; + style: StyleSpecification; + }) { + this._container = options.container; + this.style = options.style; + this.styleLoaded = FakeMap.startLoaded; + FakeMap.instances.push(this); + } + + /** The style finished loading, as MapLibre reports a frame later */ + loadStyle() { + this.styleLoaded = true; + this.fire("style.load"); + this.fire("styledata"); + } + + on(type: string, layerOrHandler: string | Listener, handler?: Listener) { + const entry = + typeof layerOrHandler === "string" + ? { layer: layerOrHandler, handler: handler! } + : { handler: layerOrHandler }; + (this.listeners[type] ??= []).push(entry); + return this; + } + + once(type: string, handler: Listener) { + const wrapped: Listener = (ev) => { + this.off(type, wrapped); + handler(ev); + }; + return this.on(type, wrapped); + } + + off(type: string, layerOrHandler: string | Listener, handler?: Listener) { + const target = + typeof layerOrHandler === "string" ? handler : layerOrHandler; + this.listeners[type] = (this.listeners[type] ?? []).filter( + (entry) => entry.handler !== target + ); + return this; + } + + fire(type: string, ev: any = {}) { + [...(this.listeners[type] ?? [])].forEach((entry) => entry.handler(ev)); + } + + isStyleLoaded() { + return this.styleLoaded; + } + + // Like MapLibre: Style.serialize() returns undefined until the style has + // loaded, and getStyle() is that serialization + getStyle() { + return this.styleLoaded ? this.style : undefined; + } + + private _assertLoaded() { + if (!this.styleLoaded) { + throw new Error("Style is not done loading"); + } + } + + addSource(id: string, source: any) { + this._assertLoaded(); + this.style.sources[id] = source; + } + + getSource(id: string) { + return this.style.sources[id]; + } + + removeSource(id: string) { + this._assertLoaded(); + delete this.style.sources[id]; + } + + addLayer(layer: LayerSpecification, beforeId?: string) { + this._assertLoaded(); + const index = beforeId + ? this.style.layers.findIndex((candidate) => candidate.id === beforeId) + : -1; + this.style.layers.splice( + index === -1 ? this.style.layers.length : index, + 0, + layer + ); + } + + getLayer(id: string) { + return this.style.layers.find((layer) => layer.id === id); + } + + removeLayer(id: string) { + this._assertLoaded(); + this.style.layers = this.style.layers.filter((layer) => layer.id !== id); + } + + // 0.001 degrees is 10 screen pixels + project([lng, lat]: [number, number]) { + return { x: lng * 10000, y: -lat * 10000 }; + } + + zoom = 12; + + getZoom() { + return this.zoom; + } + + getMaxZoom() { + return 19; + } + + getContainer() { + return this._container; + } + + getCanvas() { + return { style: {} as CSSStyleDeclaration }; + } + + getBounds() { + return { contains: () => true }; + } + + queryRenderedFeatures() { + return []; + } + } + + return { baseStyle, FakeMap, FakeMarker, FakePopup }; +}); + +vi.mock("maplibre-gl", () => ({ + default: { + Map: fakes.FakeMap, + Marker: fakes.FakeMarker, + Popup: fakes.FakePopup, + NavigationControl: vi.fn(), + ScaleControl: vi.fn(), + setRTLTextPlugin: vi.fn(), + }, +})); + +const loadStyle = vi.hoisted(() => vi.fn()); +vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({ + ...(await importOriginal>()), + loadStyle, + ensureRTLTextPlugin: vi.fn(), +})); + +const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>()); +const refreshMapTilesToken = vi.hoisted(() => vi.fn()); +vi.mock("../../../src/data/map_tiles", () => ({ + MAP_TILES_PATH: "/api/map_tiles", + mapTilesUrl: (path: string) => path, + withMapTilesToken: (url: string) => url, + refreshMapTilesToken, + subscribeMapTilesToken: (listener: (token: string) => void) => { + tokenListeners.add(listener); + return () => tokenListeners.delete(listener); + }, +})); + +const fakeMap = fakes.FakeMap; +const fakeMarker = fakes.FakeMarker; +const { baseStyle } = fakes; + +const flush = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +const createEngine = async (events: Partial = {}) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const engine = new MapLibreMapEngine(); + const ready = engine.init(container, { + center: [52, 4], + zoom: 13, + darkMode: false, + zoomControlPosition: "topleft", + events, + }); + await vi.waitUntil(() => fakeMap.instances.length > 0); + const map = fakeMap.instances[fakeMap.instances.length - 1]; + return { engine, map, ready }; +}; + +const customSourceIds = (map: InstanceType) => + Object.keys(map.style.sources).filter((id) => id.startsWith("ha-")); + +const layerIds = (map: InstanceType) => + map.style.layers.map((layer) => layer.id); + +describe("MapLibreMapEngine", () => { + beforeEach(() => { + fakeMap.instances.length = 0; + fakeMap.startLoaded = true; + fakeMap.failNextSetStyle = false; + fakeMarker.all.length = 0; + tokenListeners.clear(); + refreshMapTilesToken.mockClear(); + loadStyle.mockReset(); + loadStyle.mockImplementation(async () => baseStyle()); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + describe("style lifecycle", () => { + it("waits for the initial style before resolving init", async () => { + fakeMap.startLoaded = false; + const { map, ready } = await createEngine(); + let resolved = false; + ready.then(() => { + resolved = true; + }); + + await flush(); + expect(resolved).toBe(false); + map.loadStyle(); + await ready; + expect(resolved).toBe(true); + }); + + it("settles a pending init when destroyed", async () => { + fakeMap.startLoaded = false; + const { engine, ready } = await createEngine(); + let resolved = false; + ready.then(() => { + resolved = true; + }); + + await flush(); + expect(resolved).toBe(false); + engine.destroy(); + await ready; + expect(resolved).toBe(true); + }); + + it("queues sources and layers while a swapped style is loading", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + + engine.setDarkMode(true); + await flush(); + expect(map.setStyle).toHaveBeenCalledOnce(); + expect(map.isStyleLoaded()).toBe(false); + + // Adding to an unloaded style would throw; the engine must hold it + const circle = engine.addCircle([52, 4], { radius: 100, color: "red" }); + expect(customSourceIds(map)).toHaveLength(0); + + map.loadStyle(); + expect(customSourceIds(map)).toHaveLength(1); + expect(layerIds(map)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/-fill$/), + expect.stringMatching(/-line$/), + ]) + ); + + circle.remove(); + expect(customSourceIds(map)).toHaveLength(0); + }); + + it("carries its own sources and layers over a style swap, under the labels", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + engine.addCircle([52, 4], { radius: 100, color: "red" }); + const customLayers = layerIds(map).filter((id) => id.startsWith("ha-")); + expect(customLayers).toHaveLength(2); + + engine.setDarkMode(true); + await flush(); + map.loadStyle(); + + // The new style is a fresh copy from loadStyle; the circle survived it + expect(customSourceIds(map)).toHaveLength(1); + const ids = layerIds(map); + for (const id of customLayers) { + expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("land")); + expect(ids.indexOf(id)).toBeLessThan(ids.indexOf("labels")); + } + }); + + it("keeps its sources and layers when a swap arrives while another is loading", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + engine.addCircle([52, 4], { radius: 100, color: "red" }); + + engine.setDarkMode(true); + await flush(); + // The dark style has not loaded, so this swap sees no previous style + engine.setDarkMode(false); + await flush(); + expect(map.setStyle).toHaveBeenCalledTimes(2); + + map.loadStyle(); + expect(customSourceIds(map)).toHaveLength(1); + expect(layerIds(map).filter((id) => id.startsWith("ha-"))).toHaveLength( + 2 + ); + }); + + it("does not swap twice for the same mode, and retries after a failed swap", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + expect(loadStyle).toHaveBeenCalledTimes(1); + + engine.setDarkMode(false); + await flush(); + expect(loadStyle).toHaveBeenCalledTimes(1); + + // The dark style fails to fetch: the map stays light and dark can be + // requested again + loadStyle.mockRejectedValueOnce(new Error("offline")); + engine.setDarkMode(true); + await flush(); + expect(map.setStyle).not.toHaveBeenCalled(); + + engine.setDarkMode(true); + await flush(); + expect(loadStyle).toHaveBeenCalledTimes(3); + expect(map.setStyle).toHaveBeenCalledOnce(); + }); + + it("does not record a mode whose style could not be applied", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + + fakeMap.failNextSetStyle = true; + engine.setDarkMode(true); + await flush(); + expect(map.setStyle).toHaveBeenCalledOnce(); + expect(map.isStyleLoaded()).toBe(true); + + // Dark was not applied, so asking for it again applies it + engine.setDarkMode(true); + await flush(); + expect(map.setStyle).toHaveBeenCalledTimes(2); + expect(map.isStyleLoaded()).toBe(false); + }); + + it("applies the style again once a refused token is replaced", async () => { + const { map, ready } = await createEngine(); + await ready; + + map.fire("error", { error: { status: 403 } }); + expect(refreshMapTilesToken).toHaveBeenCalledOnce(); + expect(loadStyle).toHaveBeenCalledTimes(1); + + tokenListeners.forEach((listener) => listener("new-token")); + await flush(); + expect(loadStyle).toHaveBeenCalledTimes(2); + expect(map.setStyle).toHaveBeenCalledOnce(); + }); + }); + + describe("WebGL context loss", () => { + it("reports a fatal failure when a lost context is not restored in time", async () => { + vi.useFakeTimers(); + const fatal = vi.fn(); + const { map, ready } = await createEngine({ fatal }); + await ready; + + map.fire("webglcontextlost"); + vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE - 1); + expect(fatal).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(fatal).toHaveBeenCalledOnce(); + }); + + it("stays on the engine when the context comes back within the grace period", async () => { + vi.useFakeTimers(); + const fatal = vi.fn(); + const { map, ready } = await createEngine({ fatal }); + await ready; + + map.fire("webglcontextlost"); + vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE / 2); + map.fire("webglcontextrestored"); + vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE); + expect(fatal).not.toHaveBeenCalled(); + }); + + it("does not report a failure after being destroyed", async () => { + vi.useFakeTimers(); + const fatal = vi.fn(); + const { engine, map, ready } = await createEngine({ fatal }); + await ready; + + map.fire("webglcontextlost"); + engine.destroy(); + vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE); + expect(fatal).not.toHaveBeenCalled(); + expect(map.remove).toHaveBeenCalledOnce(); + }); + }); + + describe("camera events", () => { + it("does not report a resize as the map moving", async () => { + const moveStart = vi.fn(); + const { engine, map, ready } = await createEngine({ moveStart }); + await ready; + + engine.invalidateSize(); + expect(map.resize).toHaveBeenCalledOnce(); + expect(moveStart).not.toHaveBeenCalled(); + + map.fire("movestart"); + expect(moveStart).toHaveBeenCalledOnce(); + }); + }); + + describe("markers", () => { + it("lets input through non-interactive markers", async () => { + const { engine, ready } = await createEngine(); + await ready; + + const interactive = document.createElement("div"); + engine.addMarker(interactive, [52, 4], { size: [36, 36] }); + const passive = document.createElement("div"); + engine.addMarker(passive, [52, 4], { + size: [36, 36], + interactive: false, + }); + + expect(interactive.tabIndex).toBe(0); + expect(interactive.style.pointerEvents).toBe(""); + expect(passive.style.pointerEvents).toBe("none"); + }); + }); + + describe("clustering", () => { + const addMarker = ( + engine: MapLibreMapEngine, + location: MapLatLng, + clusterData?: unknown + ) => + engine.addMarker(document.createElement("div"), location, { + size: [48, 48], + cluster: true, + clusterData, + }); + + const iconBuilder = vi.fn((members: MapMarkerHandle[]) => ({ + element: Object.assign(document.createElement("div"), { + textContent: String(members.length), + }), + size: [40, 40] as [number, number], + })); + + beforeEach(() => { + iconBuilder.mockClear(); + }); + + it("shows clusterable markers only once clustering is configured", async () => { + const { engine, ready } = await createEngine(); + await ready; + + addMarker(engine, [52, 4]); + expect(fakeMarker.all).toHaveLength(0); + + engine.setClustering(null); + expect(fakeMarker.all).toHaveLength(1); + }); + + it("groups markers within the radius on screen and leaves the rest alone", async () => { + const { engine, ready } = await createEngine(); + await ready; + + // 20px apart, then 100px further + addMarker(engine, [52, 4.0]); + addMarker(engine, [52, 4.002]); + addMarker(engine, [52, 4.012]); + engine.setClustering({ radius: 40, iconBuilder }); + + expect(iconBuilder).toHaveBeenCalledOnce(); + expect(iconBuilder.mock.calls[0][0]).toHaveLength(2); + // One cluster icon and one lone marker + expect(fakeMarker.all).toHaveLength(2); + }); + + it("groups markers sharing a key while they fit the group radius", async () => { + const { engine, ready } = await createEngine(); + await ready; + + // 100px apart: too far for proximity, close enough for the zone group + addMarker(engine, [52, 4.0], { zone: "home" }); + addMarker(engine, [52, 4.01], { zone: "home" }); + // Same distance, no key: clusters by proximity, so stays alone + addMarker(engine, [52.01, 4.0]); + engine.setClustering({ + radius: 40, + groupRadius: 160, + groupKey: (marker) => + (marker.clusterData as { zone?: string } | undefined)?.zone, + iconBuilder, + }); + + expect(iconBuilder).toHaveBeenCalledOnce(); + expect(iconBuilder.mock.calls[0][0]).toHaveLength(2); + expect(fakeMarker.all).toHaveLength(2); + }); + + it("falls back to proximity when a keyed group is spread too wide", async () => { + const { engine, ready } = await createEngine(); + await ready; + + addMarker(engine, [52, 4.0], { zone: "home" }); + addMarker(engine, [52, 4.05], { zone: "home" }); + engine.setClustering({ + radius: 40, + groupRadius: 160, + groupKey: (marker) => + (marker.clusterData as { zone?: string } | undefined)?.zone, + iconBuilder, + }); + + expect(iconBuilder).not.toHaveBeenCalled(); + expect(fakeMarker.all).toHaveLength(2); + }); + + it("drops removed markers from their cluster on refresh", async () => { + const { engine, ready } = await createEngine(); + await ready; + + addMarker(engine, [52, 4.0]); + const second = addMarker(engine, [52, 4.002]); + engine.setClustering({ radius: 40, iconBuilder }); + expect(iconBuilder.mock.calls[0][0]).toHaveLength(2); + + second.remove(); + engine.refreshClusters(); + // A cluster of one is the marker itself + expect(iconBuilder).toHaveBeenCalledOnce(); + expect(fakeMarker.all).toHaveLength(1); + }); + + const openBubble = () => { + expect(fakeMarker.all).toHaveLength(1); + const [bubble] = fakeMarker.all; + expect(bubble.options.anchor).toBe("bottom"); + return (bubble.options.element as HTMLElement).querySelectorAll( + ".cluster-open-members > *" + ); + }; + + it("opens a cluster whose members share a spot in a bubble", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + + const elements = [ + addMarker(engine, [52, 4]), + addMarker(engine, [52, 4]), + addMarker(engine, [52, 4]), + ]; + engine.setClustering({ radius: 40, iconBuilder }); + expect(fakeMarker.all).toHaveLength(1); + + (iconBuilder.mock.results[0].value.element as HTMLElement).click(); + // Zooming would change nothing; the members themselves sit in a + // bubble pointing at the spot + expect(map.fitBounds).not.toHaveBeenCalled(); + expect(openBubble()).toHaveLength(elements.length); + + // A refresh keeps it open; regrouping after the map moves closes it + engine.refreshClusters(); + expect(openBubble()).toHaveLength(elements.length); + engine.setClustering({ radius: 40, iconBuilder }); + expect(iconBuilder).toHaveBeenCalledTimes(2); + expect(fakeMarker.all).toHaveLength(1); + expect(fakeMarker.all[0].options.anchor).toBeUndefined(); + }); + + it("moves keyboard focus into an opened cluster and back to its icon", async () => { + const { engine, ready } = await createEngine(); + await ready; + + const first = document.createElement("div"); + engine.addMarker(first, [52, 4], { size: [48, 48], cluster: true }); + addMarker(engine, [52, 4]); + engine.setClustering({ radius: 40, iconBuilder }); + const icon = iconBuilder.mock.results[0].value.element as HTMLElement; + + icon.focus(); + icon.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(document.activeElement).toBe(first); + + // The bubble closes on the next regroup; focus lands on the new icon + engine.setClustering({ radius: 40, iconBuilder }); + const reopened = iconBuilder.mock.results[1].value.element as HTMLElement; + expect(document.activeElement).toBe(reopened); + }); + + it("opens a cluster at maximum zoom in a bubble", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + map.zoom = map.getMaxZoom(); + + addMarker(engine, [52, 4.0]); + addMarker(engine, [52, 4.002]); + engine.setClustering({ radius: 40, iconBuilder }); + + (iconBuilder.mock.results[0].value.element as HTMLElement).click(); + expect(map.fitBounds).not.toHaveBeenCalled(); + expect(openBubble()).toHaveLength(2); + }); + + it("zooms in on a cluster's members when it is activated", async () => { + const { engine, map, ready } = await createEngine(); + await ready; + + addMarker(engine, [52, 4.0]); + addMarker(engine, [52, 4.002]); + engine.setClustering({ radius: 40, iconBuilder }); + const icon = iconBuilder.mock.results[0].value.element as HTMLElement; + + icon.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + expect(map.fitBounds).toHaveBeenCalledOnce(); + icon.click(); + expect(map.fitBounds).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/test/components/map/ha-map-engine.test.ts b/test/components/map/ha-map-engine.test.ts new file mode 100644 index 0000000000..4aab89002e --- /dev/null +++ b/test/components/map/ha-map-engine.test.ts @@ -0,0 +1,277 @@ +import type { HassEntities } from "home-assistant-js-websocket"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MapEngine, + MapEngineOptions, + MapMarkerHandle, +} from "../../../src/common/map/map-engine"; +import "../../../src/components/map/ha-map"; +import type { HaMap } from "../../../src/components/map/ha-map"; + +// ha-map picks its engine at runtime: MapLibre GL where WebGL2 is available, +// Leaflet otherwise or after MapLibre fails. jsdom has no WebGL2 and cannot +// run MapLibre, so the probe is stubbed and the MapLibre engine is replaced +// by a fake that records what ha-map asks of it. The Leaflet fallback is +// real, as in the ha-map fit tests. + +const webgl2 = vi.hoisted(() => ({ supported: true })); + +vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({ + ...(await importOriginal>()), + supportsWebGL2: () => webgl2.supported, +})); + +const fakeEngine = vi.hoisted(() => { + class FakeMapLibreEngine implements MapEngine { + static instances: FakeMapLibreEngine[] = []; + + static failInit = false; + + /** When set, init waits for it, so events can be fired mid-setup */ + static initGate?: Promise; + + options?: MapEngineOptions; + + constructor() { + FakeMapLibreEngine.instances.push(this); + } + + /** Settles a pending init, as the real engine's destroy does */ + private _settleInit?: () => void; + + init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => { + this.options = options; + await Promise.race([ + FakeMapLibreEngine.initGate, + new Promise((resolve) => { + this._settleInit = resolve; + }), + ]); + if (FakeMapLibreEngine.failInit) { + throw new Error("WebGL context refused"); + } + }); + + destroy = vi.fn(() => { + this._settleInit?.(); + }); + + invalidateSize = vi.fn(); + + hasUsableSize = () => true; + + setDarkMode = vi.fn(); + + setZoomControlPosition = vi.fn(); + + setScaleRuler = vi.fn(); + + setView = vi.fn(); + + setZoom = vi.fn(); + + fitBounds = vi.fn(); + + panTo = vi.fn(); + + containsLocation = () => true; + + addMarker = vi.fn((_element, location, options): MapMarkerHandle => ({ + location, + clusterData: options.clusterData, + remove: vi.fn(), + })); + + addCircle = vi.fn(() => ({ remove: vi.fn() })); + + addPath = vi.fn(() => ({ remove: vi.fn() })); + + setClustering = vi.fn(); + + refreshClusters = vi.fn(); + } + return FakeMapLibreEngine; +}); + +vi.mock("../../../src/common/map/engines/maplibre-map-engine", () => ({ + MapLibreMapEngine: fakeEngine, +})); + +class MockResizeObserver { + observe = vi.fn(); + + unobserve = vi.fn(); + + disconnect = vi.fn(); +} + +const STATES = { + "device_tracker.paulus": { + entity_id: "device_tracker.paulus", + state: "not_home", + attributes: { + friendly_name: "Paulus", + latitude: 52.372, + longitude: 4.89, + }, + context: { id: "1", user_id: null, parent_id: null }, + last_changed: "2026-01-01T00:00:00Z", + last_updated: "2026-01-01T00:00:00Z", + }, + "device_tracker.anne_therese": { + entity_id: "device_tracker.anne_therese", + state: "not_home", + attributes: { + friendly_name: "Anne Therese", + latitude: 52.377, + longitude: 4.895, + }, + context: { id: "2", user_id: null, parent_id: null }, + last_changed: "2026-01-01T00:00:00Z", + last_updated: "2026-01-01T00:00:00Z", + }, +} as unknown as HassEntities; + +const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap; +const isLoaded = (el: HaMap) => (el as any)._loaded as boolean; +const entityHandles = (el: HaMap) => + (el as any)._entityHandles as MapMarkerHandle[]; + +const createMap = async (): Promise => { + const el = document.createElement("ha-map"); + el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"]; + el.clusterMarkers = false; + (el as any)._states = STATES; + (el as any)._config = { + config: { latitude: 52.3731339, longitude: 4.8903147 }, + }; + document.body.appendChild(el); + await vi.waitUntil(() => isLoaded(el)); + await el.updateComplete; + return el; +}; + +describe("ha-map engine selection", () => { + beforeEach(() => { + vi.stubGlobal("ResizeObserver", MockResizeObserver); + webgl2.supported = true; + fakeEngine.failInit = false; + fakeEngine.initGate = undefined; + fakeEngine.instances.length = 0; + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + it("runs on the MapLibre engine when WebGL2 is available", async () => { + const el = await createMap(); + + expect(fakeEngine.instances).toHaveLength(1); + const engine = fakeEngine.instances[0]; + expect(engine.init).toHaveBeenCalledOnce(); + expect(engine.options?.rasterOnly).toBeFalsy(); + expect(leafletMap(el)).toBeUndefined(); + // Entities are drawn through the engine + expect(engine.addMarker).toHaveBeenCalledTimes(2); + expect(entityHandles(el)).toHaveLength(2); + }); + + it("runs on Leaflet when WebGL2 is not available", async () => { + webgl2.supported = false; + const el = await createMap(); + + expect(fakeEngine.instances).toHaveLength(0); + expect(leafletMap(el)).toBeDefined(); + expect(entityHandles(el)).toHaveLength(2); + }); + + it("falls back to Leaflet when the MapLibre engine cannot start", async () => { + fakeEngine.failInit = true; + const el = await createMap(); + + // MapLibre was tried once, torn down, then abandoned + expect(fakeEngine.instances).toHaveLength(1); + expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce(); + expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce(); + expect(leafletMap(el)).toBeDefined(); + // Entities are drawn on the fallback + expect(entityHandles(el)).toHaveLength(2); + }); + + it("falls back to Leaflet when the engine fails while still setting up", async () => { + let openGate!: () => void; + fakeEngine.initGate = new Promise((resolve) => { + openGate = resolve; + }); + const el = document.createElement("ha-map"); + el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"]; + el.clusterMarkers = false; + (el as any)._states = STATES; + (el as any)._config = { + config: { latitude: 52.3731339, longitude: 4.8903147 }, + }; + document.body.appendChild(el); + await vi.waitUntil(() => fakeEngine.instances[0]?.options); + const engine = fakeEngine.instances[0]; + + // The context is lost before init has resolved, and init never would + // resolve on its own: tearing the engine down is what settles it + engine.options!.events.fatal!(); + expect(isLoaded(el)).toBe(false); + + await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el)); + await el.updateComplete; + // The failed engine was never installed, and the fallback drew the map + expect(engine.destroy).toHaveBeenCalled(); + expect(fakeEngine.instances).toHaveLength(1); + expect(entityHandles(el)).toHaveLength(2); + openGate(); + }); + + it("tears down an engine still setting up when disconnected, and sets up again on reconnect", async () => { + let openGate!: () => void; + fakeEngine.initGate = new Promise((resolve) => { + openGate = resolve; + }); + const el = document.createElement("ha-map"); + el.entities = ["device_tracker.paulus"]; + el.clusterMarkers = false; + (el as any)._states = STATES; + (el as any)._config = { + config: { latitude: 52.3731339, longitude: 4.8903147 }, + }; + document.body.appendChild(el); + await vi.waitUntil(() => fakeEngine.instances[0]?.options); + + el.remove(); + expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce(); + openGate(); + fakeEngine.initGate = undefined; + + document.body.appendChild(el); + await vi.waitUntil(() => isLoaded(el)); + // The abandoned engine was not installed; a fresh one was set up + expect(fakeEngine.instances).toHaveLength(2); + expect(fakeEngine.instances[1].init).toHaveBeenCalledOnce(); + expect(fakeEngine.instances[1].destroy).not.toHaveBeenCalled(); + }); + + it("rebuilds on Leaflet after a fatal engine failure", async () => { + const el = await createMap(); + const engine = fakeEngine.instances[0]; + const handlesBefore = entityHandles(el); + + engine.options!.events.fatal!(); + await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el)); + await el.updateComplete; + + expect(engine.destroy).toHaveBeenCalledOnce(); + // Only one MapLibre attempt; the rebuild went straight to Leaflet + expect(fakeEngine.instances).toHaveLength(1); + // Entities are redrawn on the new engine, not carried over + expect(entityHandles(el)).toHaveLength(2); + expect(entityHandles(el)).not.toBe(handlesBefore); + }); +});