Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 7b6dba7777 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.
2026-09-07 16:17:19 +02:00
Maarten Lakerveld 7e2fd53b68 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.
2026-09-07 16:01:52 +02:00
Maarten Lakerveld ecb36c6144 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.
2026-09-07 16:01:52 +02:00
Maarten Lakerveld eea77277cd 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.
2026-09-07 14:55:45 +02:00
Maarten Lakerveld 7494c2b917 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.
2026-09-07 14:42:37 +02:00
c94227e830 Fix dark mode style application logic
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 14:03:14 +02:00
d43178310b Enhance keyboard accessibility for icon click
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 14:03:14 +02:00
Maarten Lakerveld e8f7cc545e 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.
2026-09-07 14:03:14 +02:00
9bffae3ee4 Exit scene live mode when leaving the editor (#53837)
* Exit scene live mode when leaving the editor

Leaving the page previously only unsubscribed, so live-activated
device states stayed behind. Restore stored states unless the tab
is hidden for panel suspend.

* Drop extra unit tests for scene live-mode disconnect.

* Restore scene live states only on editor disconnect.

Back and delete already unmount the editor, which now exits live
mode, so a second applyScene was redundant.

* Resubscribe to scene live edits after hidden-tab suspend.

Existing scenes stayed in live mode when the panel was reattached,
but the state_changed subscription was not restored.

* Keep the scene live subscription across hidden-tab suspend.

Unsubscribing and resubscribing on reattach races the closed
websocket; leaving the subscription in place lets the library
restore it after reconnect.

* Tear down scene live mode on hidden-tab route changes.

document.hidden is not enough: saving a new scene remounts a
new editor. Skip teardown only when an ancestor was detached
(panel suspend). Reuse an existing live subscription on
reattach so new scenes do not get duplicate listeners.

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-07 12:08:46 +02:00
renovate[bot]andGitHub a287aacd98 Update dependency @codemirror/state to v6.7.3 (#54020) 2026-09-07 11:05:14 +01:00
16 changed files with 3179 additions and 344 deletions
+2
View File
@@ -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) {
+1 -1
View File
@@ -48,7 +48,7 @@
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.2",
"@codemirror/state": "6.7.3",
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
+11 -9
View File
@@ -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<StyleSpecification> => {
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
// 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<MapBaseLayer> => {
if (supportsWebGL2()) {
if (!rasterOnly && supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
@@ -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<void> {
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();
}
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -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<MapEngineEvents>;
}
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<void>;
/** 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],
];
};
+23
View File
@@ -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");
}
}
};
+18
View File
@@ -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) {
+3 -2
View File
@@ -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`
<ha-map
engine="leaflet"
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
File diff suppressed because it is too large Load Diff
+17 -8
View File
@@ -221,7 +221,19 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubscribeEvents) {
// Hidden-tab panel suspend detaches an ancestor and later reattaches
// this same instance. Direct removal (route change) has no parentNode,
// so we still tear down even if the tab is hidden — e.g. saving a new
// scene remounts the itemId editor.
// Leave the live subscription in place on suspend so the websocket
// library can restore it after reconnect; a fresh subscribe here races
// the closed socket.
if (document.hidden && this.parentNode) {
return;
}
if (this._mode === "live" && this.hass) {
this._exitLiveMode();
} else if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
@@ -911,11 +923,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
if (this._unsubscribeEvents) {
return;
}
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live") {
if (!this.isConnected || this._mode !== "live" || this._unsubscribeEvents) {
unsubscribe();
return;
}
@@ -1092,9 +1107,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
};
private _goBack(): void {
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
afterNextRender(() => goBack("/config/scene/dashboard"));
}
@@ -1119,9 +1131,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
await deleteScene(this.hass, this.sceneId);
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
goBack("/config/scene/dashboard");
}
+2 -2
View File
@@ -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);
}
+9
View File
@@ -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(
+789
View File
@@ -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<string, { layer?: string; handler: Listener }[]> = {};
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<Record<string, unknown>>()),
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<void>((resolve) => {
setTimeout(resolve, 0);
});
const createEngine = async (events: Partial<MapEngineEvents> = {}) => {
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<typeof fakeMap>) =>
Object.keys(map.style.sources).filter((id) => id.startsWith("ha-"));
const layerIds = (map: InstanceType<typeof fakeMap>) =>
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);
});
});
});
+277
View File
@@ -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<Record<string, unknown>>()),
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<void>;
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<void>((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<HaMap> => {
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<void>((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<void>((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);
});
});
+5 -5
View File
@@ -2430,12 +2430,12 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/state@npm:6.7.2, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
version: 6.7.2
resolution: "@codemirror/state@npm:6.7.2"
"@codemirror/state@npm:6.7.3, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
version: 6.7.3
resolution: "@codemirror/state@npm:6.7.3"
dependencies:
"@marijn/find-cluster-break": "npm:^1.0.0"
checksum: 10/363a6217e6d34ce406abca93ae7b87181e93e308be5ee037bec911b9d8c7bf6559de7e85620460bc1b7c1a590c05d75bc64e3b09d76fd82110ab829237e3fde2
checksum: 10/04e6b758d5d45e6e6b343d9f1916d0d1f7577c7c5842a81354ebf2412f160235ee736d0d2258241e3a8ba2457276c5cc8793fa385cff0d6d7032d81991652eba
languageName: node
linkType: hard
@@ -10350,7 +10350,7 @@ __metadata:
"@codemirror/language": "npm:6.12.4"
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.2"
"@codemirror/state": "npm:6.7.2"
"@codemirror/state": "npm:6.7.3"
"@codemirror/view": "npm:6.43.11"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"