Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 6ad4fb6161 Test engine selection and the Leaflet fallback
jsdom has no WebGL2, so the existing ha-map tests only ever run Leaflet.
With the WebGL2 probe stubbed and the MapLibre engine replaced by a fake,
this covers the runtime choice: MapLibre with WebGL2, Leaflet without it,
Leaflet after MapLibre fails to start, and a rebuild on Leaflet with the
entities redrawn after a fatal engine event.
2026-09-07 12:30:58 +02:00
03b01e3b05 Fix dark mode style application logic
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 12:26:43 +02:00
510ba20b59 Enhance keyboard accessibility for icon click
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 12:26:43 +02:00
Maarten Lakerveld 50b80585f3 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 12:25:37 +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
17 changed files with 2146 additions and 504 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",
+1 -1
View File
@@ -29,7 +29,7 @@ export const computeEntityEntryName = (
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ??
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
+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,339 @@
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";
/** 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;
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive: options.interactive ?? true,
keyboard: options.interactive ?? true,
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,
]);
// 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();
}
}
@@ -0,0 +1,931 @@
import type { Feature, FeatureCollection, Polygon } from "geojson";
import type {
IControl,
LayerSpecification,
Map as MapLibreMap,
MapLayerMouseEvent,
Marker as MapLibreMarker,
StyleSpecification,
} from "maplibre-gl";
import type maplibregl from "maplibre-gl";
import {
CONTEXT_RESTORE_GRACE,
ensureRTLTextPlugin,
loadStyle,
MAP_MAX_ZOOM,
MAP_MIN_ZOOM,
RECOVERY_THROTTLE,
VECTOR_STYLES,
} from "../base-layer";
import {
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../../data/map_tiles";
import { isTouch } from "../../../util/is_touch";
import type {
MapCircleOptions,
MapClusterOptions,
MapControlPosition,
MapEngine,
MapEngineEvents,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
type MapLibreModule = typeof maplibregl;
// MapLibre zoom is one level below Leaflet's, which the interface uses
const ZOOM_OFFSET = 1;
// Marks the sources and layers this engine owns, to carry them over style swaps
const CUSTOM_PREFIX = "ha-map-";
const POSITIONS: Record<
MapControlPosition,
"top-left" | "top-right" | "bottom-left" | "bottom-right"
> = {
topleft: "top-left",
topright: "top-right",
bottomleft: "bottom-left",
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
// Roughly one zoom level per two wheel notches (MapLibre's default is 1/450)
const WHEEL_ZOOM_RATE = 1 / 200;
// Regroup clusters once continuous zooming settles, not on every wheel notch
const CLUSTER_REBUILD_DELAY = 120;
// Without these MapLibre sets aria-label "Map marker" and role button
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");
}
}
};
// A meter-radius circle as a polygon (spherical approximation)
const circlePolygon = (
center: MapLatLng,
radiusMeters: number
): Feature<Polygon> => {
const steps = 64;
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
const ring: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const theta = (2 * Math.PI * i) / steps;
ring.push([
center[1] + lngOffset * Math.sin(theta),
center[0] + latOffset * Math.cos(theta),
]);
}
return {
type: "Feature",
properties: {},
geometry: { type: "Polygon", coordinates: [ring] },
};
};
interface ManagedMarker {
element: HTMLElement;
location: MapLatLng;
options: MapMarkerOptions;
handle: MapMarkerHandle;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
}
interface ClusterGroup {
members: ManagedMarker[];
center: MapLatLng;
iconMarker?: MapLibreMarker;
}
/**
* The MapLibre GL engine: native vector rendering, requires WebGL2. The host
* falls back to Leaflet without it or after a fatal context loss.
*/
export class MapLibreMapEngine implements MapEngine {
private _maplibre?: MapLibreModule;
private _map?: MapLibreMap;
private _events: Partial<MapEngineEvents> = {};
private _zoomControl?: IControl;
private _scaleControl?: IControl;
private _markers: ManagedMarker[] = [];
private _clusterOptions: MapClusterOptions | null = null;
private _clusterGroups: ClusterGroup[] = [];
private _clusterRebuildTimeout?: number;
private _idCounter = 0;
// Carried over style swaps, which replace all sources and layers
private _customSources = new Set<string>();
private _customLayers = new Set<string>();
// The only custom layers that take clicks (hover tooltips)
private _pathPointLayers = new Set<string>();
// A style swap the differ cannot apply rebuilds the style, which is unloaded
// until the next frame and throws on mutation; layer work queues until then
private _pendingStyleOps: (() => void)[] = [];
// A failed style request rolls back to the applied mode, not the requested one
private _appliedDarkMode = false;
private _requestedDarkMode = false;
private _latestStyleRequest = 0;
private _contextLost = false;
private _fallbackTimeout?: number;
private _destroyed = false;
private _refused = false;
private _unsubscribeToken?: () => void;
private _resizing = false;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
if (options.rasterOnly) {
throw new Error("The MapLibre engine cannot render without WebGL");
}
const maplibre = (await import("maplibre-gl")).default;
this._maplibre = maplibre;
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
// MapLibre's stylesheet for controls and popups; one link per root
const root = container.parentNode;
if (root && !root.querySelector(`link[href="${MAPLIBRE_CSS_URL}"]`)) {
const style = document.createElement("link");
style.setAttribute("href", MAPLIBRE_CSS_URL);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
this._appliedDarkMode = options.darkMode;
this._requestedDarkMode = options.darkMode;
this._events = options.events;
const style = await loadStyle(
VECTOR_STYLES[options.darkMode ? "dark" : "light"]
);
if (this._destroyed) {
return;
}
const map = new maplibre.Map({
container,
style,
center: [options.center[1], options.center[0]],
zoom: options.zoom - ZOOM_OFFSET,
minZoom: MAP_MIN_ZOOM - ZOOM_OFFSET,
maxZoom: MAP_MAX_ZOOM - ZOOM_OFFSET,
// Dashboards are north-up; no rotate or pitch gestures
dragRotate: false,
pitchWithRotate: false,
touchPitch: false,
// Rendered with a device font, so these glyphs are never requested
localIdeographFontFamily: "sans-serif",
// Inline on wide maps, collapsible (open by default) on narrow ones
attributionControl: {},
// Proxied by core behind a token; absolute so the worker can resolve them
transformRequest: (url) => ({
url: withMapTilesToken(url),
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
});
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
map.scrollZoom.setWheelZoomRate(WHEEL_ZOOM_RATE);
this._map = map;
// A refused TileJSON is never retried, so the style is applied again with
// a new token. 403: stale token, 404: proxy not registered yet during a
// restart, no status: network. Throttled so another refusal cannot loop.
let lastRecovery = 0;
map.on("error", (event) => {
const status = (event.error as { status?: number } | undefined)?.status;
if (status !== undefined && status !== 403 && status !== 404) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
this._refused = true;
refreshMapTilesToken();
});
// Only a new token clears a refusal; a theme change in between is refused too
this._unsubscribeToken = subscribeMapTilesToken(() => {
if (this._refused) {
this._refused = false;
this._applyStyle(this._requestedDarkMode);
}
});
this._zoomControl = new maplibre.NavigationControl({ showCompass: false });
map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]);
map.on("click", (ev) => {
// Clicks on path points (they have tooltips) are not map clicks
const pathHit = map
.queryRenderedFeatures(ev.point)
.some((feature) => this._pathPointLayers.has(feature.layer.id));
if (!pathHit) {
this._events.click?.([ev.lngLat.lat, ev.lngLat.lng]);
}
});
map.on("zoomstart", () => this._events.zoomStart?.());
map.on("movestart", () => {
// resize() fires movestart even when nothing changed (see _resize)
if (!this._resizing) {
this._events.moveStart?.();
}
});
// Grouping depends on screen distances; regroup when the camera settles
map.on("moveend", () => {
if (this._clusterOptions) {
clearTimeout(this._clusterRebuildTimeout);
this._clusterRebuildTimeout = window.setTimeout(() => {
this._rebuildClusters();
}, CLUSTER_REBUILD_DELAY);
}
});
// A lost WebGL context gets a grace period to come back before falling back
map.on("webglcontextlost", () => {
this._contextLost = true;
this._scheduleFatal();
});
map.on("webglcontextrestored", () => {
this._contextLost = false;
clearTimeout(this._fallbackTimeout);
});
document.addEventListener("visibilitychange", this._handleVisibility);
// Sources and layers can be added once the style is applied; it was
// fetched above, so this cannot fail
await new Promise<void>((resolve) => {
if (map.isStyleLoaded()) {
resolve();
return;
}
map.once("styledata", () => resolve());
});
}
private _handleVisibility = () => {
if (this._contextLost) {
this._scheduleFatal();
}
};
private _scheduleFatal(): void {
clearTimeout(this._fallbackTimeout);
// Backgrounding drops the context too, and there it comes back on return
if (this._destroyed || document.hidden) {
return;
}
this._fallbackTimeout = window.setTimeout(() => {
this._events.fatal?.();
}, CONTEXT_RESTORE_GRACE);
}
public destroy(): void {
this._destroyed = true;
this._unsubscribeToken?.();
clearTimeout(this._fallbackTimeout);
clearTimeout(this._clusterRebuildTimeout);
document.removeEventListener("visibilitychange", this._handleVisibility);
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
this._clusterGroups = [];
this._markers = [];
this._pendingStyleOps = [];
this._map?.remove();
this._map = undefined;
}
public invalidateSize(): void {
this._resize();
}
public hasUsableSize(): boolean {
if (!this._map) {
return false;
}
const container = this._map.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
this._resize();
return true;
}
return false;
}
// MapLibre's resize fires movestart/move/moveend synchronously even when the
// size did not change; hosts must not read them as camera movement
private _resize(): void {
if (!this._map) {
return;
}
this._resizing = true;
try {
this._map.resize();
} finally {
this._resizing = false;
}
}
public setDarkMode(darkMode: boolean): void {
if (!this._map || darkMode === this._requestedDarkMode) {
return;
}
this._requestedDarkMode = darkMode;
this._applyStyle(darkMode);
}
private _applyStyle(darkMode: boolean): void {
const request = ++this._latestStyleRequest;
loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"])
.then((style) => {
if (request === this._latestStyleRequest && this._map) {
this._map.setStyle(style, {
transformStyle: (previous, next) =>
this._carryCustomLayers(previous, next),
});
this._appliedDarkMode = darkMode;
}
})
.catch(() => {
if (request === this._latestStyleRequest) {
this._requestedDarkMode = this._appliedDarkMode;
}
});
}
private _carryCustomLayers(
previous: StyleSpecification | undefined,
next: StyleSpecification
): StyleSpecification {
if (!previous) {
return next;
}
const sources = { ...next.sources };
for (const [id, source] of Object.entries(previous.sources ?? {})) {
if (this._customSources.has(id)) {
sources[id] = source;
}
}
const nextIds = new Set(next.layers.map((layer) => layer.id));
const customLayers = (previous.layers ?? []).filter(
(layer) => this._customLayers.has(layer.id) && !nextIds.has(layer.id)
);
const layers = [...next.layers];
const symbolIndex = layers.findIndex((layer) => layer.type === "symbol");
layers.splice(
symbolIndex === -1 ? layers.length : symbolIndex,
0,
...customLayers
);
return { ...next, sources, layers };
}
public setZoomControlPosition(position: MapControlPosition): void {
if (!this._map || !this._zoomControl) {
return;
}
this._map.removeControl(this._zoomControl);
this._map.addControl(this._zoomControl, POSITIONS[position]);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this._map?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this._map || !this._maplibre) {
return;
}
this._scaleControl = new this._maplibre.ScaleControl({
unit: options.metric ? "metric" : "imperial",
});
this._map.addControl(this._scaleControl, "bottom-left");
}
public setView(center: MapLatLng, zoom?: number): void {
this._map?.jumpTo({
center: [center[1], center[0]],
zoom: zoom !== undefined ? zoom - ZOOM_OFFSET : undefined,
});
}
public setZoom(zoom: number): void {
this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET });
}
private _getMaxZoom(): number {
return (this._map?.getMaxZoom() ?? 0) + ZOOM_OFFSET;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this._map!.project([location[1], location[0]]);
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this._map || !this._maplibre || !points.length) {
return;
}
let minLat = points[0][0];
let maxLat = points[0][0];
let minLng = points[0][1];
let maxLng = points[0][1];
for (const [lat, lng] of points) {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
}
const maxZoom =
options?.maxZoom !== undefined
? options.maxZoom - ZOOM_OFFSET
: undefined;
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point
this._map.easeTo({
center: [minLng, minLat],
zoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
});
return;
}
const pad = options?.pad ?? 0.5;
const latPad = (maxLat - minLat) * pad;
const lngPad = (maxLng - minLng) * pad;
this._map.fitBounds(
[
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
],
{ maxZoom, animate: options?.animate }
);
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
element.style.width = `${options.size[0]}px`;
element.style.height = `${options.size[1]}px`;
if (options.title) {
element.title = options.title;
}
if (options.interactive ?? true) {
element.tabIndex = 0;
}
setMarkerAccessibility(element, options.title, options.interactive ?? true);
const managed: ManagedMarker = {
element,
location,
options,
handle: undefined as unknown as MapMarkerHandle,
};
managed.handle = {
location,
clusterData: options.clusterData,
remove: () => {
managed.removed = true;
this._hideMarker(managed);
const index = this._markers.indexOf(managed);
if (index !== -1) {
this._markers.splice(index, 1);
}
},
};
this._markers.push(managed);
if (!options.cluster) {
// Clusterable markers appear on the next setClustering call
this._showMarker(managed);
}
return managed.handle;
}
private _showMarker(managed: ManagedMarker): void {
if (!this._map || !this._maplibre || managed.removed) {
return;
}
if (!managed.mlMarker) {
const { options } = managed;
managed.mlMarker = new this._maplibre.Marker({
element: managed.element,
...(options.anchor
? {
anchor: "top-left" as const,
offset: [-options.anchor[0], -options.anchor[1]] as [
number,
number,
],
}
: {}),
})
.setLngLat([managed.location[1], managed.location[0]])
.addTo(this._map);
}
if (managed.options.decoration && !managed.decoration) {
managed.decoration = this.addCircle(
managed.location,
managed.options.decoration
);
}
}
private _hideMarker(managed: ManagedMarker): void {
managed.mlMarker?.remove();
managed.mlMarker = undefined;
managed.decoration?.remove();
managed.decoration = undefined;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
if (!this._map) {
return { remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`;
this._addCustomSource(id, circlePolygon(center, options.radius));
this._addCustomLayer({
id: `${id}-fill`,
type: "fill",
source: id,
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
});
this._addCustomLayer({
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": options.color, "line-width": 3 },
});
return {
remove: () => {
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
this._removeCustomSource(id);
},
};
}
public addPath(path: MapPath): MapItemHandle {
if (!this._map || !this._maplibre) {
return { remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`;
const lines: FeatureCollection = {
type: "FeatureCollection",
features: path.segments.map((segment) => ({
type: "Feature",
properties: { opacity: segment.opacity ?? 1 },
geometry: {
type: "LineString",
coordinates: segment.points.map((point) => [point[1], point[0]]),
},
})),
};
const points: FeatureCollection = {
type: "FeatureCollection",
features: path.markers.map((pathMarker) => ({
type: "Feature",
properties: {
opacity: pathMarker.opacity ?? 1,
tooltip: pathMarker.tooltipHtml,
},
geometry: {
type: "Point",
coordinates: [pathMarker.location[1], pathMarker.location[0]],
},
})),
};
this._addCustomSource(`${id}-lines`, lines);
this._addCustomSource(`${id}-points`, points);
this._addCustomLayer({
id: `${id}-lines`,
type: "line",
source: `${id}-lines`,
paint: {
"line-color": path.color,
"line-width": 3,
"line-opacity": ["get", "opacity"],
},
});
this._addCustomLayer({
id: `${id}-points`,
type: "circle",
source: `${id}-points`,
paint: {
"circle-radius": isTouch ? 8 : 3,
"circle-color": path.color,
"circle-opacity": ["get", "opacity"],
"circle-stroke-width": 2,
"circle-stroke-color": path.color,
"circle-stroke-opacity": ["get", "opacity"],
},
});
const map = this._map;
const popup = new this._maplibre.Popup({
closeButton: false,
closeOnClick: false,
offset: 10,
});
const layerId = `${id}-points`;
const onEnter = (ev: MapLayerMouseEvent) => {
const feature = ev.features?.[0];
if (!feature || feature.geometry.type !== "Point") {
return;
}
map.getCanvas().style.cursor = "pointer";
popup
.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(feature.properties?.tooltip ?? "")
.addTo(map);
};
const onLeave = () => {
map.getCanvas().style.cursor = "";
popup.remove();
};
map.on("mouseenter", layerId, onEnter);
map.on("mouseleave", layerId, onLeave);
this._pathPointLayers.add(layerId);
return {
remove: () => {
map.off("mouseenter", layerId, onEnter);
map.off("mouseleave", layerId, onLeave);
this._pathPointLayers.delete(layerId);
popup.remove();
this._removeCustomLayer(`${id}-lines`);
this._removeCustomLayer(`${id}-points`);
this._removeCustomSource(`${id}-lines`);
this._removeCustomSource(`${id}-points`);
},
};
}
// Runs now, or once the style has loaded; getStyle() is undefined until then
private _whenStyleLoaded(operation: () => void): void {
const map = this._map;
if (!map) {
return;
}
if (map.getStyle()) {
operation();
return;
}
if (!this._pendingStyleOps.length) {
map.once("style.load", () => {
const operations = this._pendingStyleOps;
this._pendingStyleOps = [];
operations.forEach((pending) => pending());
});
}
this._pendingStyleOps.push(operation);
}
private _addCustomSource(
id: string,
data: Feature<Polygon> | FeatureCollection
): void {
this._whenStyleLoaded(() => {
this._map!.addSource(id, { type: "geojson", data });
this._customSources.add(id);
});
}
private _addCustomLayer(layer: LayerSpecification): void {
this._whenStyleLoaded(() => {
// Under the labels, over the base cartography
const symbolLayer = this._map!.getStyle().layers.find(
(styleLayer) =>
styleLayer.type === "symbol" && !this._customLayers.has(styleLayer.id)
);
this._map!.addLayer(layer, symbolLayer?.id);
this._customLayers.add(layer.id);
});
}
private _removeCustomLayer(id: string): void {
this._whenStyleLoaded(() => {
if (this._map!.getLayer(id)) {
this._map!.removeLayer(id);
}
this._customLayers.delete(id);
});
}
private _removeCustomSource(id: string): void {
this._whenStyleLoaded(() => {
if (this._map!.getSource(id)) {
this._map!.removeSource(id);
}
this._customSources.delete(id);
});
}
public setClustering(options: MapClusterOptions | null): void {
this._clusterOptions = options;
this._rebuildClusters();
}
public refreshClusters(): void {
this._rebuildClusters(false);
}
// Groups clusterable markers by screen distance
private _rebuildClusters(regroup = true): void {
if (!this._map) {
return;
}
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
const clusterable = this._markers.filter(
(managed) => managed.options.cluster && !managed.removed
);
if (!this._clusterOptions) {
this._clusterGroups = [];
clusterable.forEach((managed) => this._showMarker(managed));
return;
}
if (!regroup) {
// Markers removed since the last grouping leave their groups
this._clusterGroups = this._clusterGroups
.map((group) => ({
...group,
members: group.members.filter((managed) => !managed.removed),
}))
.filter((group) => group.members.length);
}
if (regroup || !this._clusterGroups.length) {
const { radius, groupKey, groupRadius } = this._clusterOptions;
const groups: {
seed: { x: number; y: number };
members: ManagedMarker[];
}[] = [];
// Keyed groups first; one spread too wide falls through to proximity
const ungrouped: ManagedMarker[] = [];
if (groupKey) {
const byKey: Record<string, ManagedMarker[]> = {};
for (const managed of clusterable) {
const key = groupKey(managed.handle);
if (key === undefined) {
ungrouped.push(managed);
} else {
(byKey[key] ??= []).push(managed);
}
}
for (const members of Object.values(byKey)) {
const points = members.map((m) => this._project(m.location));
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const spread = Math.hypot(
Math.max(...xs) - Math.min(...xs),
Math.max(...ys) - Math.min(...ys)
);
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
groups.push({ seed: points[0], members });
} else {
ungrouped.push(...members);
}
}
} else {
ungrouped.push(...clusterable);
}
for (const managed of ungrouped) {
const point = this._project(managed.location);
const group = groups.find(
(candidate) =>
Math.hypot(
candidate.seed.x - point.x,
candidate.seed.y - point.y
) <= radius
);
if (group) {
group.members.push(managed);
} else {
groups.push({ seed: point, members: [managed] });
}
}
this._clusterGroups = groups.map((group) => ({
members: group.members,
center: [
group.members.reduce((sum, m) => sum + m.location[0], 0) /
group.members.length,
group.members.reduce((sum, m) => sum + m.location[1], 0) /
group.members.length,
] as MapLatLng,
}));
}
for (const group of this._clusterGroups) {
group.iconMarker = undefined;
if (group.members.length === 1) {
this._showMarker(group.members[0]);
continue;
}
group.members.forEach((managed) => this._hideMarker(managed));
const icon = this._clusterOptions.iconBuilder(
group.members.map((managed) => managed.handle),
group.center
);
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// Clicking a bubble zooms in on its members
const zoomToMembers = () => {
this.fitBounds(
group.members.map((managed) => managed.location),
{ pad: 0.3, maxZoom: this._getMaxZoom() }
);
};
icon.element.tabIndex = 0;
setMarkerAccessibility(
icon.element,
group.members
.map((managed) => managed.options.title)
.filter(Boolean)
.join(", ") || undefined,
true
);
icon.element.addEventListener("click", (ev) => {
ev.stopPropagation();
zoomToMembers();
});
icon.element.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
zoomToMembers();
}
});
const location = icon.location ?? group.center;
group.iconMarker = new this._maplibre!.Marker({
element: icon.element,
...(icon.anchor
? {
anchor: "top-left" as const,
offset: [-icon.anchor[0], -icon.anchor[1]] as [number, number],
}
: {}),
})
.setLngLat([location[1], location[0]])
.addTo(this._map);
}
}
}
+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],
];
};
+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}
+382 -314
View File
@@ -1,18 +1,7 @@
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
Circle,
CircleMarker,
Control,
LatLngExpression,
LatLngTuple,
Layer,
Map,
Marker,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { Layer } from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -21,16 +10,25 @@ import {
formatTimeWeekday,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { UNIT_KM } from "../../common/const";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { ensureMapTilesToken } from "../../data/map_tiles";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type { LeafletMapEngine } from "../../common/map/engines/leaflet-map-engine";
import type {
MapClusterIcon,
MapEngine,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapPath,
MapPathMarker,
MapPathSegment,
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
@@ -40,6 +38,7 @@ import {
statesContext,
uiContext,
} from "../../data/context";
import { ensureMapTilesToken } from "../../data/map_tiles";
import type {
HomeAssistantConfig,
HomeAssistantConnection,
@@ -48,10 +47,7 @@ import type {
HomeAssistantUI,
ThemeMode,
} from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { UNIT_KM } from "../../common/const";
declare global {
// for fire event
@@ -66,7 +62,7 @@ const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
export interface HaMapPathPoint {
point: LatLngTuple;
point: MapLatLng;
timestamp: Date;
}
export interface HaMapPaths {
@@ -96,6 +92,8 @@ export interface HaMapEntity {
focus?: boolean;
}
const CLUSTER_RADIUS = 40;
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@state()
@@ -129,8 +127,18 @@ export class HaMap extends ReactiveElement {
@property({ attribute: false }) public paths?: HaMapPaths[];
/**
* Raw Leaflet layers, for ha-locations-editor only. Requires
* engine="leaflet".
*/
@property({ attribute: false }) public layers?: Layer[];
/**
* Which map engine to use. "leaflet" is required by embedders that manage
* raw Leaflet layers (ha-locations-editor); "auto" picks the best engine.
*/
@property() public engine: "auto" | "leaflet" = "auto";
@property({ type: Boolean }) public clickable = false;
@property({ attribute: "auto-fit", type: Boolean }) public autoFit = false;
@@ -158,27 +166,25 @@ export class HaMap extends ReactiveElement {
@query("#map") private _mapElement?: HTMLElement;
public leafletMap?: Map;
private _engine?: MapEngine;
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
/** Escape hatch for ha-locations-editor; only set with engine="leaflet" */
public get leafletMap() {
// Only the Leaflet engine has this property
return (this._engine as Partial<LeafletMapEngine> | undefined)?.leafletMap;
}
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
private _entityHandles: MapMarkerHandle[] = [];
private _mapFocusItems: (Marker | Circle)[] = [];
private _zoneHandles: MapItemHandle[] = [];
private _mapZones: DecoratedMarker[] = [];
private _pathHandles: MapItemHandle[] = [];
private _mapFocusZones: (Marker | Circle)[] = [];
private _focusPoints: MapLatLng[] = [];
private _mapCluster: MarkerClusterGroup | undefined;
private _scaleRulerControl?: Control.Scale;
private _mapPaths: (Polyline | CircleMarker)[] = [];
private _focusZonePoints: MapLatLng[] = [];
private _clickCount = 0;
@@ -211,16 +217,16 @@ export class HaMap extends ReactiveElement {
"visibilitychange",
this._handleVisibilityChange
);
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
}
this._engine?.destroy();
this._engine = undefined;
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._focusPoints = [];
this._focusZonePoints = [];
// the control went away with the map, so don't hold on to it
this._scaleRulerControl = undefined;
this._pendingFit = undefined;
this._hasFitted = false;
this._loaded = false;
if (this._resizeObserver) {
@@ -281,11 +287,9 @@ export class HaMap extends ReactiveElement {
}
if (changedProps.has("zoom")) {
this._isProgrammaticFit = true;
this.leafletMap!.setZoom(this.zoom);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
this._withProgrammaticFit(() => {
this._engine!.setZoom(this.zoom);
});
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
@@ -313,76 +317,139 @@ export class HaMap extends ReactiveElement {
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._baseLayer?.setDarkMode(this._darkMode);
this._engine?.setDarkMode(this._darkMode);
}
private _loading = false;
private async _loadMap(): Promise<void> {
if (this._loading) return;
let map = this.shadowRoot!.getElementById("map");
if (!map) {
map = document.createElement("div");
map.id = "map";
this.shadowRoot!.append(map);
private _forceLeaflet = false;
// Each engine is its own chunk; a map only downloads the one it uses
private async _createEngine(): Promise<MapEngine> {
if (this.engine === "leaflet" || this._forceLeaflet || !supportsWebGL2()) {
const leaflet =
await import("../../common/map/engines/leaflet-map-engine");
return new leaflet.LeafletMapEngine();
}
const maplibre =
await import("../../common/map/engines/maplibre-map-engine");
return new maplibre.MapLibreMapEngine();
}
// An engine that cannot start hands over to the Leaflet fallback
private async _loadMap(): Promise<void> {
try {
await this._setUpEngine();
} catch (err) {
if (!this.isConnected) {
return;
}
if (this._forceLeaflet || !supportsWebGL2()) {
// Already on the fallback; nothing left to try
throw err;
}
this._forceLeaflet = true;
await this._loadMap();
}
}
private async _setUpEngine(): Promise<void> {
if (this._loading) return;
// A fresh container per engine; engines leave state on the element they used
this.shadowRoot!.getElementById("map")?.remove();
const map = document.createElement("div");
map.id = "map";
this.shadowRoot!.append(map);
this._loading = true;
try {
// The tiles are proxied by core behind a token, so nothing loads without
// one. A host that provides no connection, or a backend without the
// proxy, leaves the map without tiles rather than failing to set up.
// Without a connection or the tile proxy the map sets up without tiles
const token = this._connection
? await ensureMapTilesToken(this._connection.connection)
: undefined;
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
const engine = await this._createEngine();
await engine.init(map, {
center: [
this._config?.latitude ?? 52.3731339,
this._config?.longitude ?? 4.8903147,
],
zoom: this.zoom,
darkMode: this._darkMode,
token,
rasterOnly: this._forceLeaflet,
zoomControlPosition: "topleft",
events: {
click: (location) => this._handleEngineClick(location),
zoomStart: () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
},
moveStart: () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
},
fatal: () => this._handleEngineFatal(),
},
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
// would leave a live map - and its WebGL context - on a detached host,
// and its container too initialized to set up again on reconnect.
// Disconnected while the style was loading; disconnectedCallback had
// nothing to tear down yet
if (!this.isConnected) {
setup.map.remove();
engine.destroy();
return;
}
this.leafletMap = setup.map;
this.Leaflet = setup.leaflet;
this._baseLayer = setup.baseLayer;
this._engine = engine;
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
setTimeout(() => {
if (this._clickCount === 1) {
fireEvent(this, "map-clicked", {
location: [ev.latlng.lat, ev.latlng.lng],
});
}
this._clickCount = 0;
}, 250);
}
this._clickCount++;
});
this.leafletMap.on("zoomstart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this.leafletMap.on("movestart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this._loaded = true;
} finally {
this._loading = false;
}
}
// Rebuild on the Leaflet fallback after a fatal engine failure
private _handleEngineFatal(): void {
if (this._forceLeaflet) {
return;
}
this._forceLeaflet = true;
this._engine?.destroy();
this._engine = undefined;
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._focusPoints = [];
this._focusZonePoints = [];
this._pendingFit = undefined;
this._hasFitted = false;
this._loaded = false;
this._loadMap();
}
private _handleEngineClick(location: MapLatLng): void {
// Fire only for single clicks, not for the two of a double-click zoom
if (this._clickCount === 0) {
setTimeout(() => {
if (this._clickCount === 1) {
fireEvent(this, "map-clicked", { location });
}
this._clickCount = 0;
}, 250);
}
this._clickCount++;
}
// The first fit after load jumps to the content; later fits animate
private _hasFitted = false;
private _withProgrammaticFit(fit: () => void): void {
this._isProgrammaticFit = true;
fit();
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
public fitMap(options?: {
zoom?: number;
pad?: number;
@@ -391,7 +458,7 @@ export class HaMap extends ReactiveElement {
if (options?.unpause_autofit) {
this._pauseAutoFit = false;
}
if (!this.leafletMap || !this.Leaflet || !this._config) {
if (!this._engine || !this._config) {
return;
}
@@ -400,60 +467,53 @@ export class HaMap extends ReactiveElement {
}
if (
!this._mapFocusItems.length &&
!this._mapFocusZones.length &&
!this._focusPoints.length &&
!this._focusZonePoints.length &&
!this.layers?.length
) {
this._isProgrammaticFit = true;
this.leafletMap.setView(
new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
options?.zoom || this.zoom
);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
this._withProgrammaticFit(() => {
this._engine!.setView(
[this._config.latitude, this._config.longitude],
options?.zoom || this.zoom
);
});
this._hasFitted = true;
return;
}
let bounds = this.Leaflet.latLngBounds(
this._mapFocusItems
? this._mapFocusItems.map((item) => item.getLatLng())
: []
);
const points = [...this._focusPoints, ...this._focusZonePoints];
this._mapFocusZones?.forEach((zone) => {
bounds.extend("getBounds" in zone ? zone.getBounds() : zone.getLatLng());
// Raw Leaflet layers (ha-locations-editor) contribute their bounds
const leafletMap = this.leafletMap;
if (this.layers?.length && leafletMap) {
this.layers.forEach((layer: any) => {
if ("getBounds" in layer) {
const layerBounds = layer.getBounds();
points.push(
[layerBounds.getSouth(), layerBounds.getWest()],
[layerBounds.getNorth(), layerBounds.getEast()]
);
} else if ("getLatLng" in layer) {
const latLng = layer.getLatLng();
points.push([latLng.lat, latLng.lng]);
}
});
}
this._withProgrammaticFit(() => {
this._engine!.fitBounds(points, {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
});
});
this.layers?.forEach((layer: any) => {
bounds.extend(
"getBounds" in layer ? layer.getBounds() : layer.getLatLng()
);
});
bounds = bounds.pad(options?.pad ?? 0.5);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
this._hasFitted = true;
}
// Leaflet derives the zoom level that fits given bounds from the current
// size of the map container. When the container has not been laid out yet,
// that size is 0x0 and the computed zoom collapses to the minimum, leaving
// the map zoomed out to the world even after the container gets its size.
// Defer fitting until the resize observer reports a usable size.
// Fitting uses the container size; before layout it is 0x0 and the zoom
// collapses to the minimum, so defer until the resize observer reports one
private _deferIfUnsized(fit: () => void): boolean {
const size = this.leafletMap!.getSize();
if (size.x > 0 && size.y > 0) {
this._pendingFit = undefined;
return false;
}
const container = this.leafletMap!.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it.
this.leafletMap!.invalidateSize(false);
if (this._engine!.hasUsableSize()) {
this._pendingFit = undefined;
return false;
}
@@ -462,11 +522,10 @@ export class HaMap extends ReactiveElement {
}
private _runPendingFit(): void {
if (!this._pendingFit || !this.leafletMap) {
if (!this._pendingFit || !this._engine) {
return;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
if (this._engine.hasUsableSize()) {
const pendingFit = this._pendingFit;
this._pendingFit = undefined;
pendingFit();
@@ -474,23 +533,23 @@ export class HaMap extends ReactiveElement {
}
public fitBounds(
boundingbox: LatLngExpression[],
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
if (!this.leafletMap || !this.Leaflet) {
if (!this._engine) {
return;
}
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
return;
}
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
options?.pad ?? 0.5
);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
this._withProgrammaticFit(() => {
this._engine!.fitBounds(boundingbox, {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
});
});
this._hasFitted = true;
}
private _drawLayers(prevLayers: Layer[] | undefined): void {
@@ -500,9 +559,12 @@ export class HaMap extends ReactiveElement {
if (!this.layers) {
return;
}
const map = this.leafletMap!;
const leafletMap = this.leafletMap;
if (!leafletMap) {
return;
}
this.layers.forEach((layer) => {
map.addLayer(layer);
leafletMap.addLayer(layer);
});
}
@@ -531,16 +593,12 @@ export class HaMap extends ReactiveElement {
}
private _drawPaths(): void {
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
if (!this._i18n || !this._config || !map || !Leaflet) {
if (!this._i18n || !this._config || !this._engine) {
return;
}
if (this._mapPaths.length) {
this._mapPaths.forEach((marker) => marker.remove());
this._mapPaths = [];
if (this._pathHandles.length) {
this._pathHandles.forEach((handle) => handle.remove());
this._pathHandles = [];
}
if (!this.paths) {
return;
@@ -558,6 +616,9 @@ export class HaMap extends ReactiveElement {
baseOpacity = 1 - path.gradualOpacity;
}
const segments: MapPathSegment[] = [];
const markers: MapPathMarker[] = [];
for (
let pointIndex = 0;
pointIndex < path.points.length - 1;
@@ -570,30 +631,19 @@ export class HaMap extends ReactiveElement {
const thisPoint = path.points[pointIndex];
const nextPoint = path.points[pointIndex + 1];
// DRAW point
this._mapPaths.push(
Leaflet.circleMarker(thisPoint.point, {
radius: isTouch ? 8 : 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: true,
}).bindTooltip(this._computePathTooltip(path, thisPoint), {
direction: "top",
})
);
markers.push({
location: thisPoint.point,
opacity,
tooltipHtml: this._computePathTooltip(path, thisPoint),
});
// DRAW line between this and next point
if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) {
// if the path does not cross the antimeridian, draw a simple line
// between the two points
this._mapPaths.push(
Leaflet.polyline([thisPoint.point, nextPoint.point], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
})
);
segments.push({
points: [thisPoint.point, nextPoint.point],
opacity,
});
} else {
// if the path crosses the antimeridian, split the line into two, to
// avoid it being drawn across the entire map
@@ -614,29 +664,23 @@ export class HaMap extends ReactiveElement {
longitudeDifference;
}
const intersectionPoint1: LatLngTuple = [
const intersectionPoint1: MapLatLng = [
intersectionLatitude,
thisPoint.point[1] > 0 ? 180 : -180,
];
const intersectionPoint2: LatLngTuple = [
const intersectionPoint2: MapLatLng = [
intersectionLatitude,
nextPoint.point[1] > 0 ? 180 : -180,
];
this._mapPaths.push(
Leaflet.polyline([thisPoint.point, intersectionPoint1], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
})
);
this._mapPaths.push(
Leaflet.polyline([intersectionPoint2, nextPoint.point], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
})
);
segments.push({
points: [thisPoint.point, intersectionPoint1],
opacity,
});
segments.push({
points: [intersectionPoint2, nextPoint.point],
opacity,
});
}
}
const pointIndex = path.points.length - 1;
@@ -644,52 +688,40 @@ export class HaMap extends ReactiveElement {
const opacity = path.gradualOpacity
? baseOpacity! + pointIndex * opacityStep!
: undefined;
// DRAW end path point
this._mapPaths.push(
Leaflet.circleMarker(path.points[pointIndex].point, {
radius: isTouch ? 8 : 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: true,
}).bindTooltip(
this._computePathTooltip(path, path.points[pointIndex]),
{ direction: "top" }
)
);
markers.push({
location: path.points[pointIndex].point,
opacity,
tooltipHtml: this._computePathTooltip(path, path.points[pointIndex]),
});
}
this._mapPaths.forEach((marker) => map.addLayer(marker));
const enginePath: MapPath = {
color: path.color || darkPrimaryColor,
segments,
markers,
};
this._pathHandles.push(this._engine!.addPath(enginePath));
});
}
private _drawEntities(): void {
const states = this._states;
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
const engine = this._engine;
if (!states || !map || !Leaflet) {
if (!states || !engine) {
return;
}
if (this._mapItems.length) {
this._mapItems.forEach((marker) => marker.remove());
this._mapItems = [];
this._mapFocusItems = [];
}
this._entityHandles.forEach((handle) => handle.remove());
this._entityHandles = [];
this._focusPoints = [];
if (this._mapZones.length) {
this._mapZones.forEach((marker) => marker.remove());
this._mapZones = [];
this._mapFocusZones = [];
}
if (this._mapCluster) {
this._mapCluster.remove();
this._mapCluster = undefined;
}
this._zoneHandles.forEach((handle) => handle.remove());
this._zoneHandles = [];
this._focusZonePoints = [];
if (!this.entities) {
engine.setClustering(null);
return;
}
@@ -703,8 +735,6 @@ export class HaMap extends ReactiveElement {
"--dark-primary-color"
);
const className = this._darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
if (!stateObj) {
@@ -724,6 +754,7 @@ export class HaMap extends ReactiveElement {
continue;
}
const { latitude, longitude, gpsAccuracy } = location;
const position: MapLatLng = [latitude, longitude];
if (computeStateDomain(stateObj) === "zone") {
// DRAW ZONE
@@ -731,42 +762,61 @@ export class HaMap extends ReactiveElement {
continue;
}
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
if (radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: zoneMarkerColor })
);
}
// create icon
let iconHTML: string;
const iconEl = document.createElement("div");
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconHTML = el.outerHTML;
iconEl.appendChild(el);
} else {
const el = document.createElement("span");
el.textContent = title;
iconHTML = el.outerHTML;
iconEl.appendChild(el);
}
// create circle around it
const circle = Leaflet.circle([latitude, longitude], {
interactive: false,
color: passive ? passiveZoneColor : zoneColor,
radius,
});
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
ev.stopPropagation();
fireEvent(this, "hass-more-info", {
entityId: stateObj.entity_id,
});
};
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
}
});
}
const markerIconSize = this._getMarkerSize(computedStyles) / 2;
const marker = new DecoratedMarker([latitude, longitude], circle, {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [markerIconSize, markerIconSize],
className,
}),
interactive: this.interactiveZones,
title,
});
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
interactive: this.interactiveZones,
title,
})
);
this._mapZones.push(marker);
if (
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
this._mapFocusZones.push(circle);
if (radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
}
}
continue;
@@ -810,64 +860,50 @@ export class HaMap extends ReactiveElement {
entityMarker.entityColor = entity.color;
}
// create marker with the icon
const markerSize = this._getMarkerSize(computedStyles);
const marker = new DecoratedMarker([latitude, longitude], undefined, {
icon: Leaflet.divIcon({
html: entityMarker,
iconSize: [markerSize, markerSize],
className: "",
}),
title: title,
});
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
title,
cluster: true,
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
: undefined,
})
);
if (typeof entity === "string" || entity.focus !== false) {
this._mapFocusItems.push(marker);
this._focusPoints.push(position);
}
// create circle around if entity has accuracy
if (gpsAccuracy) {
marker.decorationLayer = Leaflet.circle([latitude, longitude], {
interactive: false,
color: darkPrimaryColor,
radius: gpsAccuracy,
});
}
this._mapItems.push(marker);
}
if (this.clusterMarkers) {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: 40,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
} else {
this._mapItems.forEach((marker) => map.addLayer(marker));
}
this._mapZones.forEach((marker) => map.addLayer(marker));
engine.setClustering(
this.clusterMarkers
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
: null
);
}
// Renders a marker cluster as a circle with the member count
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
const size = Math.round(
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
);
const element = document.createElement("div");
element.className = "marker-cluster";
const count = document.createElement("span");
count.textContent = String(members.length);
element.appendChild(count);
return { element, size: [size, size] };
};
private _drawScaleRuler(): void {
if (this._scaleRulerControl) {
this.leafletMap?.removeControl(this._scaleRulerControl);
this._scaleRulerControl = undefined;
}
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
return;
}
const metric = this._config?.unit_system?.length === UNIT_KM;
this._scaleRulerControl = this.Leaflet.control.scale({
position: "bottomleft",
metric,
imperial: !metric,
});
this._scaleRulerControl.addTo(this.leafletMap);
this._engine?.setScaleRuler(
this.scaleRuler
? { metric: this._config?.unit_system?.length === UNIT_KM }
: null
);
}
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
@@ -880,7 +916,7 @@ export class HaMap extends ReactiveElement {
private async _attachObserver(): Promise<void> {
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(() => {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
this._engine?.invalidateSize();
this._runPendingFit();
});
}
@@ -920,8 +956,8 @@ export class HaMap extends ReactiveElement {
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The only two rules the MapLibre canvas needs from its stylesheet, the
rest of it styles controls and popups we do not render. */
/* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre
stylesheet; these are the only two rules its canvas needs. */
.maplibregl-map {
position: relative;
overflow: hidden;
@@ -931,6 +967,31 @@ export class HaMap extends ReactiveElement {
top: 0;
left: 0;
}
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
background-color: #1c1c1c;
}
.dark .maplibregl-ctrl-group button + button {
border-top-color: #313131;
}
.dark .maplibregl-ctrl button .maplibregl-ctrl-icon {
filter: invert(1);
}
.maplibregl-popup-content {
padding: 8px;
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-body);
background: rgba(80, 80, 80, 0.9);
color: white;
border-radius: var(--ha-border-radius-sm);
box-shadow: none;
text-align: center;
}
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
border-top-color: rgba(80, 80, 80, 0.9);
}
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
border-bottom-color: rgba(80, 80, 80, 0.9);
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -956,6 +1017,17 @@ export class HaMap extends ReactiveElement {
.leaflet-pane {
z-index: 0 !important;
}
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--primary-text-color);
}
.zone-icon.dark {
color: #ffffff;
}
.leaflet-control,
.leaflet-top,
.leaflet-bottom {
@@ -1006,22 +1078,18 @@ export class HaMap extends ReactiveElement {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster div {
.marker-cluster {
box-sizing: border-box;
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
width: calc(var(--ha-marker-size, 48px) * 0.667);
height: calc(var(--ha-marker-size, 48px) * 0.667);
border-radius: 50%;
text-align: center;
align-content: center;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
.marker-cluster span {
line-height: var(--ha-line-height-expanded);
}
`;
}
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiContentCopy, mdiPencil, mdiRestore } from "@mdi/js";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -8,10 +8,7 @@ import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatNumber } from "../../../common/number/format_number";
@@ -24,13 +21,10 @@ import type {
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
import "../../../components/ha-labels-picker";
@@ -199,8 +193,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _name!: string;
@state() private _useDeviceName = false;
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@@ -262,9 +254,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
this._device = this.entry.device_id
? this.hass.devices[this.entry.device_id]
: undefined;
if (
!changedProperties.has("entry") ||
changedProperties.get("entry")?.id === this.entry.id
@@ -273,8 +262,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
this._name = this.entry.name || "";
this._useDeviceName =
!!this._device && !computeEntityEntryName(this.entry, this.hass.devices);
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
@@ -284,6 +271,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._entityId = this.entry.entity_id;
this._disabledBy = this.entry.disabled_by;
this._hiddenBy = this.entry.hidden_by;
this._device = this.entry.device_id
? this.hass.devices[this.entry.device_id]
: undefined;
this._switchAsInvert = this.entry.options?.switch_as_x?.invert === true;
const domain = computeDomain(this.entry.entity_id);
@@ -396,7 +386,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._computeName(),
name: this._name || null,
icon: this._icon || null,
entityId: this._entityId,
areaId: this._areaId ?? null,
@@ -474,74 +464,41 @@ export class EntityRegistrySettingsEditor extends LitElement {
const defaultPrecision =
this.entry.options?.sensor?.suggested_display_precision ?? undefined;
const deviceName =
this._device && this._useDeviceName
? computeDeviceNameDisplay(
this._device,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
${
this.hideName
? nothing
: html`<div
class="name-field"
role="group"
aria-label=${this.hass.localize(
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
>
${
deviceName !== undefined
? html`<div class="device-name" id="entity-name">
<span class="device-name-label">
${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
</span>
<span class="device-name-value" title=${deviceName}>
${deviceName}
</span>
<ha-icon-button
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.edit_device_name"
)}
.disabled=${this.disabled}
@click=${this._openDeviceSettings}
></ha-icon-button>
</div>`
: html`<ha-input
inset-label
class="name"
id="entity-name"
.value=${this._name}
.placeholder=${this.entry.original_name || ""}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
></ha-input>`
}
${
this._device
? html`<ha-checkbox
.checked=${this._useDeviceName}
.disabled=${this.disabled}
aria-controls="entity-name"
@change=${this._useDeviceNameChanged}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.use_device_name"
)}
</ha-checkbox>`
? html`<span slot="hint"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_name_tip",
{
link: html`<button
class="link"
@click=${this._resetNameAndOpenDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.open_device_settings"
)}
</button>`,
}
)}</span
>`
: nothing
}
</div>`
</ha-input>`
}
${
this.hideIcon
@@ -1288,7 +1245,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._computeName(),
name: this._name.trim() || null,
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
@@ -1735,23 +1692,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _useDeviceNameChanged(
ev: HASSDomCurrentTargetEvent<HaCheckbox>
): void {
this._useDeviceName = ev.currentTarget.checked;
}
private _computeName(): string | null {
if (this.hideName) {
return this.entry.name;
}
if (this._device && this._useDeviceName) {
// Preserve entities that already use the device name without an override.
return computeEntityEntryName(this.entry, this.hass.devices)
? ""
: this.entry.name;
}
return this._name.trim() || null;
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
this._openDeviceSettings();
}
private _openDeviceSettings() {
@@ -1855,8 +1798,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
ha-input.entityId,
ha-input.name,
.device-name {
ha-input.name {
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
}
@@ -1864,60 +1806,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
ha-input.name {
--ha-input-start-max-width: 35%;
}
.name-field {
margin: var(--ha-space-2) 0;
}
.name-field ha-input {
margin: 0;
--ha-input-padding-bottom: 0;
}
.device-name {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
height: 56px;
padding-inline: var(--ha-space-4);
background: var(--ha-color-form-background);
border-radius: var(--ha-border-radius-sm) var(--ha-border-radius-sm) 0
0;
box-shadow: inset 0 -1px var(--ha-color-border-neutral-quiet);
font-family: var(--ha-font-family-body);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
cursor: default;
}
.device-name-label {
position: absolute;
top: var(--ha-space-3);
inset-inline-start: var(--ha-space-4);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-xs);
}
.device-name-value {
flex: 1;
min-width: 0;
padding-top: var(--ha-space-3);
color: var(--primary-text-color);
font-size: var(--ha-font-size-m);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
}
.device-name ha-icon-button {
flex: none;
color: var(--secondary-text-color);
}
.name-field ha-checkbox {
display: block;
width: fit-content;
padding-inline: var(--ha-space-4);
--wa-form-control-label-font-size: var(--ha-font-size-s);
}
.name-field ha-checkbox::part(base) {
min-height: 36px;
}
ha-input.entityId ha-icon-button:last-child {
margin-inline-start: 0;
}
+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);
}
-2
View File
@@ -1939,8 +1939,6 @@
"faq": "documentation",
"editor": {
"name": "Name",
"use_device_name": "Use device name",
"edit_device_name": "Edit device name",
"icon": "Icon",
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
"default_code": "Default code",
@@ -8,7 +8,6 @@ import * as computeStateNameModule from "../../../src/common/entity/compute_stat
import * as stripPrefixModule from "../../../src/common/entity/strip_prefix_from_entity_name";
import type { HomeAssistant } from "../../../src/types";
import {
mockDevice,
mockEntity,
mockEntityEntry,
mockStateObj,
@@ -133,20 +132,6 @@ describe("computeEntityEntryName", () => {
expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name");
});
it("preserves an explicitly empty name instead of the integration name", () => {
const entry = mockEntityEntry({
device_id: "dev1",
name: "",
original_name: "Temperature",
});
const devices = { dev1: mockDevice({ id: "dev1", name: "Living room" }) };
expect(computeEntityEntryName(entry, devices)).toBe("");
expect(computeEntityEntryName({ ...entry, name: null }, devices)).toBe(
"Temperature"
);
});
it("returns undefined if no name, original_name, or device", () => {
const entry = mockEntity({ entity_id: "light.kitchen" });
const hass = {
+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(
+203
View File
@@ -0,0 +1,203 @@
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;
options?: MapEngineOptions;
constructor() {
FakeMapLibreEngine.instances.push(this);
}
init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => {
this.options = options;
if (FakeMapLibreEngine.failInit) {
throw new Error("WebGL context refused");
}
});
destroy = vi.fn();
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.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, then abandoned
expect(fakeEngine.instances).toHaveLength(1);
expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce();
expect(leafletMap(el)).toBeDefined();
// Entities are drawn on the fallback
expect(entityHandles(el)).toHaveLength(2);
});
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"