From ac582cbfbbc5893ba9df591cb8672a5703f87f02 Mon Sep 17 00:00:00 2001 From: Maarten Lakerveld Date: Wed, 9 Sep 2026 14:26:28 +0200 Subject: [PATCH] Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet (#54022) * Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet ha-map no longer drives Leaflet directly. All primitive operations - camera, HTML element markers, meter-radius circles, history paths, clustering with a pluggable icon builder, scale ruler, dark mode - go through a MapEngine interface, and the engine is selected at runtime: - MapLibreMapEngine renders the vector base map natively where WebGL2 is available, without Leaflet in the loop: continuous fractional zoom, DOM markers synced to the basemap every frame, GeoJSON zone and accuracy circles below the labels, GeoJSON history trails with hover popups, and a pixel-distance cluster grid recomputed when the camera settles. Dark mode swaps the style while carrying the custom layers over, and rotation and pitch stay disabled for north-up dashboards. - LeafletMapEngine wraps the existing behavior unchanged (vector tiles through the maplibre adapter with the raster fallback, markercluster) for browsers without WebGL2 - the legacy floor includes iOS 12-14 and kiosk browsers without hardware acceleration - and for ha-locations-editor, which manages raw Leaflet layers with leaflet-draw and declares engine=leaflet. A permanently lost WebGL context rebuilds the map on the Leaflet engine instead of leaving a dead canvas. Zoom levels keep Leaflet semantics across engines. MapLibre's stylesheet is shipped to /static/map for the native engine's controls and popups. * Enhance keyboard accessibility for icon click Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix dark mode style application logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Address code review on the map engine A fatal engine event during setup was dropped because the in-flight setup owned the loading flag; the fallback request is now recorded and setup switches to Leaflet once init settles. Entity markers accept Enter and Space, as both engines make them focusable buttons. History points show their popup on tap as well as hover, since touch has no hover. Tests cover the engine choice and fallback paths, including a fatal event mid-setup, and the MapLibre engine itself against a fake map: style swaps, queued layer work, custom layer carry-over, failed style requests, token refusal recovery, context-loss grace, and clustering. * Address second code review round on the map engine A cluster whose members share a spot, or that sits at maximum zoom, could never be opened: fitting its bounds changed nothing and the next regroup recreated it. Activating such a cluster now opens it: all its members are shown in a bubble with a tail pointing at their spot, each reachable on its own, until the map moves again. An engine whose init failed, or was abandoned by a disconnect or a fatal event during setup, was never destroyed and could keep a WebGL context; setup now destroys any engine that did not become the active one. The Leaflet engine sizes the cluster element like MapLibre does, so cluster bubbles no longer shrink to their text. * Keep the engine's own record of its map sources and layers Carrying zone circles and history paths over a style swap relied on MapLibre serializing the outgoing style into transformStyle's previous argument. The engine now keeps the source and layer specifications it added and rebuilds them into the new style itself, so the carry-over no longer depends on what MapLibre hands over, and a missing previous style cannot drop them. * Address review: setup teardown, marker input, cluster focus An engine still setting up could not be torn down: ha-map only held it once init resolved, so removing the element mid-load kept its WebGL context alive and left the loading guard set, ignoring a reconnect. The engine being set up is now tracked and destroyed on disconnect, a stale setup notices it was superseded, and the MapLibre engine settles a pending init when destroyed. Non-interactive markers let pointer input through, as they do on Leaflet. Opening a cluster from the keyboard moves focus to its first member, and a member that has focus when the bubble closes hands it to the icon that replaces it. * Tear down an engine that fails while still setting up A fatal event during setup only flagged the fallback and waited for init to settle, which relied on MapLibre still firing style.load. The engine being set up is destroyed instead, which settles its init, so the setup hands over to Leaflet without depending on the failed engine. * Make history point popups readable on MapLibre MapLibre's stylesheet is linked into the map's shadow root and wins over the component styles on equal specificity, property by property: its white popup background applied while our white text color did too, leaving the timestamp invisible. The popup rules now carry !important, as the Leaflet tooltip rules already do for the same reason. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- build-scripts/gulp/gather-static.js | 2 + src/common/map/base-layer.ts | 20 +- src/common/map/engines/leaflet-map-engine.ts | 350 ++++++ src/common/map/engines/maplibre-map-engine.ts | 1021 +++++++++++++++++ src/common/map/map-engine.ts | 207 ++++ src/common/map/marker-accessibility.ts | 23 + src/components/map/ha-entity-marker.ts | 18 + src/components/map/ha-locations-editor.ts | 5 +- src/components/map/ha-map.ts | 762 +++++++----- src/panels/lovelace/cards/hui-map-card.ts | 4 +- test/common/map/base-layer.test.ts | 9 + test/common/map/maplibre-map-engine.test.ts | 789 +++++++++++++ test/components/map/ha-map-engine.test.ts | 277 +++++ 13 files changed, 3157 insertions(+), 330 deletions(-) create mode 100644 src/common/map/engines/leaflet-map-engine.ts create mode 100644 src/common/map/engines/maplibre-map-engine.ts create mode 100644 src/common/map/map-engine.ts create mode 100644 src/common/map/marker-accessibility.ts create mode 100644 test/common/map/maplibre-map-engine.test.ts create mode 100644 test/components/map/ha-map-engine.test.ts 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); + }); +});