mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-28 07:58:45 +01:00
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>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
dab85a9239
commit
ac582cbfbb
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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],
|
||||
];
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,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}
|
||||
|
||||
+445
-317
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user